kctl-linear 0.2.0__py3-none-any.whl

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,37 @@
1
+ """Health check — verify API connectivity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from kctl_linear.core.callbacks import AppContext
8
+
9
+ app = typer.Typer(help="API health check.")
10
+
11
+
12
+ @app.callback(invoke_without_command=True)
13
+ def health(ctx: typer.Context) -> None:
14
+ """Check Linear API connectivity and show current user info."""
15
+ actx: AppContext = ctx.obj
16
+ out = actx.output
17
+ client = actx.client
18
+
19
+ viewer = client.viewer()
20
+
21
+ if out.json_mode:
22
+ out.raw_json({"status": "ok", "viewer": viewer})
23
+ return
24
+
25
+ sections = [
26
+ ("API Status", [("Status", "Connected"), ("Endpoint", "https://api.linear.app/graphql")]),
27
+ (
28
+ "Authenticated User",
29
+ [
30
+ ("Name", viewer.get("name", "")),
31
+ ("Email", viewer.get("email", "")),
32
+ ("Admin", str(viewer.get("admin", False))),
33
+ ("Active", str(viewer.get("active", True))),
34
+ ],
35
+ ),
36
+ ]
37
+ out.detail("Linear Health", sections)
@@ -0,0 +1,305 @@
1
+ """Issue management commands — daily use."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from kctl_linear.core.callbacks import AppContext
10
+ from kctl_linear.core.client import (
11
+ COMMENT_CREATE_MUTATION,
12
+ ISSUE_CREATE_MUTATION,
13
+ ISSUE_SEARCH_QUERY,
14
+ ISSUE_SHOW_QUERY,
15
+ ISSUE_UPDATE_MUTATION,
16
+ ISSUES_LIST_QUERY,
17
+ TEAM_BY_KEY_QUERY,
18
+ USER_BY_NAME_QUERY,
19
+ WORKFLOW_STATE_QUERY,
20
+ )
21
+
22
+ app = typer.Typer(help="Issue management.")
23
+
24
+
25
+ def _resolve_team_id(ctx: AppContext, team_key: str) -> str:
26
+ """Resolve a team key (e.g., 'KOD') to its UUID."""
27
+ data = ctx.client.query(TEAM_BY_KEY_QUERY, {"key": team_key})
28
+ nodes = data.get("teams", {}).get("nodes", [])
29
+ if not nodes:
30
+ raise typer.BadParameter(f"Team '{team_key}' not found")
31
+ return nodes[0]["id"]
32
+
33
+
34
+ def _resolve_user_id(ctx: AppContext, name: str) -> str:
35
+ """Resolve a user display name to their UUID."""
36
+ data = ctx.client.query(USER_BY_NAME_QUERY, {"name": name})
37
+ nodes = data.get("users", {}).get("nodes", [])
38
+ if not nodes:
39
+ raise typer.BadParameter(f"User '{name}' not found")
40
+ return nodes[0]["id"]
41
+
42
+
43
+ def _resolve_state_id(ctx: AppContext, team_key: str, state_name: str) -> str:
44
+ """Resolve a workflow state name to its UUID."""
45
+ data = ctx.client.query(WORKFLOW_STATE_QUERY, {"teamKey": team_key, "stateName": state_name})
46
+ nodes = data.get("workflowStates", {}).get("nodes", [])
47
+ if not nodes:
48
+ raise typer.BadParameter(f"State '{state_name}' not found for team '{team_key}'")
49
+ return nodes[0]["id"]
50
+
51
+
52
+ @app.command("list")
53
+ def list_(
54
+ ctx: typer.Context,
55
+ team: Annotated[str | None, typer.Option("--team", "-t", help="Team key (e.g., KOD)")] = None,
56
+ state: Annotated[str | None, typer.Option("--state", "-s", help="Filter by state name")] = None,
57
+ assignee: Annotated[str | None, typer.Option("--assignee", "-a", help="Filter by assignee ('me' for self)")] = None,
58
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 50,
59
+ ) -> None:
60
+ """List issues with optional filters."""
61
+ actx: AppContext = ctx.obj
62
+ out = actx.output
63
+ client = actx.client
64
+
65
+ team_key = team or actx.default_team
66
+ variables: dict[str, str | int | None] = {"first": limit}
67
+
68
+ if team_key:
69
+ variables["teamKey"] = team_key
70
+ if state:
71
+ variables["state"] = state
72
+
73
+ # Resolve 'me' to viewer ID
74
+ if assignee == "me":
75
+ viewer = client.viewer()
76
+ variables["assigneeId"] = viewer["id"]
77
+ elif assignee:
78
+ variables["assigneeId"] = _resolve_user_id(actx, assignee)
79
+
80
+ data = client.query(ISSUES_LIST_QUERY, variables)
81
+ issues = data.get("issues", {}).get("nodes", [])
82
+
83
+ if out.json_mode:
84
+ out.raw_json(issues)
85
+ return
86
+
87
+ if not issues:
88
+ out.info("No issues found")
89
+ return
90
+
91
+ rows = [
92
+ [
93
+ issue.get("identifier", ""),
94
+ issue.get("title", "")[:55],
95
+ str(issue.get("priority", "-")),
96
+ issue.get("state", {}).get("name", ""),
97
+ (issue.get("assignee") or {}).get("name", "unassigned"),
98
+ ]
99
+ for issue in issues
100
+ ]
101
+ out.table(
102
+ f"Issues ({len(issues)})",
103
+ [("ID", "cyan"), ("Title", "white"), ("P", "yellow"), ("State", "green"), ("Assignee", "magenta")],
104
+ rows,
105
+ )
106
+
107
+
108
+ @app.command()
109
+ def show(
110
+ ctx: typer.Context,
111
+ issue_id: Annotated[str, typer.Argument(help="Issue ID (UUID or identifier like KOD-123)")],
112
+ ) -> None:
113
+ """Show issue details, comments, and history."""
114
+ actx: AppContext = ctx.obj
115
+ out = actx.output
116
+
117
+ data = actx.client.query(ISSUE_SHOW_QUERY, {"id": issue_id})
118
+ issue = data.get("issue", {})
119
+
120
+ if out.json_mode:
121
+ out.raw_json(issue)
122
+ return
123
+
124
+ labels = [label["name"] for label in issue.get("labels", {}).get("nodes", [])]
125
+ sections = [
126
+ (
127
+ "Issue",
128
+ [
129
+ ("Identifier", issue.get("identifier", "")),
130
+ ("Title", issue.get("title", "")),
131
+ ("State", issue.get("state", {}).get("name", "")),
132
+ ("Priority", issue.get("priorityLabel", "")),
133
+ ("Estimate", str(issue.get("estimate") or "-")),
134
+ ("Assignee", (issue.get("assignee") or {}).get("name", "unassigned")),
135
+ ("Team", (issue.get("team") or {}).get("name", "")),
136
+ ("Project", (issue.get("project") or {}).get("name", "-")),
137
+ ("Cycle", (issue.get("cycle") or {}).get("name", "-")),
138
+ ("Labels", ", ".join(labels) if labels else "-"),
139
+ ("URL", issue.get("url", "")),
140
+ ],
141
+ ),
142
+ ]
143
+
144
+ if issue.get("description"):
145
+ sections.append(("Description", [("", issue["description"][:500])]))
146
+
147
+ comments = issue.get("comments", {}).get("nodes", [])
148
+ if comments:
149
+ comment_rows = [
150
+ (f"{c.get('user', {}).get('name', '?')} ({c.get('createdAt', '')[:10]})", c.get("body", "")[:200])
151
+ for c in comments[:10]
152
+ ]
153
+ sections.append(("Comments", comment_rows))
154
+
155
+ out.detail(issue.get("identifier", "Issue"), sections)
156
+
157
+
158
+ @app.command()
159
+ def create(
160
+ ctx: typer.Context,
161
+ title: Annotated[str, typer.Option("--title", help="Issue title")],
162
+ team: Annotated[str | None, typer.Option("--team", "-t", help="Team key")] = None,
163
+ description: Annotated[str | None, typer.Option("--desc", "-d", help="Issue description")] = None,
164
+ priority: Annotated[
165
+ int | None, typer.Option("--priority", "-p", help="Priority 0-4 (0=none, 1=urgent, 4=low)")
166
+ ] = None,
167
+ assignee: Annotated[str | None, typer.Option("--assignee", "-a", help="Assignee name ('me' for self)")] = None,
168
+ ) -> None:
169
+ """Create a new issue."""
170
+ actx: AppContext = ctx.obj
171
+ out = actx.output
172
+
173
+ team_key = team or actx.default_team
174
+ if not team_key:
175
+ out.error("Team key required. Use --team or set default_team in config.")
176
+ raise typer.Exit(1)
177
+
178
+ team_id = _resolve_team_id(actx, team_key)
179
+ variables: dict[str, str | int | None] = {"teamId": team_id, "title": title}
180
+
181
+ if description:
182
+ variables["description"] = description
183
+ if priority is not None:
184
+ variables["priority"] = priority
185
+ if assignee == "me":
186
+ variables["assigneeId"] = actx.client.viewer()["id"]
187
+ elif assignee:
188
+ variables["assigneeId"] = _resolve_user_id(actx, assignee)
189
+
190
+ data = actx.client.query(ISSUE_CREATE_MUTATION, variables)
191
+ result = data.get("issueCreate", {})
192
+ issue = result.get("issue", {})
193
+
194
+ if out.json_mode:
195
+ out.raw_json(result)
196
+ return
197
+
198
+ out.success(f"Created {issue.get('identifier', '?')}: {issue.get('title', '')}")
199
+ out.kv("URL", issue.get("url", ""))
200
+
201
+
202
+ @app.command()
203
+ def update(
204
+ ctx: typer.Context,
205
+ issue_id: Annotated[str, typer.Argument(help="Issue ID")],
206
+ state: Annotated[str | None, typer.Option("--state", "-s", help="New state name")] = None,
207
+ assignee: Annotated[str | None, typer.Option("--assignee", "-a", help="New assignee")] = None,
208
+ priority: Annotated[int | None, typer.Option("--priority", "-p", help="New priority (0-4)")] = None,
209
+ title: Annotated[str | None, typer.Option("--title", help="New title")] = None,
210
+ description: Annotated[str | None, typer.Option("--desc", "-d", help="New description")] = None,
211
+ ) -> None:
212
+ """Update an existing issue."""
213
+ actx: AppContext = ctx.obj
214
+ out = actx.output
215
+
216
+ variables: dict[str, str | int | None] = {"id": issue_id}
217
+
218
+ if state:
219
+ team_key = actx.default_team
220
+ if not team_key:
221
+ out.error("default_team must be configured to resolve state names")
222
+ raise typer.Exit(1)
223
+ variables["stateId"] = _resolve_state_id(actx, team_key, state)
224
+
225
+ if assignee == "me":
226
+ variables["assigneeId"] = actx.client.viewer()["id"]
227
+ elif assignee:
228
+ variables["assigneeId"] = _resolve_user_id(actx, assignee)
229
+
230
+ if priority is not None:
231
+ variables["priority"] = priority
232
+ if title:
233
+ variables["title"] = title
234
+ if description:
235
+ variables["description"] = description
236
+
237
+ data = actx.client.query(ISSUE_UPDATE_MUTATION, variables)
238
+ result = data.get("issueUpdate", {})
239
+ issue = result.get("issue", {})
240
+
241
+ if out.json_mode:
242
+ out.raw_json(result)
243
+ return
244
+
245
+ out.success(f"Updated {issue.get('identifier', '?')}: {issue.get('title', '')}")
246
+
247
+
248
+ @app.command()
249
+ def comment(
250
+ ctx: typer.Context,
251
+ issue_id: Annotated[str, typer.Argument(help="Issue ID")],
252
+ body: Annotated[str, typer.Option("--body", "-b", help="Comment text")],
253
+ ) -> None:
254
+ """Add a comment to an issue."""
255
+ actx: AppContext = ctx.obj
256
+ out = actx.output
257
+
258
+ data = actx.client.query(COMMENT_CREATE_MUTATION, {"issueId": issue_id, "body": body})
259
+ result = data.get("commentCreate", {})
260
+
261
+ if out.json_mode:
262
+ out.raw_json(result)
263
+ return
264
+
265
+ if result.get("success"):
266
+ out.success("Comment added")
267
+ else:
268
+ out.error("Failed to add comment")
269
+
270
+
271
+ @app.command()
272
+ def search(
273
+ ctx: typer.Context,
274
+ query: Annotated[str, typer.Argument(help="Search query")],
275
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 50,
276
+ ) -> None:
277
+ """Full-text search for issues."""
278
+ actx: AppContext = ctx.obj
279
+ out = actx.output
280
+
281
+ data = actx.client.query(ISSUE_SEARCH_QUERY, {"term": query, "first": limit})
282
+ issues = data.get("searchIssues", {}).get("nodes", [])
283
+
284
+ if out.json_mode:
285
+ out.raw_json(issues)
286
+ return
287
+
288
+ if not issues:
289
+ out.info(f"No issues matching '{query}'")
290
+ return
291
+
292
+ rows = [
293
+ [
294
+ issue.get("identifier", ""),
295
+ issue.get("title", "")[:55],
296
+ issue.get("state", {}).get("name", ""),
297
+ (issue.get("assignee") or {}).get("name", ""),
298
+ ]
299
+ for issue in issues
300
+ ]
301
+ out.table(
302
+ f"Search: '{query}' ({len(issues)} results)",
303
+ [("ID", "cyan"), ("Title", "white"), ("State", "green"), ("Assignee", "magenta")],
304
+ rows,
305
+ )
@@ -0,0 +1,91 @@
1
+ """Label management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from kctl_linear.core.callbacks import AppContext
10
+ from kctl_linear.core.client import LABEL_CREATE_MUTATION, LABELS_LIST_QUERY, TEAM_BY_KEY_QUERY
11
+
12
+ app = typer.Typer(help="Label management.")
13
+
14
+
15
+ def _resolve_team_id(ctx: AppContext, team_key: str) -> str:
16
+ """Resolve a team key to its UUID."""
17
+ data = ctx.client.query(TEAM_BY_KEY_QUERY, {"key": team_key})
18
+ nodes = data.get("teams", {}).get("nodes", [])
19
+ if not nodes:
20
+ raise typer.BadParameter(f"Team '{team_key}' not found")
21
+ return nodes[0]["id"]
22
+
23
+
24
+ @app.command("list")
25
+ def list_(
26
+ ctx: typer.Context,
27
+ team: Annotated[str | None, typer.Option("--team", "-t", help="Team key")] = None,
28
+ ) -> None:
29
+ """List all labels, optionally filtered by team."""
30
+ actx: AppContext = ctx.obj
31
+ out = actx.output
32
+
33
+ team_key = team or actx.default_team
34
+ variables: dict[str, str | None] = {}
35
+ if team_key:
36
+ variables["teamKey"] = team_key
37
+
38
+ data = actx.client.query(LABELS_LIST_QUERY, variables)
39
+ labels = data.get("issueLabels", {}).get("nodes", [])
40
+
41
+ if out.json_mode:
42
+ out.raw_json(labels)
43
+ return
44
+
45
+ if not labels:
46
+ out.info("No labels found")
47
+ return
48
+
49
+ rows = [
50
+ [
51
+ lbl.get("name", ""),
52
+ lbl.get("color", ""),
53
+ (lbl.get("parent") or {}).get("name", "-"),
54
+ ]
55
+ for lbl in labels
56
+ ]
57
+ out.table(
58
+ f"Labels ({len(labels)})",
59
+ [("Name", "cyan"), ("Color", "yellow"), ("Parent", "white")],
60
+ rows,
61
+ )
62
+
63
+
64
+ @app.command()
65
+ def create(
66
+ ctx: typer.Context,
67
+ name: Annotated[str, typer.Argument(help="Label name")],
68
+ color: Annotated[str | None, typer.Option("--color", "-c", help="Hex color (e.g., #ff0000)")] = None,
69
+ team: Annotated[str | None, typer.Option("--team", "-t", help="Team key (scoped label)")] = None,
70
+ ) -> None:
71
+ """Create a new label."""
72
+ actx: AppContext = ctx.obj
73
+ out = actx.output
74
+
75
+ variables: dict[str, str | None] = {"name": name}
76
+ if color:
77
+ variables["color"] = color
78
+
79
+ team_key = team or actx.default_team
80
+ if team_key:
81
+ variables["teamId"] = _resolve_team_id(actx, team_key)
82
+
83
+ data = actx.client.query(LABEL_CREATE_MUTATION, variables)
84
+ result = data.get("issueLabelCreate", {})
85
+ label = result.get("issueLabel", {})
86
+
87
+ if out.json_mode:
88
+ out.raw_json(result)
89
+ return
90
+
91
+ out.success(f"Created label '{label.get('name', name)}' ({label.get('color', '')})")
@@ -0,0 +1,110 @@
1
+ """Project tracking commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from kctl_linear.core.callbacks import AppContext
10
+ from kctl_linear.core.client import PROJECT_SHOW_QUERY, PROJECTS_LIST_QUERY
11
+
12
+ app = typer.Typer(help="Project tracking.")
13
+
14
+
15
+ @app.command("list")
16
+ def list_(ctx: typer.Context) -> None:
17
+ """List active projects with progress."""
18
+ actx: AppContext = ctx.obj
19
+ out = actx.output
20
+
21
+ data = actx.client.query(PROJECTS_LIST_QUERY)
22
+ projects = data.get("projects", {}).get("nodes", [])
23
+
24
+ if out.json_mode:
25
+ out.raw_json(projects)
26
+ return
27
+
28
+ if not projects:
29
+ out.info("No projects found")
30
+ return
31
+
32
+ rows = [
33
+ [
34
+ p.get("name", ""),
35
+ p.get("state", ""),
36
+ f"{(p.get('progress', 0) or 0) * 100:.0f}%",
37
+ (p.get("lead") or {}).get("name", "-"),
38
+ (p.get("targetDate") or "-")[:10],
39
+ ]
40
+ for p in projects
41
+ ]
42
+ out.table(
43
+ f"Projects ({len(projects)})",
44
+ [("Name", "cyan"), ("State", "green"), ("Progress", "yellow"), ("Lead", "magenta"), ("Target", "white")],
45
+ rows,
46
+ )
47
+
48
+
49
+ @app.command()
50
+ def show(
51
+ ctx: typer.Context,
52
+ project_id: Annotated[str, typer.Argument(help="Project ID (UUID)")],
53
+ ) -> None:
54
+ """Show project details, milestones, and member issues."""
55
+ actx: AppContext = ctx.obj
56
+ out = actx.output
57
+
58
+ data = actx.client.query(PROJECT_SHOW_QUERY, {"id": project_id})
59
+ project = data.get("project", {})
60
+
61
+ if out.json_mode:
62
+ out.raw_json(project)
63
+ return
64
+
65
+ teams = [t.get("name", "") for t in project.get("teams", {}).get("nodes", [])]
66
+ members = [m.get("name", "") for m in project.get("members", {}).get("nodes", [])]
67
+ milestones = project.get("projectMilestones", {}).get("nodes", [])
68
+ issues = project.get("issues", {}).get("nodes", [])
69
+
70
+ sections = [
71
+ (
72
+ "Project",
73
+ [
74
+ ("Name", project.get("name", "")),
75
+ ("State", project.get("state", "")),
76
+ ("Progress", f"{(project.get('progress', 0) or 0) * 100:.0f}%"),
77
+ ("Lead", (project.get("lead") or {}).get("name", "-")),
78
+ ("Start", (project.get("startDate") or "-")[:10]),
79
+ ("Target", (project.get("targetDate") or "-")[:10]),
80
+ ("Teams", ", ".join(teams) if teams else "-"),
81
+ ("Members", ", ".join(members) if members else "-"),
82
+ ("URL", project.get("url", "")),
83
+ ],
84
+ ),
85
+ ]
86
+
87
+ if project.get("description"):
88
+ sections.append(("Description", [("", project["description"][:500])]))
89
+
90
+ if milestones:
91
+ ms_rows = [(m.get("name", ""), (m.get("targetDate") or "-")[:10]) for m in milestones]
92
+ sections.append(("Milestones", ms_rows))
93
+
94
+ out.detail(project.get("name", "Project"), sections)
95
+
96
+ if issues:
97
+ rows = [
98
+ [
99
+ i.get("identifier", ""),
100
+ i.get("title", "")[:50],
101
+ i.get("state", {}).get("name", ""),
102
+ (i.get("assignee") or {}).get("name", ""),
103
+ ]
104
+ for i in issues[:20]
105
+ ]
106
+ out.table(
107
+ f"Issues ({len(issues)})",
108
+ [("ID", "cyan"), ("Title", "white"), ("State", "green"), ("Assignee", "magenta")],
109
+ rows,
110
+ )
@@ -0,0 +1,76 @@
1
+ """Skill generation for Claude Code integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from kctl_linear.core.callbacks import AppContext
11
+
12
+ app = typer.Typer(help="Claude Code skill management.")
13
+
14
+
15
+ @app.command()
16
+ def generate(
17
+ ctx: typer.Context,
18
+ output: Annotated[str, typer.Option("--output", "-o", help="Output directory")] = "",
19
+ install: Annotated[bool, typer.Option("--install", help="Install to ~/.claude/skills/")] = False,
20
+ check: Annotated[bool, typer.Option("--check", help="Check if SKILL.md is stale (exit 1 if stale)")] = False,
21
+ ) -> None:
22
+ """Auto-generate SKILL.md from CLI command registry.
23
+
24
+ Examples:
25
+ kctl-linear skill generate
26
+ kctl-linear skill generate --install
27
+ kctl-linear skill generate --check
28
+ """
29
+ actx: AppContext = ctx.obj
30
+ out = actx.output
31
+ from kctl_lib.skill_generator import check_stale, generate_skill
32
+
33
+ from kctl_linear.cli import app as cli_app
34
+
35
+ skill_name = "linear-admin"
36
+ description = "Linear project tracking administration via kctl-linear CLI"
37
+
38
+ # Canonical in-repo skill dir — source of SKILL.extra.md regardless
39
+ # of where output goes. Without this, --install writes to
40
+ # ~/.claude/skills/ and the handwritten runbook vanishes.
41
+ cli_root = Path(__file__).resolve().parents[3]
42
+ source_dir = cli_root / "skills" / skill_name
43
+
44
+ # Determine output directory
45
+ if output:
46
+ output_dir = Path(output)
47
+ elif install:
48
+ output_dir = Path.home() / ".claude" / "skills" / skill_name
49
+ else:
50
+ output_dir = source_dir
51
+
52
+ # Check-only mode
53
+ if check:
54
+ skill_file = output_dir / "SKILL.md"
55
+ is_stale, reason = check_stale(cli_app, skill_file)
56
+ if is_stale:
57
+ out.warn(f"SKILL.md is stale: {reason}")
58
+ out.info("Run: kctl-linear skill generate")
59
+ raise typer.Exit(1)
60
+ out.success(f"SKILL.md is up to date: {reason}")
61
+ return
62
+
63
+ # Extra content always lives at the in-repo location.
64
+ extra = source_dir / "SKILL.extra.md"
65
+
66
+ generate_skill(
67
+ cli_app,
68
+ "kctl-linear",
69
+ skill_name,
70
+ description,
71
+ output_dir=output_dir,
72
+ extra_file=extra if extra.exists() else None,
73
+ )
74
+ out.success(f"Generated {output_dir / 'SKILL.md'}")
75
+ if install:
76
+ out.success(f"Installed to ~/.claude/skills/{skill_name}/")