pi-mono-linear 0.1.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # pi-mono-linear
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ ### Enhanced: auth setup
8
+
9
+ - Added `/linear-auth` command and `linear_configure_auth` tool for masked local API-key capture.
10
+ - Linear tools now prompt for a fresh key and retry once when auth is missing, invalid, or expired.
11
+ - Key setup writes to `~/.pi/agent/auth.json` without returning the key to the model.
12
+
13
+ ## 0.1.0
14
+
15
+ ### Minor Changes
16
+
17
+ ### New Extension: linear
18
+
19
+ - Added native Linear GraphQL tools for workspace metadata, teams, issues, projects, workflow states, labels, users, comments, cycles, and documents.
20
+ - Added mutation tools for creating issues, updating issues, and creating comments.
21
+ - Added bundled `linear` skill that explains when and how to use the native `linear_*` tools.
22
+ - Added authentication support via `LINEAR_API_KEY` and `~/.pi/agent/auth.json` at `.linear.key`.
23
+ - Added README documentation for API key creation, required Linear read/write permissions, and setup/troubleshooting.
package/LICENCE.md ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2026 Emanuel Casco
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # pi-mono-linear
2
+
3
+ A pi extension and skill package that exposes native Linear GraphQL tools for issue, project, team, user, comment, cycle, label, workflow-state, and document workflows.
4
+
5
+ ## Tools
6
+
7
+ ### Workspace and users
8
+
9
+ - `linear_whoami`
10
+ - `linear_workspace_metadata`
11
+ - `linear_list_teams`
12
+ - `linear_get_team`
13
+ - `linear_list_users`
14
+ - `linear_get_user`
15
+
16
+ ### Issues
17
+
18
+ - `linear_list_issues`
19
+ - `linear_get_issue`
20
+ - `linear_search_issues`
21
+ - `linear_list_my_issues`
22
+ - `linear_create_issue`
23
+ - `linear_update_issue`
24
+
25
+ ### Metadata and related records
26
+
27
+ - `linear_list_projects`
28
+ - `linear_get_project`
29
+ - `linear_list_issue_statuses`
30
+ - `linear_get_issue_status`
31
+ - `linear_list_labels`
32
+ - `linear_list_cycles`
33
+ - `linear_list_documents`
34
+ - `linear_get_document`
35
+
36
+ ### Comments
37
+
38
+ - `linear_list_comments`
39
+ - `linear_create_comment`
40
+ - `linear_configure_auth`
41
+
42
+ The package also bundles the `linear` skill under `skills/linear/SKILL.md`.
43
+
44
+ ## Authentication
45
+
46
+ The extension looks for a Linear API key in this order:
47
+
48
+ 1. in-memory key override created by `/linear-auth --force` or `linear_configure_auth`
49
+ 2. `LINEAR_API_KEY` environment variable
50
+ 3. `~/.pi/agent/auth.json` at `.linear.key`
51
+
52
+ If no key is found, or if Linear rejects the key as invalid/expired, the native tools can prompt you with a masked local dialog and store the replacement key without returning it to the model.
53
+
54
+ Do **not** paste API keys into an LLM chat.
55
+
56
+ ### Recommended: native auth command
57
+
58
+ Run this in pi:
59
+
60
+ ```text
61
+ /linear-auth --force
62
+ ```
63
+
64
+ The command shows the Linear API-key URL and required permissions, prompts for the key with masked input, and writes it to `~/.pi/agent/auth.json` at `.linear.key`.
65
+
66
+ The same flow is available to the agent through the `linear_configure_auth` tool. That tool returns only metadata such as `stored: true`; it never returns the key.
67
+
68
+ If a normal `linear_*` request fails because the key is missing, invalid, or expired, the extension retries once after prompting you for a fresh key.
69
+
70
+ ### Option A: environment variable
71
+
72
+ For the current shell session:
73
+
74
+ ```bash
75
+ export LINEAR_API_KEY="lin_api_xxx"
76
+ ```
77
+
78
+ To persist it, add that export to your shell profile (`~/.zshrc`, `~/.bashrc`, etc.) or your preferred secrets manager.
79
+
80
+ ### Option B: pi auth file
81
+
82
+ Create or update `~/.pi/agent/auth.json`:
83
+
84
+ ```json
85
+ {
86
+ "linear": {
87
+ "key": "lin_api_xxx"
88
+ }
89
+ }
90
+ ```
91
+
92
+ Recommended file permissions:
93
+
94
+ ```bash
95
+ mkdir -p ~/.pi/agent
96
+ chmod 700 ~/.pi/agent
97
+ chmod 600 ~/.pi/agent/auth.json
98
+ ```
99
+
100
+ If the file already contains other credentials, merge the `linear.key` entry instead of overwriting the file.
101
+
102
+ ## Creating a Linear API key
103
+
104
+ 1. Open Linear.
105
+ 2. Go to **Settings → Account → Security & Access**.
106
+ 3. Under **Personal API keys**, click **Create key**.
107
+ 4. Name it something recognizable, for example `pi-agent`.
108
+ 5. Choose the minimum access level needed for your workflow.
109
+ 6. If your workspace supports team restrictions, restrict the key to only the teams pi needs.
110
+ 7. Copy the key once and store it via `LINEAR_API_KEY` or `~/.pi/agent/auth.json`.
111
+
112
+ ## Required scopes / permissions
113
+
114
+ Use the least privilege that supports the tools you need:
115
+
116
+ - **Read** is enough for read-only tools such as `linear_workspace_metadata`, `linear_search_issues`, `linear_get_issue`, list tools, and document/comment reads.
117
+ - **Write** is required for mutation tools: `linear_create_issue`, `linear_update_issue`, and `linear_create_comment`.
118
+ - **Admin** is not required for this extension's tools.
119
+
120
+ If you restrict the key to specific teams, the key must include the teams/projects/issues you want to query or mutate.
121
+
122
+ Linear API keys are sent in the `Authorization` header as the raw key value; do not prefix them with `Bearer`.
123
+
124
+ ## Usage tips
125
+
126
+ - Use `linear_workspace_metadata` first when team/project/state/label/user IDs are unknown.
127
+ - Use `linear_search_issues` for keyword lookup.
128
+ - Use `linear_get_issue` before updating an issue or creating a comment.
129
+ - Use `linear_list_issues` for filtered issue lists by team, assignee, status, and limit.
130
+
131
+ ## Troubleshooting
132
+
133
+ ### Missing auth key
134
+
135
+ Set `LINEAR_API_KEY` or add `.linear.key` to `~/.pi/agent/auth.json`.
136
+
137
+ ### Permission errors
138
+
139
+ Confirm the key has the right access level:
140
+
141
+ - Read-only workflows need **Read**.
142
+ - Create/update/comment workflows need **Write**.
143
+ - Team-restricted keys must include the relevant team.
144
+
145
+ ### Custom Linear endpoint
146
+
147
+ By default, the extension uses `https://api.linear.app/graphql`. Override it with:
148
+
149
+ ```bash
150
+ export LINEAR_GRAPHQL_URL="https://api.linear.app/graphql"
151
+ ```
package/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { registerLinearTools } from "./src/linear-tools.js";
3
+
4
+ export default function linearExtension(pi: ExtensionAPI): void {
5
+ registerLinearTools(pi);
6
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "pi-mono-linear",
3
+ "version": "0.1.1",
4
+ "description": "Pi extension and skill for Linear GraphQL tools",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi-skill",
10
+ "linear"
11
+ ],
12
+ "dependencies": {
13
+ "pi-common": "0.1.1"
14
+ },
15
+ "peerDependencies": {
16
+ "@mariozechner/pi-coding-agent": "*",
17
+ "@sinclair/typebox": "*"
18
+ },
19
+ "pi": {
20
+ "extensions": [
21
+ "./index.ts"
22
+ ],
23
+ "skills": [
24
+ "./skills"
25
+ ]
26
+ }
27
+ }
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: linear
3
+ description: Access Linear project management data using native pi tools — issues, projects, teams, users, comments, cycles, labels, workflow states, and documents. Requires a Linear personal API key.
4
+ ---
5
+
6
+ # Linear Integration
7
+
8
+ Use the native `linear_*` tools to read and write Linear data through the Linear GraphQL API.
9
+
10
+ ## Critical Rules
11
+
12
+ - Never ask the user to paste a Linear API key in chat.
13
+ - Never expose the API key inline in shell commands.
14
+ - Before updating an issue or commenting, use `linear_get_issue` to verify the target.
15
+ - When IDs are unknown, use `linear_workspace_metadata` first.
16
+
17
+ ## Authentication
18
+
19
+ The tools read the key from an in-memory override, `LINEAR_API_KEY`, or `~/.pi/agent/auth.json` at `.linear.key`.
20
+
21
+ If auth is missing, invalid, or expired, do **not** ask the user to paste the key in chat. Use the native `linear_configure_auth` tool or ask the user to run `/linear-auth --force`. The prompt is masked and the key is stored by the extension without returning it to the model.
22
+
23
+ ## Tool Workflow
24
+
25
+ - Use `linear_configure_auth` only when auth is missing, invalid, expired, or the user asks to update the key.
26
+ - Use `linear_workspace_metadata` first when team/project/state/label/user IDs are unknown.
27
+ - Use `linear_search_issues` for keyword lookup.
28
+ - Use `linear_get_issue` before updating or commenting.
29
+ - Use `linear_list_issues` for filtered issue lists by team, assignee, status, or limit.
30
+ - Use `linear_create_issue` to create issues once the team ID is known.
31
+ - Use `linear_update_issue` to change title, description, priority, state, or assignee.
32
+ - Use `linear_create_comment` to add Markdown context to an issue.
33
+
34
+ ## Available Tool Groups
35
+
36
+ ### Workspace and users
37
+
38
+ - `linear_whoami`
39
+ - `linear_workspace_metadata`
40
+ - `linear_list_teams`
41
+ - `linear_get_team`
42
+ - `linear_list_users`
43
+ - `linear_get_user`
44
+
45
+ ### Issues
46
+
47
+ - `linear_list_issues`
48
+ - `linear_search_issues`
49
+ - `linear_get_issue`
50
+ - `linear_list_my_issues`
51
+ - `linear_create_issue`
52
+ - `linear_update_issue`
53
+
54
+ ### Projects, states, labels, cycles, documents
55
+
56
+ - `linear_list_projects`
57
+ - `linear_get_project`
58
+ - `linear_list_issue_statuses`
59
+ - `linear_get_issue_status`
60
+ - `linear_list_labels`
61
+ - `linear_list_cycles`
62
+ - `linear_list_documents`
63
+ - `linear_get_document`
64
+
65
+ ### Comments
66
+
67
+ - `linear_list_comments`
68
+ - `linear_create_comment`
69
+ - `linear_configure_auth`
70
+
71
+ ## Priority Values
72
+
73
+ | Value | Label |
74
+ | ----- | ----------- |
75
+ | 0 | No priority |
76
+ | 1 | Urgent |
77
+ | 2 | High |
78
+ | 3 | Medium |
79
+ | 4 | Low |
80
+
81
+ ## Issue Status Types
82
+
83
+ | Type | Meaning |
84
+ | ----------- | --------------------------- |
85
+ | `backlog` | Not yet started, in backlog |
86
+ | `triage` | Needs triage |
87
+ | `unstarted` | Not yet started |
88
+ | `started` | In progress |
89
+ | `completed` | Done |
90
+ | `canceled` | Won't do |
@@ -0,0 +1,190 @@
1
+ import { readAuthToken } from "pi-common/auth";
2
+ import { createTtlCache } from "pi-common/cache";
3
+ import { ApiError } from "pi-common/errors";
4
+ import { createHttpClient, type HttpClient } from "pi-common/http-client";
5
+ import { createRateLimiter, type RateLimiter } from "pi-common/rate-limiter";
6
+ import * as queries from "./linear-queries.js";
7
+
8
+ export interface LinearClientOptions {
9
+ endpoint?: string;
10
+ timeoutMs?: number;
11
+ }
12
+
13
+ export interface ListIssuesOptions {
14
+ teamId?: string;
15
+ assigneeId?: string;
16
+ statusName?: string;
17
+ limit?: number;
18
+ }
19
+
20
+ export interface CreateIssueInput {
21
+ teamId: string;
22
+ title: string;
23
+ description?: string;
24
+ priority?: number;
25
+ assigneeId?: string;
26
+ labelIds?: string[];
27
+ projectId?: string;
28
+ stateId?: string;
29
+ }
30
+
31
+ export interface UpdateIssueInput {
32
+ title?: string;
33
+ description?: string;
34
+ priority?: number;
35
+ stateId?: string;
36
+ assigneeId?: string;
37
+ }
38
+
39
+ type Variables = Record<string, unknown>;
40
+
41
+ interface GraphQlResponse<T> {
42
+ data?: T;
43
+ errors?: Array<{ message?: string; extensions?: unknown }>;
44
+ }
45
+
46
+ const cache = createTtlCache<unknown>({ defaultTtlMs: 60_000, maxEntries: 100 });
47
+
48
+ export class LinearClient {
49
+ private readonly http: HttpClient;
50
+ private readonly limiter: RateLimiter;
51
+
52
+ constructor(options: LinearClientOptions = {}) {
53
+ this.http = createHttpClient({
54
+ baseUrl: options.endpoint ?? process.env.LINEAR_GRAPHQL_URL ?? "https://api.linear.app/graphql",
55
+ timeoutMs: options.timeoutMs ?? 30_000,
56
+ service: "Linear",
57
+ headers: async () => ({ Authorization: await readLinearToken(), "Content-Type": "application/json" }),
58
+ });
59
+ this.limiter = createRateLimiter({ minIntervalMs: 250 });
60
+ }
61
+
62
+ whoami(): Promise<unknown> {
63
+ return this.cached("whoami", () => this.graphql(queries.WHOAMI));
64
+ }
65
+
66
+ workspaceMetadata(): Promise<unknown> {
67
+ return this.cached("workspaceMetadata", () => this.graphql(queries.WORKSPACE_METADATA));
68
+ }
69
+
70
+ listTeams(): Promise<unknown> {
71
+ return this.cached("teams", () => this.graphql(queries.LIST_TEAMS));
72
+ }
73
+
74
+ getTeam(teamId: string): Promise<unknown> {
75
+ return this.cached(`team:${teamId}`, () => this.graphql(queries.GET_TEAM, { id: teamId }));
76
+ }
77
+
78
+ listIssues(options: ListIssuesOptions): Promise<unknown> {
79
+ const variables = { filter: buildIssueFilter(options), first: options.limit ?? 50 };
80
+ return this.cached(`issues:${JSON.stringify(variables)}`, () => this.graphql(queries.LIST_ISSUES, variables));
81
+ }
82
+
83
+ getIssue(issueId: string): Promise<unknown> {
84
+ return this.cached(`issue:${issueId}`, () => this.graphql(queries.GET_ISSUE, { id: issueId }));
85
+ }
86
+
87
+ searchIssues(query: string, limit = 20): Promise<unknown> {
88
+ return this.cached(`search:${query}:${limit}`, () => this.graphql(queries.SEARCH_ISSUES, { term: query, first: limit }));
89
+ }
90
+
91
+ listMyIssues(limit = 50): Promise<unknown> {
92
+ return this.cached(`myIssues:${limit}`, () => this.graphql(queries.LIST_MY_ISSUES, { first: limit }));
93
+ }
94
+
95
+ createIssue(input: CreateIssueInput): Promise<unknown> {
96
+ return this.graphql(queries.CREATE_ISSUE, { input: compact(input) });
97
+ }
98
+
99
+ updateIssue(issueId: string, input: UpdateIssueInput): Promise<unknown> {
100
+ return this.graphql(queries.UPDATE_ISSUE, { id: issueId, input: compact(input) });
101
+ }
102
+
103
+ listProjects(teamId?: string): Promise<unknown> {
104
+ return teamId
105
+ ? this.cached(`teamProjects:${teamId}`, () => this.graphql(queries.LIST_TEAM_PROJECTS, { id: teamId }))
106
+ : this.cached("projects", () => this.graphql(queries.LIST_PROJECTS));
107
+ }
108
+
109
+ getProject(projectId: string): Promise<unknown> {
110
+ return this.cached(`project:${projectId}`, () => this.graphql(queries.GET_PROJECT, { id: projectId }));
111
+ }
112
+
113
+ listIssueStatuses(teamId?: string): Promise<unknown> {
114
+ return teamId
115
+ ? this.cached(`teamStatuses:${teamId}`, () => this.graphql(queries.LIST_TEAM_STATUSES, { id: teamId }))
116
+ : this.cached("statuses", () => this.graphql(queries.LIST_STATUSES));
117
+ }
118
+
119
+ getIssueStatus(stateId: string): Promise<unknown> {
120
+ return this.cached(`status:${stateId}`, () => this.graphql(queries.GET_STATUS, { id: stateId }));
121
+ }
122
+
123
+ listLabels(teamId?: string): Promise<unknown> {
124
+ return teamId
125
+ ? this.cached(`teamLabels:${teamId}`, () => this.graphql(queries.LIST_TEAM_LABELS, { id: teamId }))
126
+ : this.cached("labels", () => this.graphql(queries.LIST_LABELS));
127
+ }
128
+
129
+ listUsers(): Promise<unknown> {
130
+ return this.cached("users", () => this.graphql(queries.LIST_USERS));
131
+ }
132
+
133
+ getUser(userId: string): Promise<unknown> {
134
+ return this.cached(`user:${userId}`, () => this.graphql(queries.GET_USER, { id: userId }));
135
+ }
136
+
137
+ listComments(issueId: string): Promise<unknown> {
138
+ return this.cached(`comments:${issueId}`, () => this.graphql(queries.LIST_COMMENTS, { id: issueId }));
139
+ }
140
+
141
+ createComment(issueId: string, body: string): Promise<unknown> {
142
+ return this.graphql(queries.CREATE_COMMENT, { input: { issueId, body } });
143
+ }
144
+
145
+ listCycles(teamId?: string): Promise<unknown> {
146
+ return teamId
147
+ ? this.cached(`teamCycles:${teamId}`, () => this.graphql(queries.LIST_TEAM_CYCLES, { id: teamId }))
148
+ : this.cached("cycles", () => this.graphql(queries.LIST_CYCLES));
149
+ }
150
+
151
+ listDocuments(projectId?: string): Promise<unknown> {
152
+ return projectId
153
+ ? this.cached(`projectDocuments:${projectId}`, () => this.graphql(queries.LIST_PROJECT_DOCUMENTS, { id: projectId }))
154
+ : this.cached("documents", () => this.graphql(queries.LIST_DOCUMENTS));
155
+ }
156
+
157
+ getDocument(documentId: string): Promise<unknown> {
158
+ return this.cached(`document:${documentId}`, () => this.graphql(queries.GET_DOCUMENT, { id: documentId }));
159
+ }
160
+
161
+ private async graphql<T = unknown>(query: string, variables: Variables = {}): Promise<T> {
162
+ return this.limiter.schedule(async () => {
163
+ const response = await this.http.post<GraphQlResponse<T>>("", { query, variables });
164
+ if (response.errors?.length) {
165
+ throw new ApiError(response.errors[0]?.message ?? "Linear GraphQL error", 200, response.errors, "Linear");
166
+ }
167
+ return response.data as T;
168
+ });
169
+ }
170
+
171
+ private cached<T>(key: string, load: () => Promise<T>): Promise<T> {
172
+ return cache.getOrSet(key, load) as Promise<T>;
173
+ }
174
+ }
175
+
176
+ export function readLinearToken(): Promise<string> {
177
+ return readAuthToken({ envName: "LINEAR_API_KEY", authPath: ["linear", "key"] });
178
+ }
179
+
180
+ function buildIssueFilter(options: ListIssuesOptions): Variables {
181
+ const filter: Variables = {};
182
+ if (options.teamId) filter.team = { id: { eq: options.teamId } };
183
+ if (options.assigneeId) filter.assignee = { id: { eq: options.assigneeId } };
184
+ if (options.statusName) filter.state = { name: { eqIgnoreCase: options.statusName } };
185
+ return filter;
186
+ }
187
+
188
+ function compact<T extends object>(input: T): Partial<T> {
189
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined && value !== null && value !== "")) as Partial<T>;
190
+ }
@@ -0,0 +1,86 @@
1
+ export const WHOAMI = `query { viewer { id name email displayName } }`;
2
+
3
+ export const LIST_TEAMS = `query { teams { nodes { id name key description } } }`;
4
+
5
+ export const GET_TEAM = `query($id: String!) { team(id: $id) { id name key description organization { id name } } }`;
6
+
7
+ export const LIST_ISSUES = `query($filter: IssueFilter, $first: Int) {
8
+ issues(filter: $filter, first: $first, orderBy: updatedAt) {
9
+ nodes { id identifier title description priority url state { id name type } assignee { id name } team { id name key } labels { nodes { id name } } createdAt updatedAt }
10
+ pageInfo { hasNextPage endCursor }
11
+ }
12
+ }`;
13
+
14
+ export const GET_ISSUE = `query($id: String!) {
15
+ issue(id: $id) {
16
+ id identifier title description priority url
17
+ state { id name type }
18
+ assignee { id name email }
19
+ team { id name key }
20
+ project { id name }
21
+ labels { nodes { id name } }
22
+ parent { id identifier title }
23
+ children { nodes { id identifier title } }
24
+ comments { nodes { id body user { id name } createdAt } }
25
+ createdAt updatedAt
26
+ }
27
+ }`;
28
+
29
+ export const SEARCH_ISSUES = `query($term: String!, $first: Int) {
30
+ searchIssues(term: $term, first: $first) {
31
+ nodes { id identifier title description priority url state { id name type } assignee { id name } team { id name key } labels { nodes { id name } } createdAt }
32
+ pageInfo { hasNextPage endCursor }
33
+ }
34
+ }`;
35
+
36
+ export const LIST_MY_ISSUES = `query($first: Int) {
37
+ viewer {
38
+ id name
39
+ assignedIssues(first: $first, orderBy: updatedAt, filter: { state: { type: { neq: "completed" } } }) {
40
+ nodes { id identifier title priority url state { id name type } team { id name key } createdAt }
41
+ pageInfo { hasNextPage endCursor }
42
+ }
43
+ }
44
+ }`;
45
+
46
+ export const CREATE_ISSUE = `mutation($input: IssueCreateInput!) {
47
+ issueCreate(input: $input) { success issue { id identifier title url priority state { id name } } }
48
+ }`;
49
+
50
+ export const UPDATE_ISSUE = `mutation($id: String!, $input: IssueUpdateInput!) {
51
+ issueUpdate(id: $id, input: $input) { success issue { id identifier title priority state { id name } } }
52
+ }`;
53
+
54
+ export const LIST_PROJECTS = `query { projects { nodes { id name description state team { id name key } } } }`;
55
+ export const LIST_TEAM_PROJECTS = `query($id: String!) { team(id: $id) { id name projects { nodes { id name description state } } } }`;
56
+ export const GET_PROJECT = `query($id: String!) { project(id: $id) { id name description state url team { id name key } lead { id name } } }`;
57
+
58
+ export const LIST_STATUSES = `query { workflowStates { nodes { id name type color position team { id name key } } } }`;
59
+ export const LIST_TEAM_STATUSES = `query($id: String!) { team(id: $id) { id name states { nodes { id name type color position } } } }`;
60
+ export const GET_STATUS = `query($id: String!) { workflowState(id: $id) { id name type color position team { id name key } } }`;
61
+
62
+ export const LIST_LABELS = `query { issueLabels { nodes { id name color team { id name key } } } }`;
63
+ export const LIST_TEAM_LABELS = `query($id: String!) { team(id: $id) { id name labels { nodes { id name color } } } }`;
64
+
65
+ export const LIST_USERS = `query { users { nodes { id name email displayName } } }`;
66
+ export const GET_USER = `query($id: String!) { user(id: $id) { id name email displayName } }`;
67
+
68
+ export const LIST_COMMENTS = `query($id: String!) { issue(id: $id) { id identifier comments { nodes { id body user { id name } createdAt } } } }`;
69
+ export const CREATE_COMMENT = `mutation($input: CommentCreateInput!) {
70
+ commentCreate(input: $input) { success comment { id body user { id name } createdAt } }
71
+ }`;
72
+
73
+ export const LIST_CYCLES = `query { cycles(first: 50, orderBy: createdAt) { nodes { id name number startDate endDate completedAt team { id name key } } } }`;
74
+ export const LIST_TEAM_CYCLES = `query($id: String!) { team(id: $id) { id name cycles { nodes { id name number startDate endDate completedAt } } } }`;
75
+
76
+ export const LIST_DOCUMENTS = `query { documents(first: 50, orderBy: updatedAt) { nodes { id title updatedAt project { id name } } } }`;
77
+ export const LIST_PROJECT_DOCUMENTS = `query($id: String!) { project(id: $id) { id name documents { nodes { id title updatedAt } } } }`;
78
+ export const GET_DOCUMENT = `query($id: String!) { document(id: $id) { id title content contentIcon project { id name } updatedAt } }`;
79
+
80
+ export const WORKSPACE_METADATA = `query {
81
+ teams { nodes { id name key } }
82
+ projects { nodes { id name description state team { id name } } }
83
+ workflowStates { nodes { id name type color position team { id name key } } }
84
+ issueLabels { nodes { id name color team { id name key } } }
85
+ users { nodes { id name email displayName } }
86
+ }`;
@@ -0,0 +1,69 @@
1
+ import { Type } from "@sinclair/typebox";
2
+
3
+ export const MaxResponseCharsSchema = Type.Optional(
4
+ Type.Number({ description: "Maximum characters returned to the model before truncation", minimum: 1 }),
5
+ );
6
+
7
+ export const LimitSchema = Type.Optional(Type.Number({ description: "Maximum number of records to fetch", minimum: 1, maximum: 250 }));
8
+ export const TeamIdSchema = Type.String({ description: "Linear team UUID or key where accepted by Linear" });
9
+ export const IssueIdSchema = Type.String({ description: "Linear issue UUID or identifier such as ENG-123" });
10
+ export const UserIdSchema = Type.String({ description: "Linear user UUID" });
11
+ export const ProjectIdSchema = Type.String({ description: "Linear project UUID" });
12
+
13
+ export const EmptyParams = Type.Object({ maxResponseChars: MaxResponseCharsSchema });
14
+ export const OptionalTeamParams = Type.Object({ teamId: Type.Optional(TeamIdSchema), maxResponseChars: MaxResponseCharsSchema });
15
+ export const IdParams = Type.Object({ id: Type.String({ description: "Linear object ID" }), maxResponseChars: MaxResponseCharsSchema });
16
+
17
+ export const LinearGetTeamParams = Type.Object({ teamId: TeamIdSchema, maxResponseChars: MaxResponseCharsSchema });
18
+ export const LinearGetIssueParams = Type.Object({ issueId: IssueIdSchema, maxResponseChars: MaxResponseCharsSchema });
19
+ export const LinearGetProjectParams = Type.Object({ projectId: ProjectIdSchema, maxResponseChars: MaxResponseCharsSchema });
20
+ export const LinearGetUserParams = Type.Object({ userId: UserIdSchema, maxResponseChars: MaxResponseCharsSchema });
21
+ export const LinearGetDocumentParams = Type.Object({ documentId: Type.String({ description: "Linear document UUID" }), maxResponseChars: MaxResponseCharsSchema });
22
+ export const LinearGetStatusParams = Type.Object({ stateId: Type.String({ description: "Linear workflow state UUID" }), maxResponseChars: MaxResponseCharsSchema });
23
+
24
+ export const LinearListIssuesParams = Type.Object({
25
+ teamId: Type.Optional(TeamIdSchema),
26
+ assigneeId: Type.Optional(UserIdSchema),
27
+ statusName: Type.Optional(Type.String({ description: "Workflow status name, case-insensitive" })),
28
+ limit: LimitSchema,
29
+ maxResponseChars: MaxResponseCharsSchema,
30
+ });
31
+
32
+ export const LinearSearchIssuesParams = Type.Object({
33
+ query: Type.String({ description: "Search term" }),
34
+ limit: LimitSchema,
35
+ maxResponseChars: MaxResponseCharsSchema,
36
+ });
37
+
38
+ export const LinearListMyIssuesParams = Type.Object({ limit: LimitSchema, maxResponseChars: MaxResponseCharsSchema });
39
+
40
+ export const LinearCreateIssueParams = Type.Object({
41
+ teamId: TeamIdSchema,
42
+ title: Type.String({ description: "Issue title" }),
43
+ description: Type.Optional(Type.String({ description: "Issue description in Markdown" })),
44
+ priority: Type.Optional(Type.Number({ description: "0 No priority, 1 Urgent, 2 High, 3 Medium, 4 Low", minimum: 0, maximum: 4 })),
45
+ assigneeId: Type.Optional(UserIdSchema),
46
+ labelIds: Type.Optional(Type.Array(Type.String({ description: "Linear label UUID" }))),
47
+ projectId: Type.Optional(ProjectIdSchema),
48
+ stateId: Type.Optional(Type.String({ description: "Workflow state UUID" })),
49
+ maxResponseChars: MaxResponseCharsSchema,
50
+ });
51
+
52
+ export const LinearUpdateIssueParams = Type.Object({
53
+ issueId: IssueIdSchema,
54
+ title: Type.Optional(Type.String()),
55
+ description: Type.Optional(Type.String({ description: "Issue description in Markdown" })),
56
+ priority: Type.Optional(Type.Number({ description: "0 No priority, 1 Urgent, 2 High, 3 Medium, 4 Low", minimum: 0, maximum: 4 })),
57
+ stateId: Type.Optional(Type.String({ description: "Workflow state UUID" })),
58
+ assigneeId: Type.Optional(UserIdSchema),
59
+ maxResponseChars: MaxResponseCharsSchema,
60
+ });
61
+
62
+ export const LinearCommentsParams = Type.Object({ issueId: IssueIdSchema, maxResponseChars: MaxResponseCharsSchema });
63
+ export const LinearCreateCommentParams = Type.Object({
64
+ issueId: IssueIdSchema,
65
+ body: Type.String({ description: "Comment body in Markdown" }),
66
+ maxResponseChars: MaxResponseCharsSchema,
67
+ });
68
+
69
+ export const LinearDocumentsParams = Type.Object({ projectId: Type.Optional(ProjectIdSchema), maxResponseChars: MaxResponseCharsSchema });
@@ -0,0 +1,296 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { registerAuthConfigurator, runWithAuthRetry, type AuthConfiguratorOptions } from "pi-common/auth-config";
3
+ import { jsonToolResult } from "pi-common/tool-result";
4
+ import { LinearClient } from "./linear-client.js";
5
+ import {
6
+ EmptyParams,
7
+ LinearCommentsParams,
8
+ LinearCreateCommentParams,
9
+ LinearCreateIssueParams,
10
+ LinearDocumentsParams,
11
+ LinearGetDocumentParams,
12
+ LinearGetIssueParams,
13
+ LinearGetProjectParams,
14
+ LinearGetStatusParams,
15
+ LinearGetTeamParams,
16
+ LinearGetUserParams,
17
+ LinearListIssuesParams,
18
+ LinearListMyIssuesParams,
19
+ LinearSearchIssuesParams,
20
+ LinearUpdateIssueParams,
21
+ OptionalTeamParams,
22
+ } from "./linear-schemas.js";
23
+
24
+ const LINEAR_AUTH: AuthConfiguratorOptions = {
25
+ service: "linear",
26
+ displayName: "Linear",
27
+ envName: "LINEAR_API_KEY",
28
+ authPath: ["linear", "key"],
29
+ commandName: "linear-auth",
30
+ toolName: "linear_configure_auth",
31
+ tokenUrl: "https://linear.app/settings/account/security",
32
+ scopeInstructions: [
33
+ "Read access is enough for lookup/list/get tools.",
34
+ "Write access is required for creating issues, updating issues, and creating comments.",
35
+ "Admin access is not required.",
36
+ ],
37
+ };
38
+
39
+ function withLinearAuth<T>(ctx: ExtensionContext, operation: () => Promise<T>): Promise<T> {
40
+ return runWithAuthRetry(ctx, LINEAR_AUTH, operation);
41
+ }
42
+
43
+ export function registerLinearTools(pi: ExtensionAPI): void {
44
+ const client = new LinearClient();
45
+ registerAuthConfigurator(pi, LINEAR_AUTH);
46
+
47
+ pi.registerTool({
48
+ name: "linear_whoami",
49
+ label: "Linear Whoami",
50
+ description: "Get the authenticated Linear user.",
51
+ parameters: EmptyParams,
52
+ async execute(_id, params, _signal, _onUpdate, ctx) {
53
+ return jsonToolResult(await withLinearAuth(ctx, () => client.whoami()), { maxChars: params.maxResponseChars });
54
+ },
55
+ });
56
+
57
+ pi.registerTool({
58
+ name: "linear_workspace_metadata",
59
+ label: "Linear Workspace Metadata",
60
+ description: "Get teams, projects, workflow states, labels, and users in one Linear call.",
61
+ promptSnippet: "Fetch Linear workspace teams, projects, states, labels, and users.",
62
+ promptGuidelines: [
63
+ "Use linear_configure_auth only when Linear auth is missing, invalid, expired, or the user asks to update the key; never ask the user to paste API keys in chat.",
64
+ "Use linear_workspace_metadata first when Linear team, project, state, label, or user IDs are unknown.",
65
+ "Use linear_search_issues for Linear keyword lookup.",
66
+ "Use linear_get_issue before updating or commenting on a Linear issue.",
67
+ ],
68
+ parameters: EmptyParams,
69
+ async execute(_id, params, _signal, _onUpdate, ctx) {
70
+ return jsonToolResult(await withLinearAuth(ctx, () => client.workspaceMetadata()), { maxChars: params.maxResponseChars });
71
+ },
72
+ });
73
+
74
+ pi.registerTool({
75
+ name: "linear_list_teams",
76
+ label: "Linear List Teams",
77
+ description: "List Linear teams.",
78
+ parameters: EmptyParams,
79
+ async execute(_id, params, _signal, _onUpdate, ctx) {
80
+ return jsonToolResult(await withLinearAuth(ctx, () => client.listTeams()), { maxChars: params.maxResponseChars });
81
+ },
82
+ });
83
+
84
+ pi.registerTool({
85
+ name: "linear_get_team",
86
+ label: "Linear Get Team",
87
+ description: "Get a Linear team by ID.",
88
+ parameters: LinearGetTeamParams,
89
+ async execute(_id, params, _signal, _onUpdate, ctx) {
90
+ return jsonToolResult(await withLinearAuth(ctx, () => client.getTeam(params.teamId)), { maxChars: params.maxResponseChars });
91
+ },
92
+ });
93
+
94
+ pi.registerTool({
95
+ name: "linear_list_issues",
96
+ label: "Linear List Issues",
97
+ description: "List Linear issues with optional team, assignee, status, and limit filters.",
98
+ parameters: LinearListIssuesParams,
99
+ async execute(_id, params, _signal, _onUpdate, ctx) {
100
+ const result = await withLinearAuth(ctx, () => client.listIssues({
101
+ teamId: params.teamId,
102
+ assigneeId: params.assigneeId,
103
+ statusName: params.statusName,
104
+ limit: params.limit,
105
+ }));
106
+ return jsonToolResult(result, { maxChars: params.maxResponseChars });
107
+ },
108
+ });
109
+
110
+ pi.registerTool({
111
+ name: "linear_get_issue",
112
+ label: "Linear Get Issue",
113
+ description: "Get full Linear issue details by UUID or identifier like ENG-123.",
114
+ parameters: LinearGetIssueParams,
115
+ async execute(_id, params, _signal, _onUpdate, ctx) {
116
+ return jsonToolResult(await withLinearAuth(ctx, () => client.getIssue(params.issueId)), { maxChars: params.maxResponseChars });
117
+ },
118
+ });
119
+
120
+ pi.registerTool({
121
+ name: "linear_search_issues",
122
+ label: "Linear Search Issues",
123
+ description: "Search Linear issues by keyword.",
124
+ parameters: LinearSearchIssuesParams,
125
+ async execute(_id, params, _signal, _onUpdate, ctx) {
126
+ return jsonToolResult(await withLinearAuth(ctx, () => client.searchIssues(params.query, params.limit)), { maxChars: params.maxResponseChars });
127
+ },
128
+ });
129
+
130
+ pi.registerTool({
131
+ name: "linear_list_my_issues",
132
+ label: "Linear My Issues",
133
+ description: "List open Linear issues assigned to the authenticated user.",
134
+ parameters: LinearListMyIssuesParams,
135
+ async execute(_id, params, _signal, _onUpdate, ctx) {
136
+ return jsonToolResult(await withLinearAuth(ctx, () => client.listMyIssues(params.limit)), { maxChars: params.maxResponseChars });
137
+ },
138
+ });
139
+
140
+ pi.registerTool({
141
+ name: "linear_create_issue",
142
+ label: "Linear Create Issue",
143
+ description: "Create a Linear issue. Use linear_workspace_metadata first if team/state/user/project IDs are unknown.",
144
+ parameters: LinearCreateIssueParams,
145
+ async execute(_id, params, _signal, _onUpdate, ctx) {
146
+ const result = await withLinearAuth(ctx, () => client.createIssue({
147
+ teamId: params.teamId,
148
+ title: params.title,
149
+ description: params.description,
150
+ priority: params.priority,
151
+ assigneeId: params.assigneeId,
152
+ labelIds: params.labelIds,
153
+ projectId: params.projectId,
154
+ stateId: params.stateId,
155
+ }));
156
+ return jsonToolResult(result, { maxChars: params.maxResponseChars });
157
+ },
158
+ });
159
+
160
+ pi.registerTool({
161
+ name: "linear_update_issue",
162
+ label: "Linear Update Issue",
163
+ description: "Update a Linear issue title, description, priority, state, or assignee. Call linear_get_issue first.",
164
+ parameters: LinearUpdateIssueParams,
165
+ async execute(_id, params, _signal, _onUpdate, ctx) {
166
+ const result = await withLinearAuth(ctx, () => client.updateIssue(params.issueId, {
167
+ title: params.title,
168
+ description: params.description,
169
+ priority: params.priority,
170
+ stateId: params.stateId,
171
+ assigneeId: params.assigneeId,
172
+ }));
173
+ return jsonToolResult(result, { maxChars: params.maxResponseChars });
174
+ },
175
+ });
176
+
177
+ pi.registerTool({
178
+ name: "linear_list_projects",
179
+ label: "Linear List Projects",
180
+ description: "List Linear projects, optionally for a team.",
181
+ parameters: OptionalTeamParams,
182
+ async execute(_id, params, _signal, _onUpdate, ctx) {
183
+ return jsonToolResult(await withLinearAuth(ctx, () => client.listProjects(params.teamId)), { maxChars: params.maxResponseChars });
184
+ },
185
+ });
186
+
187
+ pi.registerTool({
188
+ name: "linear_get_project",
189
+ label: "Linear Get Project",
190
+ description: "Get a Linear project by ID.",
191
+ parameters: LinearGetProjectParams,
192
+ async execute(_id, params, _signal, _onUpdate, ctx) {
193
+ return jsonToolResult(await withLinearAuth(ctx, () => client.getProject(params.projectId)), { maxChars: params.maxResponseChars });
194
+ },
195
+ });
196
+
197
+ pi.registerTool({
198
+ name: "linear_list_issue_statuses",
199
+ label: "Linear List Issue Statuses",
200
+ description: "List Linear workflow states, optionally for a team.",
201
+ parameters: OptionalTeamParams,
202
+ async execute(_id, params, _signal, _onUpdate, ctx) {
203
+ return jsonToolResult(await withLinearAuth(ctx, () => client.listIssueStatuses(params.teamId)), { maxChars: params.maxResponseChars });
204
+ },
205
+ });
206
+
207
+ pi.registerTool({
208
+ name: "linear_get_issue_status",
209
+ label: "Linear Get Issue Status",
210
+ description: "Get a Linear workflow state by ID.",
211
+ parameters: LinearGetStatusParams,
212
+ async execute(_id, params, _signal, _onUpdate, ctx) {
213
+ return jsonToolResult(await withLinearAuth(ctx, () => client.getIssueStatus(params.stateId)), { maxChars: params.maxResponseChars });
214
+ },
215
+ });
216
+
217
+ pi.registerTool({
218
+ name: "linear_list_labels",
219
+ label: "Linear List Labels",
220
+ description: "List Linear labels, optionally for a team.",
221
+ parameters: OptionalTeamParams,
222
+ async execute(_id, params, _signal, _onUpdate, ctx) {
223
+ return jsonToolResult(await withLinearAuth(ctx, () => client.listLabels(params.teamId)), { maxChars: params.maxResponseChars });
224
+ },
225
+ });
226
+
227
+ pi.registerTool({
228
+ name: "linear_list_users",
229
+ label: "Linear List Users",
230
+ description: "List Linear users.",
231
+ parameters: EmptyParams,
232
+ async execute(_id, params, _signal, _onUpdate, ctx) {
233
+ return jsonToolResult(await withLinearAuth(ctx, () => client.listUsers()), { maxChars: params.maxResponseChars });
234
+ },
235
+ });
236
+
237
+ pi.registerTool({
238
+ name: "linear_get_user",
239
+ label: "Linear Get User",
240
+ description: "Get a Linear user by ID.",
241
+ parameters: LinearGetUserParams,
242
+ async execute(_id, params, _signal, _onUpdate, ctx) {
243
+ return jsonToolResult(await withLinearAuth(ctx, () => client.getUser(params.userId)), { maxChars: params.maxResponseChars });
244
+ },
245
+ });
246
+
247
+ pi.registerTool({
248
+ name: "linear_list_comments",
249
+ label: "Linear List Comments",
250
+ description: "List comments for a Linear issue.",
251
+ parameters: LinearCommentsParams,
252
+ async execute(_id, params, _signal, _onUpdate, ctx) {
253
+ return jsonToolResult(await withLinearAuth(ctx, () => client.listComments(params.issueId)), { maxChars: params.maxResponseChars });
254
+ },
255
+ });
256
+
257
+ pi.registerTool({
258
+ name: "linear_create_comment",
259
+ label: "Linear Create Comment",
260
+ description: "Create a comment on a Linear issue. Call linear_get_issue first.",
261
+ parameters: LinearCreateCommentParams,
262
+ async execute(_id, params, _signal, _onUpdate, ctx) {
263
+ return jsonToolResult(await withLinearAuth(ctx, () => client.createComment(params.issueId, params.body)), { maxChars: params.maxResponseChars });
264
+ },
265
+ });
266
+
267
+ pi.registerTool({
268
+ name: "linear_list_cycles",
269
+ label: "Linear List Cycles",
270
+ description: "List Linear cycles, optionally for a team.",
271
+ parameters: OptionalTeamParams,
272
+ async execute(_id, params, _signal, _onUpdate, ctx) {
273
+ return jsonToolResult(await withLinearAuth(ctx, () => client.listCycles(params.teamId)), { maxChars: params.maxResponseChars });
274
+ },
275
+ });
276
+
277
+ pi.registerTool({
278
+ name: "linear_list_documents",
279
+ label: "Linear List Documents",
280
+ description: "List Linear documents, optionally for a project.",
281
+ parameters: LinearDocumentsParams,
282
+ async execute(_id, params, _signal, _onUpdate, ctx) {
283
+ return jsonToolResult(await withLinearAuth(ctx, () => client.listDocuments(params.projectId)), { maxChars: params.maxResponseChars });
284
+ },
285
+ });
286
+
287
+ pi.registerTool({
288
+ name: "linear_get_document",
289
+ label: "Linear Get Document",
290
+ description: "Get a Linear document by ID.",
291
+ parameters: LinearGetDocumentParams,
292
+ async execute(_id, params, _signal, _onUpdate, ctx) {
293
+ return jsonToolResult(await withLinearAuth(ctx, () => client.getDocument(params.documentId)), { maxChars: params.maxResponseChars });
294
+ },
295
+ });
296
+ }