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,3 @@
1
+ """kctl-linear: Linear project management."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as python -m kctl_linear."""
2
+
3
+ from kctl_linear.cli import _run
4
+
5
+ _run()
kctl_linear/cli.py ADDED
@@ -0,0 +1,131 @@
1
+ """Main CLI entry point for kctl-linear."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from kctl_lib import KctlError, handle_cli_error
10
+
11
+ from kctl_linear import __version__
12
+ from kctl_linear.commands.config_cmd import app as config_app
13
+ from kctl_linear.commands.cycles import app as cycles_app
14
+ from kctl_linear.commands.dashboard import app as dashboard_app
15
+ from kctl_linear.commands.health import app as health_app
16
+ from kctl_linear.commands.issues import app as issues_app
17
+ from kctl_linear.commands.labels import app as labels_app
18
+ from kctl_linear.commands.projects import app as projects_app
19
+ from kctl_linear.commands.teams import app as teams_app
20
+ from kctl_linear.commands.users import app as users_app
21
+ from kctl_linear.core.callbacks import AppContext
22
+ from kctl_linear.core.plugins import discover_and_load_plugins
23
+ from kctl_linear.commands.skill_cmd import app as skill_app
24
+ from kctl_linear.commands.doctor_cmd import app as doctor_app
25
+ from kctl_lib.self_update import notify_if_outdated
26
+
27
+
28
+ def version_callback(value: bool) -> None:
29
+ if value:
30
+ typer.echo(f"kctl-linear {__version__}")
31
+ raise typer.Exit()
32
+
33
+
34
+ app = typer.Typer(
35
+ name="kctl-linear",
36
+ help="Linear project management",
37
+ no_args_is_help=True,
38
+ rich_markup_mode="rich",
39
+ pretty_exceptions_enable=False,
40
+ )
41
+
42
+
43
+ @app.callback()
44
+ def main(
45
+ ctx: typer.Context,
46
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
47
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
48
+ profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
49
+ format: Annotated[str, typer.Option("--format", "-f", help="Output format: pretty, json, csv, yaml")] = "pretty",
50
+ no_header: Annotated[bool, typer.Option("--no-header", help="Omit header row in CSV output")] = False,
51
+ version: Annotated[
52
+ bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
53
+ ] = False,
54
+ ) -> None:
55
+ """Linear project management."""
56
+ ctx.ensure_object(dict)
57
+ ctx.obj = AppContext(
58
+ json_mode=json_output,
59
+ quiet=quiet,
60
+ profile=profile,
61
+ format=format,
62
+ no_header=no_header,
63
+ )
64
+ notify_if_outdated(ctx.obj.output, "kctl-linear", __version__)
65
+
66
+
67
+ # Command group registration
68
+ app.add_typer(config_app, name="config")
69
+ app.add_typer(health_app, name="health")
70
+ app.add_typer(dashboard_app, name="dashboard")
71
+ app.add_typer(issues_app, name="issues")
72
+ app.add_typer(cycles_app, name="cycles")
73
+ app.add_typer(projects_app, name="projects")
74
+ app.add_typer(teams_app, name="teams")
75
+ app.add_typer(labels_app, name="labels")
76
+ app.add_typer(users_app, name="users")
77
+ app.add_typer(skill_app, name="skill", hidden=True)
78
+ app.add_typer(doctor_app, name="doctor")
79
+
80
+ # Load third-party plugins via entry points
81
+ discover_and_load_plugins(app)
82
+
83
+
84
+ @app.command("self-update")
85
+ def self_update_cmd(ctx: typer.Context) -> None:
86
+ """Check for updates and upgrade kctl-linear."""
87
+ actx = ctx.obj
88
+ out = actx.output
89
+
90
+ from kctl_lib.self_update import check_update
91
+ from kctl_lib.self_update import update as do_update
92
+
93
+ latest = check_update("kctl-linear", __version__)
94
+ if latest:
95
+ out.info(f"Updating to {latest}...")
96
+ do_update("kctl-linear")
97
+ out.success(f"Updated to {latest}")
98
+ else:
99
+ out.success("Already up to date")
100
+
101
+
102
+ @app.command()
103
+ def completions(
104
+ shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
105
+ install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
106
+ ) -> None:
107
+ """Generate or install shell completions."""
108
+ from kctl_lib.completions import get_completion_script, install_completions
109
+
110
+ if install:
111
+ path = install_completions("kctl-linear", shell)
112
+ if path:
113
+ typer.echo(f"Completions installed to {path}")
114
+ else:
115
+ typer.echo(f"Could not install completions for {shell}", err=True)
116
+ raise typer.Exit(code=1)
117
+ else:
118
+ script = get_completion_script("kctl-linear", shell)
119
+ typer.echo(script)
120
+
121
+
122
+ def _run() -> None:
123
+ """Entry point with error handling."""
124
+ try:
125
+ app()
126
+ except KctlError as e:
127
+ handle_cli_error(e)
128
+
129
+
130
+ if __name__ == "__main__":
131
+ _run()
File without changes
@@ -0,0 +1,201 @@
1
+ """Config profile 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.config import (
11
+ SERVICE_KEY,
12
+ ServiceConfig,
13
+ get_all_services_in_profile,
14
+ get_profile_names,
15
+ get_service_config,
16
+ remove_profile,
17
+ resolve_active_profile_name,
18
+ set_default_profile,
19
+ set_service_config,
20
+ )
21
+
22
+ app = typer.Typer(help="Profile and configuration management.")
23
+
24
+
25
+ @app.command()
26
+ def init(ctx: typer.Context) -> None:
27
+ """Interactive config setup."""
28
+ actx: AppContext = ctx.obj
29
+ out = actx.output
30
+ profile_name = typer.prompt("Profile name", default="default")
31
+ api_key = typer.prompt("Linear API key", hide_input=True)
32
+ default_team = typer.prompt("Default team key (e.g., KOD)", default="")
33
+ svc = ServiceConfig(api_key=api_key, default_team=default_team)
34
+ set_service_config(profile_name, svc)
35
+ set_default_profile(profile_name)
36
+ out.success(f"Config saved to profile '{profile_name}'")
37
+
38
+
39
+ @app.command()
40
+ def add(
41
+ ctx: typer.Context,
42
+ name: Annotated[str, typer.Argument(help="Profile name")],
43
+ ) -> None:
44
+ """Add a new config profile."""
45
+ actx: AppContext = ctx.obj
46
+ out = actx.output
47
+ api_key = typer.prompt("Linear API key", hide_input=True)
48
+ default_team = typer.prompt("Default team key (e.g., KOD)", default="")
49
+ svc = ServiceConfig(api_key=api_key, default_team=default_team)
50
+ set_service_config(name, svc)
51
+ out.success(f"Profile '{name}' added")
52
+
53
+
54
+ @app.command()
55
+ def use(
56
+ ctx: typer.Context,
57
+ name: Annotated[str, typer.Argument(help="Profile name to activate")],
58
+ ) -> None:
59
+ """Switch active config profile."""
60
+ actx: AppContext = ctx.obj
61
+ out = actx.output
62
+ available = get_profile_names()
63
+ if name not in available:
64
+ out.error(f"Profile '{name}' not found. Available: {', '.join(available)}")
65
+ raise typer.Exit(1)
66
+ set_default_profile(name)
67
+ out.success(f"Switched to profile '{name}'")
68
+
69
+
70
+ @app.command()
71
+ def show(ctx: typer.Context) -> None:
72
+ """Show current configuration."""
73
+ actx: AppContext = ctx.obj
74
+ out = actx.output
75
+ active = resolve_active_profile_name(actx.profile)
76
+ profiles = get_profile_names()
77
+ if out.json_mode:
78
+ out.raw_json({"active_profile": active, "profiles": {n: get_all_services_in_profile(n) for n in profiles}})
79
+ return
80
+ out.header("Configuration")
81
+ out.kv("Active profile", active)
82
+ for name in profiles:
83
+ marker = " (active)" if name == active else ""
84
+ out.header(f"Profile: {name}{marker}")
85
+ services = get_all_services_in_profile(name)
86
+ for svc_name, svc_data in services.items():
87
+ out.text(f" [bold]{svc_name}:[/bold]")
88
+ if isinstance(svc_data, dict):
89
+ for k, v in svc_data.items():
90
+ out.kv(f" {k}", str(v))
91
+
92
+
93
+ @app.command()
94
+ def validate(ctx: typer.Context) -> None:
95
+ """Validate current config completeness."""
96
+ actx: AppContext = ctx.obj
97
+ out = actx.output
98
+ active = resolve_active_profile_name(actx.profile)
99
+ svc = get_service_config(active)
100
+ issues: list[str] = []
101
+ if not svc.api_key:
102
+ issues.append("api_key is not set")
103
+ if not svc.default_team:
104
+ issues.append("default_team is not set (optional but recommended)")
105
+ if out.json_mode:
106
+ out.raw_json({"profile": active, "valid": not any("api_key" in i for i in issues), "issues": issues})
107
+ return
108
+ if any("api_key" in i for i in issues):
109
+ out.error(f"Profile '{active}' has issues:")
110
+ for issue in issues:
111
+ out.text(f" - {issue}")
112
+ raise typer.Exit(1)
113
+ if issues:
114
+ out.warn(f"Profile '{active}' has warnings:")
115
+ for issue in issues:
116
+ out.text(f" - {issue}")
117
+ else:
118
+ out.success(f"Profile '{active}' is valid")
119
+
120
+
121
+ @app.command()
122
+ def remove(
123
+ ctx: typer.Context,
124
+ name: Annotated[str, typer.Argument(help="Profile name to remove")],
125
+ ) -> None:
126
+ """Remove a config profile."""
127
+ actx: AppContext = ctx.obj
128
+ out = actx.output
129
+ available = get_profile_names()
130
+ if name not in available:
131
+ out.error(f"Profile '{name}' not found")
132
+ raise typer.Exit(1)
133
+ remove_profile(name)
134
+ out.success(f"Profile '{name}' removed")
135
+
136
+
137
+ @app.command("set")
138
+ def set_(
139
+ ctx: typer.Context,
140
+ key: Annotated[str, typer.Argument(help="Config key")],
141
+ value: Annotated[str, typer.Argument(help="Config value")],
142
+ ) -> None:
143
+ """Set a single config value."""
144
+ actx: AppContext = ctx.obj
145
+ out = actx.output
146
+ active = resolve_active_profile_name(actx.profile)
147
+ svc = get_service_config(active)
148
+ if key not in ServiceConfig.model_fields:
149
+ out.error(f"Unknown key '{key}'. Valid: {', '.join(ServiceConfig.model_fields)}")
150
+ raise typer.Exit(1)
151
+ data = svc.model_dump()
152
+ data[key] = value
153
+ set_service_config(active, ServiceConfig(**data))
154
+ out.success(f"Set {key}={value} in profile '{active}'")
155
+
156
+
157
+ @app.command()
158
+ def profiles(ctx: typer.Context) -> None:
159
+ """List all config profiles."""
160
+ actx: AppContext = ctx.obj
161
+ out = actx.output
162
+ active = resolve_active_profile_name(actx.profile)
163
+ names = get_profile_names()
164
+ if out.json_mode:
165
+ out.raw_json({"profiles": names, "active": active})
166
+ return
167
+ rows = [[name, "active" if name == active else ""] for name in names]
168
+ out.table("Profiles", [("Name", "cyan"), ("Status", "green")], rows)
169
+
170
+
171
+ @app.command()
172
+ def current(ctx: typer.Context) -> None:
173
+ """Show active profile and resolved context."""
174
+ actx: AppContext = ctx.obj
175
+ out = actx.output
176
+ active = resolve_active_profile_name(actx.profile)
177
+ svc = get_service_config(active)
178
+ if out.json_mode:
179
+ out.raw_json({"profile": active, **svc.model_dump()})
180
+ return
181
+ fields = [(k, str(v) or "(not set)") for k, v in svc.model_dump().items()]
182
+ sections = [("Active Profile", [("Name", active)] + fields)]
183
+ out.detail("Current Config", sections)
184
+
185
+
186
+ @app.command()
187
+ def test(ctx: typer.Context) -> None:
188
+ """Test API connection with current configuration."""
189
+ actx: AppContext = ctx.obj
190
+ out = actx.output
191
+ active = resolve_active_profile_name(actx.profile)
192
+ out.info(f"Testing profile '{active}' \u2192 {SERVICE_KEY}")
193
+ try:
194
+ data = actx.client.query("{ viewer { id name email } }")
195
+ viewer = data.get("viewer", {})
196
+ name = viewer.get("name", "unknown")
197
+ email = viewer.get("email", "")
198
+ out.success(f"Connected \u2014 authenticated as '{name}' ({email})")
199
+ except Exception as e:
200
+ out.error(f"Connection failed: {e}")
201
+ raise typer.Exit(1) from e
@@ -0,0 +1,205 @@
1
+ """Cycle (sprint) 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 (
11
+ CYCLE_CURRENT_QUERY,
12
+ CYCLE_SHOW_QUERY,
13
+ CYCLES_LIST_QUERY,
14
+ )
15
+
16
+ app = typer.Typer(help="Cycle (sprint) management.")
17
+
18
+
19
+ @app.command()
20
+ def current(
21
+ ctx: typer.Context,
22
+ team: Annotated[str | None, typer.Option("--team", "-t", help="Team key")] = None,
23
+ ) -> None:
24
+ """Show current active cycle with progress and issues."""
25
+ actx: AppContext = ctx.obj
26
+ out = actx.output
27
+
28
+ team_key = team or actx.default_team
29
+ data = actx.client.query(CYCLE_CURRENT_QUERY, {"teamKey": team_key})
30
+ cycles = data.get("cycles", {}).get("nodes", [])
31
+
32
+ if not cycles:
33
+ out.info("No active cycle")
34
+ return
35
+
36
+ cycle = cycles[0]
37
+
38
+ if out.json_mode:
39
+ out.raw_json(cycle)
40
+ return
41
+
42
+ progress = cycle.get("progress", 0)
43
+ progress_pct = f"{progress * 100:.0f}%" if isinstance(progress, (int, float)) else str(progress)
44
+
45
+ sections = [
46
+ (
47
+ "Current Cycle",
48
+ [
49
+ ("Name", cycle.get("name") or f"Cycle {cycle.get('number', '?')}"),
50
+ ("Progress", progress_pct),
51
+ ("Starts", (cycle.get("startsAt") or "")[:10]),
52
+ ("Ends", (cycle.get("endsAt") or "")[:10]),
53
+ ],
54
+ ),
55
+ ]
56
+ out.detail("Active Cycle", sections)
57
+
58
+ issues = cycle.get("issues", {}).get("nodes", [])
59
+ if issues:
60
+ rows = [
61
+ [
62
+ issue.get("identifier", ""),
63
+ issue.get("title", "")[:50],
64
+ str(issue.get("priority", "-")),
65
+ issue.get("state", {}).get("name", ""),
66
+ (issue.get("assignee") or {}).get("name", ""),
67
+ ]
68
+ for issue in issues
69
+ ]
70
+ out.table(
71
+ f"Cycle Issues ({len(issues)})",
72
+ [("ID", "cyan"), ("Title", "white"), ("P", "yellow"), ("State", "green"), ("Assignee", "magenta")],
73
+ rows,
74
+ )
75
+
76
+
77
+ @app.command("list")
78
+ def list_(
79
+ ctx: typer.Context,
80
+ team: Annotated[str | None, typer.Option("--team", "-t", help="Team key")] = None,
81
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 20,
82
+ ) -> None:
83
+ """List past and upcoming cycles."""
84
+ actx: AppContext = ctx.obj
85
+ out = actx.output
86
+
87
+ team_key = team or actx.default_team
88
+ data = actx.client.query(CYCLES_LIST_QUERY, {"teamKey": team_key, "first": limit})
89
+ cycles = data.get("cycles", {}).get("nodes", [])
90
+
91
+ if out.json_mode:
92
+ out.raw_json(cycles)
93
+ return
94
+
95
+ if not cycles:
96
+ out.info("No cycles found")
97
+ return
98
+
99
+ rows = [
100
+ [
101
+ str(c.get("number", "")),
102
+ c.get("name") or f"Cycle {c.get('number', '?')}",
103
+ (c.get("startsAt") or "")[:10],
104
+ (c.get("endsAt") or "")[:10],
105
+ f"{(c.get('progress', 0) or 0) * 100:.0f}%",
106
+ ]
107
+ for c in cycles
108
+ ]
109
+ out.table(
110
+ f"Cycles ({len(cycles)})",
111
+ [("Num", "cyan"), ("Name", "white"), ("Start", "yellow"), ("End", "yellow"), ("Progress", "green")],
112
+ rows,
113
+ )
114
+
115
+
116
+ @app.command()
117
+ def show(
118
+ ctx: typer.Context,
119
+ cycle_id: Annotated[str, typer.Argument(help="Cycle ID (UUID)")],
120
+ ) -> None:
121
+ """Show cycle details: scope, completed, remaining issues."""
122
+ actx: AppContext = ctx.obj
123
+ out = actx.output
124
+
125
+ data = actx.client.query(CYCLE_SHOW_QUERY, {"id": cycle_id})
126
+ cycle = data.get("cycle", {})
127
+
128
+ if out.json_mode:
129
+ out.raw_json(cycle)
130
+ return
131
+
132
+ progress = cycle.get("progress", 0)
133
+ progress_pct = f"{progress * 100:.0f}%" if isinstance(progress, (int, float)) else str(progress)
134
+ issues = cycle.get("issues", {}).get("nodes", [])
135
+
136
+ sections = [
137
+ (
138
+ "Cycle Details",
139
+ [
140
+ ("Name", cycle.get("name") or f"Cycle {cycle.get('number', '?')}"),
141
+ ("Number", str(cycle.get("number", ""))),
142
+ ("Progress", progress_pct),
143
+ ("Starts", (cycle.get("startsAt") or "")[:10]),
144
+ ("Ends", (cycle.get("endsAt") or "")[:10]),
145
+ ("Total Issues", str(len(issues))),
146
+ ],
147
+ ),
148
+ ]
149
+ out.detail(f"Cycle {cycle.get('number', '?')}", sections)
150
+
151
+ if issues:
152
+ rows = [
153
+ [
154
+ issue.get("identifier", ""),
155
+ issue.get("title", "")[:50],
156
+ issue.get("state", {}).get("name", ""),
157
+ (issue.get("assignee") or {}).get("name", ""),
158
+ str(issue.get("estimate") or "-"),
159
+ ]
160
+ for issue in issues
161
+ ]
162
+ out.table(
163
+ "",
164
+ [("ID", "cyan"), ("Title", "white"), ("State", "green"), ("Assignee", "magenta"), ("Est", "yellow")],
165
+ rows,
166
+ )
167
+
168
+
169
+ @app.command()
170
+ def stats(
171
+ ctx: typer.Context,
172
+ team: Annotated[str | None, typer.Option("--team", "-t", help="Team key")] = None,
173
+ ) -> None:
174
+ """Show velocity trends across recent cycles."""
175
+ actx: AppContext = ctx.obj
176
+ out = actx.output
177
+
178
+ team_key = team or actx.default_team
179
+ data = actx.client.query(CYCLES_LIST_QUERY, {"teamKey": team_key, "first": 10})
180
+ cycles = data.get("cycles", {}).get("nodes", [])
181
+
182
+ if out.json_mode:
183
+ out.raw_json({"cycles": len(cycles), "data": cycles})
184
+ return
185
+
186
+ if not cycles:
187
+ out.info("No cycles found for velocity analysis")
188
+ return
189
+
190
+ out.header("Velocity Trends (last 10 cycles)")
191
+ rows = [
192
+ [
193
+ str(c.get("number", "")),
194
+ c.get("name") or f"Cycle {c.get('number', '?')}",
195
+ f"{(c.get('progress', 0) or 0) * 100:.0f}%",
196
+ (c.get("startsAt") or "")[:10],
197
+ (c.get("endsAt") or "")[:10],
198
+ ]
199
+ for c in cycles
200
+ ]
201
+ out.table(
202
+ "",
203
+ [("Num", "cyan"), ("Name", "white"), ("Completion", "green"), ("Start", "yellow"), ("End", "yellow")],
204
+ rows,
205
+ )
@@ -0,0 +1,84 @@
1
+ """Dashboard — quick overview of my issues, current cycle, active projects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from kctl_linear.core.callbacks import AppContext
8
+ from kctl_linear.core.client import DASHBOARD_QUERY
9
+
10
+ app = typer.Typer(help="Quick overview dashboard.")
11
+
12
+
13
+ @app.callback(invoke_without_command=True)
14
+ def dashboard(ctx: typer.Context) -> None:
15
+ """Show my issues, current cycle progress, and active projects."""
16
+ actx: AppContext = ctx.obj
17
+ out = actx.output
18
+ client = actx.client
19
+
20
+ variables: dict[str, str | None] = {"teamKey": actx.default_team}
21
+ data = client.query(DASHBOARD_QUERY, variables)
22
+
23
+ viewer = data.get("viewer", {})
24
+ my_issues = viewer.get("assignedIssues", {}).get("nodes", [])
25
+ cycles = data.get("cycles", {}).get("nodes", [])
26
+ projects = data.get("projects", {}).get("nodes", [])
27
+
28
+ if out.json_mode:
29
+ out.raw_json(
30
+ {
31
+ "viewer": {"name": viewer.get("name"), "email": viewer.get("email")},
32
+ "my_issues": my_issues,
33
+ "current_cycle": cycles[0] if cycles else None,
34
+ "active_projects": projects,
35
+ }
36
+ )
37
+ return
38
+
39
+ # My issues
40
+ out.header(f"My Issues ({len(my_issues)})")
41
+ if my_issues:
42
+ rows = [
43
+ [
44
+ issue.get("identifier", ""),
45
+ issue.get("title", "")[:60],
46
+ str(issue.get("priority", "")),
47
+ issue.get("state", {}).get("name", ""),
48
+ ]
49
+ for issue in my_issues
50
+ ]
51
+ out.table(
52
+ "",
53
+ [("ID", "cyan"), ("Title", "white"), ("Priority", "yellow"), ("State", "green")],
54
+ rows,
55
+ )
56
+ else:
57
+ out.info("No active issues assigned to you")
58
+
59
+ # Current cycle
60
+ if cycles:
61
+ cycle = cycles[0]
62
+ progress = cycle.get("progress", 0)
63
+ progress_pct = f"{progress * 100:.0f}%" if isinstance(progress, (int, float)) else str(progress)
64
+ out.header("Current Cycle")
65
+ out.kv("Name", cycle.get("name") or f"Cycle {cycle.get('number', '?')}")
66
+ out.kv("Progress", progress_pct)
67
+ out.kv("Starts", cycle.get("startsAt", "")[:10])
68
+ out.kv("Ends", cycle.get("endsAt", "")[:10])
69
+ else:
70
+ out.header("Current Cycle")
71
+ out.info("No active cycle")
72
+
73
+ # Active projects
74
+ if projects:
75
+ out.header(f"Active Projects ({len(projects)})")
76
+ rows = [
77
+ [
78
+ proj.get("name", ""),
79
+ f"{(proj.get('progress', 0) or 0) * 100:.0f}%",
80
+ (proj.get("targetDate") or "")[:10],
81
+ ]
82
+ for proj in projects
83
+ ]
84
+ out.table("", [("Name", "cyan"), ("Progress", "green"), ("Target", "yellow")], rows)
@@ -0,0 +1,58 @@
1
+ """Doctor diagnostic checks for kctl-linear."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import typer
8
+
9
+ from kctl_lib.doctor_base import CheckResult, DoctorCheck, run_doctor
10
+
11
+ from kctl_linear.core.callbacks import AppContext
12
+
13
+
14
+ @dataclass
15
+ class ConfigCheck:
16
+ """Check that configuration is present."""
17
+
18
+ name: str = "Configuration"
19
+ profile: str | None = None
20
+
21
+ def run(self) -> CheckResult:
22
+ try:
23
+ from kctl_linear.core.config import get_service_config, resolve_active_profile_name
24
+
25
+ profile = resolve_active_profile_name(self.profile)
26
+ cfg = get_service_config(profile)
27
+ if not cfg.api_key:
28
+ return CheckResult(
29
+ name=self.name,
30
+ status="fail",
31
+ message="No API key configured",
32
+ fix_command="kctl-linear config init",
33
+ )
34
+ return CheckResult(name=self.name, status="ok", message=f"Profile: {profile}")
35
+ except Exception as e:
36
+ return CheckResult(
37
+ name=self.name,
38
+ status="fail",
39
+ message=str(e),
40
+ fix_command="kctl-linear config init",
41
+ )
42
+
43
+
44
+ app = typer.Typer(help="Run diagnostic checks.", invoke_without_command=True)
45
+
46
+
47
+ @app.callback(invoke_without_command=True)
48
+ def doctor(ctx: typer.Context) -> None:
49
+ """Run all diagnostic checks."""
50
+ if ctx.invoked_subcommand is not None:
51
+ return
52
+ actx: AppContext = ctx.obj
53
+ out = actx.output
54
+
55
+ checks: list[DoctorCheck] = [ConfigCheck(profile=actx.profile)]
56
+ all_passed = run_doctor(checks, out) # type: ignore[arg-type]
57
+ if not all_passed:
58
+ raise typer.Exit(code=1)