@kurokeita/add-skill 1.14.0 → 1.16.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/README.md CHANGED
@@ -6,8 +6,8 @@ CLI to manage and install AI agent skills, agents, and workflows to various plat
6
6
 
7
7
  - **List Items**: View a list of available AI skills, agents, and workflows.
8
8
  - **Local status**: Check which items are installed locally.
9
- - **Add Items**: Interactively add skills, agents, and workflows to your supported platforms.
10
- - **Remove Items**: Remove locally installed items from specific platforms.
9
+ - **Add Items**: Interactively add skills, agents, and workflows with live search/filtering support.
10
+ - **Remove Items**: Remove locally installed items with live search/filtering support.
11
11
  - **Import**: Import items directly from GitHub URLs.
12
12
 
13
13
  ## Usage
@@ -40,7 +40,7 @@ pnpx @kurokeita/add-skill list agent --local
40
40
 
41
41
  ### Add Items
42
42
 
43
- Starts an interactive session to select and install items.
43
+ Starts an interactive session to select and install items. Supports live search/filtering as you type.
44
44
 
45
45
  ```bash
46
46
  # Add skills
@@ -55,7 +55,7 @@ pnpx @kurokeita/add-skill add workflow
55
55
 
56
56
  ### Remove Items
57
57
 
58
- Interactively select items and platforms to remove them from.
58
+ Interactively select items and platforms to remove them from. Supports live search/filtering as you type.
59
59
 
60
60
  ```bash
61
61
  # Remove skills
package/dist/bin/cli.js CHANGED
@@ -9,6 +9,7 @@ import pc5 from "picocolors";
9
9
  import os3 from "node:os";
10
10
  import path6 from "node:path";
11
11
  import {
12
+ autocompleteMultiselect,
12
13
  cancel,
13
14
  confirm,
14
15
  intro,
@@ -626,7 +627,7 @@ async function add(type, url, options) {
626
627
  selectedItems = [result.skillName];
627
628
  s2.stop(pc.green(`Fetched ${normalizedType}: ${result.skillName}`));
628
629
  } catch (e) {
629
- s2.stop(pc.red("Failed to fetch"));
630
+ s2.error("Failed to fetch");
630
631
  throw e;
631
632
  }
632
633
  } else {
@@ -646,10 +647,9 @@ async function add(type, url, options) {
646
647
  currentType = void 0;
647
648
  continue;
648
649
  }
649
- const items = await multiselect({
650
+ const items = await autocompleteMultiselect({
650
651
  message: `Select ${normalizedType}s to install:`,
651
- options: availableItems,
652
- required: true
652
+ options: availableItems
653
653
  });
654
654
  if (isCancel(items)) {
655
655
  if (isSingleShot) break;
@@ -880,7 +880,7 @@ async function importItem(type, url, options) {
880
880
  isFile = result.isFile;
881
881
  s.stop(pc2.green(`Fetched: ${itemName}`));
882
882
  } catch (e) {
883
- s.stop(pc2.red("Failed to fetch"));
883
+ s.error("Failed to fetch");
884
884
  throw e;
885
885
  }
886
886
  const shouldFlatten = (normalizedType === "agent" || normalizedType === "workflow") && !isFile;
@@ -1067,6 +1067,7 @@ async function list(type, options) {
1067
1067
  // src/commands/remove.ts
1068
1068
  import path8 from "node:path";
1069
1069
  import {
1070
+ autocompleteMultiselect as autocompleteMultiselect2,
1070
1071
  confirm as confirm3,
1071
1072
  intro as intro4,
1072
1073
  isCancel as isCancel4,
@@ -1135,10 +1136,9 @@ async function remove(type, options) {
1135
1136
  continue;
1136
1137
  }
1137
1138
  const sortedItems = Array.from(uniqueItems).sort();
1138
- const itemSelections = await multiselect2({
1139
+ const itemSelections = await autocompleteMultiselect2({
1139
1140
  message: `Select ${normalizedType}s to remove:`,
1140
- options: sortedItems.map((item) => ({ value: item, label: item })),
1141
- required: true
1141
+ options: sortedItems.map((item) => ({ value: item, label: item }))
1142
1142
  });
1143
1143
  if (isCancel4(itemSelections)) {
1144
1144
  if (isSingleShot) break;
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: gh-address-comments
3
+ description: Help address review/issue comments on the open GitHub PR for the current branch using gh CLI; verify gh auth first and prompt the user to authenticate if not logged in.
4
+ metadata:
5
+ short-description: Address comments in a GitHub PR review
6
+ ---
7
+
8
+ # PR Comment Handler
9
+
10
+ Guide to find the open PR for the current branch and address its comments with gh CLI. Run all `gh` commands with elevated network access.
11
+
12
+ Prereq: ensure `gh` is authenticated (for example, run `gh auth login` once), then run `gh auth status` with escalated permissions (include workflow/repo scopes) so `gh` commands succeed. If sandboxing blocks `gh auth status`, rerun it with `sandbox_permissions=require_escalated`.
13
+
14
+ ## 1) Inspect comments needing attention
15
+
16
+ - Run scripts/fetch_comments.py which will print out all the comments and review threads on the PR
17
+
18
+ ## 2) Ask the user for clarification
19
+
20
+ - Number all the review threads and comments and provide a short summary of what would be required to apply a fix for it
21
+ - Ask the user which numbered comments should be addressed
22
+
23
+ ## 3) If user chooses comments
24
+
25
+ - Apply fixes for the selected comments
26
+
27
+ Notes:
28
+
29
+ - If gh hits auth/rate issues mid-run, prompt the user to re-authenticate with `gh auth login`, then retry.
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Fetch all PR conversation comments + reviews + review threads (inline threads)
4
+ for the PR associated with the current git branch, by shelling out to:
5
+
6
+ gh api graphql
7
+
8
+ Requires:
9
+ - `gh auth login` already set up
10
+ - current branch has an associated (open) PR
11
+
12
+ Usage:
13
+ python fetch_comments.py > pr_comments.json
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import subprocess
20
+ import sys
21
+ from typing import Any
22
+
23
+ QUERY = """\
24
+ query(
25
+ $owner: String!,
26
+ $repo: String!,
27
+ $number: Int!,
28
+ $commentsCursor: String,
29
+ $reviewsCursor: String,
30
+ $threadsCursor: String
31
+ ) {
32
+ repository(owner: $owner, name: $repo) {
33
+ pullRequest(number: $number) {
34
+ number
35
+ url
36
+ title
37
+ state
38
+
39
+ # Top-level "Conversation" comments (issue comments on the PR)
40
+ comments(first: 100, after: $commentsCursor) {
41
+ pageInfo { hasNextPage endCursor }
42
+ nodes {
43
+ id
44
+ body
45
+ createdAt
46
+ updatedAt
47
+ author { login }
48
+ }
49
+ }
50
+
51
+ # Review submissions (Approve / Request changes / Comment), with body if present
52
+ reviews(first: 100, after: $reviewsCursor) {
53
+ pageInfo { hasNextPage endCursor }
54
+ nodes {
55
+ id
56
+ state
57
+ body
58
+ submittedAt
59
+ author { login }
60
+ }
61
+ }
62
+
63
+ # Inline review threads (grouped), includes resolved state
64
+ reviewThreads(first: 100, after: $threadsCursor) {
65
+ pageInfo { hasNextPage endCursor }
66
+ nodes {
67
+ id
68
+ isResolved
69
+ isOutdated
70
+ path
71
+ line
72
+ diffSide
73
+ startLine
74
+ startDiffSide
75
+ originalLine
76
+ originalStartLine
77
+ resolvedBy { login }
78
+ comments(first: 100) {
79
+ nodes {
80
+ id
81
+ body
82
+ createdAt
83
+ updatedAt
84
+ author { login }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ """
93
+
94
+
95
+ def _run(cmd: list[str], stdin: str | None = None) -> str:
96
+ p = subprocess.run(cmd, input=stdin, capture_output=True, text=True)
97
+ if p.returncode != 0:
98
+ raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{p.stderr}")
99
+ return p.stdout
100
+
101
+
102
+ def _run_json(cmd: list[str], stdin: str | None = None) -> dict[str, Any]:
103
+ out = _run(cmd, stdin=stdin)
104
+ try:
105
+ return json.loads(out)
106
+ except json.JSONDecodeError as e:
107
+ raise RuntimeError(f"Failed to parse JSON from command output: {e}\nRaw:\n{out}") from e
108
+
109
+
110
+ def _ensure_gh_authenticated() -> None:
111
+ try:
112
+ _run(["gh", "auth", "status"])
113
+ except RuntimeError:
114
+ print("run `gh auth login` to authenticate the GitHub CLI", file=sys.stderr)
115
+ raise RuntimeError("gh auth status failed; run `gh auth login` to authenticate the GitHub CLI") from None
116
+
117
+
118
+ def gh_pr_view_json(fields: str) -> dict[str, Any]:
119
+ # fields is a comma-separated list like: "number,headRepositoryOwner,headRepository"
120
+ return _run_json(["gh", "pr", "view", "--json", fields])
121
+
122
+
123
+ def get_current_pr_ref() -> tuple[str, str, int]:
124
+ """
125
+ Resolve the PR for the current branch (whatever gh considers associated).
126
+ Works for cross-repo PRs too, by reading head repository owner/name.
127
+ """
128
+ pr = gh_pr_view_json("number,headRepositoryOwner,headRepository")
129
+ owner = pr["headRepositoryOwner"]["login"]
130
+ repo = pr["headRepository"]["name"]
131
+ number = int(pr["number"])
132
+ return owner, repo, number
133
+
134
+
135
+ def gh_api_graphql(
136
+ owner: str,
137
+ repo: str,
138
+ number: int,
139
+ comments_cursor: str | None = None,
140
+ reviews_cursor: str | None = None,
141
+ threads_cursor: str | None = None,
142
+ ) -> dict[str, Any]:
143
+ """
144
+ Call `gh api graphql` using -F variables, avoiding JSON blobs with nulls.
145
+ Query is passed via stdin using query=@- to avoid shell newline/quoting issues.
146
+ """
147
+ cmd = [
148
+ "gh",
149
+ "api",
150
+ "graphql",
151
+ "-F",
152
+ "query=@-",
153
+ "-F",
154
+ f"owner={owner}",
155
+ "-F",
156
+ f"repo={repo}",
157
+ "-F",
158
+ f"number={number}",
159
+ ]
160
+ if comments_cursor:
161
+ cmd += ["-F", f"commentsCursor={comments_cursor}"]
162
+ if reviews_cursor:
163
+ cmd += ["-F", f"reviewsCursor={reviews_cursor}"]
164
+ if threads_cursor:
165
+ cmd += ["-F", f"threadsCursor={threads_cursor}"]
166
+
167
+ return _run_json(cmd, stdin=QUERY)
168
+
169
+
170
+ def fetch_all(owner: str, repo: str, number: int) -> dict[str, Any]:
171
+ conversation_comments: list[dict[str, Any]] = []
172
+ reviews: list[dict[str, Any]] = []
173
+ review_threads: list[dict[str, Any]] = []
174
+
175
+ comments_cursor: str | None = None
176
+ reviews_cursor: str | None = None
177
+ threads_cursor: str | None = None
178
+
179
+ pr_meta: dict[str, Any] | None = None
180
+
181
+ while True:
182
+ payload = gh_api_graphql(
183
+ owner=owner,
184
+ repo=repo,
185
+ number=number,
186
+ comments_cursor=comments_cursor,
187
+ reviews_cursor=reviews_cursor,
188
+ threads_cursor=threads_cursor,
189
+ )
190
+
191
+ if "errors" in payload and payload["errors"]:
192
+ raise RuntimeError(f"GitHub GraphQL errors:\n{json.dumps(payload['errors'], indent=2)}")
193
+
194
+ pr = payload["data"]["repository"]["pullRequest"]
195
+ if pr_meta is None:
196
+ pr_meta = {
197
+ "number": pr["number"],
198
+ "url": pr["url"],
199
+ "title": pr["title"],
200
+ "state": pr["state"],
201
+ "owner": owner,
202
+ "repo": repo,
203
+ }
204
+
205
+ c = pr["comments"]
206
+ r = pr["reviews"]
207
+ t = pr["reviewThreads"]
208
+
209
+ conversation_comments.extend(c.get("nodes") or [])
210
+ reviews.extend(r.get("nodes") or [])
211
+ review_threads.extend(t.get("nodes") or [])
212
+
213
+ comments_cursor = c["pageInfo"]["endCursor"] if c["pageInfo"]["hasNextPage"] else None
214
+ reviews_cursor = r["pageInfo"]["endCursor"] if r["pageInfo"]["hasNextPage"] else None
215
+ threads_cursor = t["pageInfo"]["endCursor"] if t["pageInfo"]["hasNextPage"] else None
216
+
217
+ if not (comments_cursor or reviews_cursor or threads_cursor):
218
+ break
219
+
220
+ assert pr_meta is not None
221
+ return {
222
+ "pull_request": pr_meta,
223
+ "conversation_comments": conversation_comments,
224
+ "reviews": reviews,
225
+ "review_threads": review_threads,
226
+ }
227
+
228
+
229
+ def main() -> None:
230
+ _ensure_gh_authenticated()
231
+ owner, repo, number = get_current_pr_ref()
232
+ result = fetch_all(owner, repo, number)
233
+ print(json.dumps(result, indent=2))
234
+
235
+
236
+ if __name__ == "__main__":
237
+ main()
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: "gh-fix-ci"
3
+ description: "Use when a user asks to debug or fix failing GitHub PR checks that run in GitHub Actions; use `gh` to inspect checks and logs, summarize failure context, draft a fix plan, and implement only after explicit approval. Treat external providers (for example Buildkite) as out of scope and report only the details URL."
4
+ ---
5
+
6
+ # Gh Pr Checks Plan Fix
7
+
8
+ ## Overview
9
+
10
+ Use gh to locate failing PR checks, fetch GitHub Actions logs for actionable failures, summarize the failure snippet, then propose a fix plan and implement after explicit approval.
11
+
12
+ - If a plan-oriented skill (for example `create-plan`) is available, use it; otherwise draft a concise plan inline and request approval before implementing.
13
+
14
+ Prereq: authenticate with the standard GitHub CLI once (for example, run `gh auth login`), then confirm with `gh auth status` (repo + workflow scopes are typically required).
15
+
16
+ ## Inputs
17
+
18
+ - `repo`: path inside the repo (default `.`)
19
+ - `pr`: PR number or URL (optional; defaults to current branch PR)
20
+ - `gh` authentication for the repo host
21
+
22
+ ## Quick start
23
+
24
+ - `python "<path-to-skill>/scripts/inspect_pr_checks.py" --repo "." --pr "<number-or-url>"`
25
+ - Add `--json` if you want machine-friendly output for summarization.
26
+
27
+ ## Workflow
28
+
29
+ 1. Verify gh authentication.
30
+ - Run `gh auth status` in the repo.
31
+ - If unauthenticated, ask the user to run `gh auth login` (ensuring repo + workflow scopes) before proceeding.
32
+ 2. Resolve the PR.
33
+ - Prefer the current branch PR: `gh pr view --json number,url`.
34
+ - If the user provides a PR number or URL, use that directly.
35
+ 3. Inspect failing checks (GitHub Actions only).
36
+ - Preferred: run the bundled script (handles gh field drift and job-log fallbacks):
37
+ - `python "<path-to-skill>/scripts/inspect_pr_checks.py" --repo "." --pr "<number-or-url>"`
38
+ - Add `--json` for machine-friendly output.
39
+ - Manual fallback:
40
+ - `gh pr checks <pr> --json name,state,bucket,link,startedAt,completedAt,workflow`
41
+ - If a field is rejected, rerun with the available fields reported by `gh`.
42
+ - For each failing check, extract the run id from `detailsUrl` and run:
43
+ - `gh run view <run_id> --json name,workflowName,conclusion,status,url,event,headBranch,headSha`
44
+ - `gh run view <run_id> --log`
45
+ - If the run log says it is still in progress, fetch job logs directly:
46
+ - `gh api "/repos/<owner>/<repo>/actions/jobs/<job_id>/logs" > "<path>"`
47
+ 4. Scope non-GitHub Actions checks.
48
+ - If `detailsUrl` is not a GitHub Actions run, label it as external and only report the URL.
49
+ - Do not attempt Buildkite or other providers; keep the workflow lean.
50
+ 5. Summarize failures for the user.
51
+ - Provide the failing check name, run URL (if any), and a concise log snippet.
52
+ - Call out missing logs explicitly.
53
+ 6. Create a plan.
54
+ - Use the `create-plan` skill to draft a concise plan and request approval.
55
+ 7. Implement after approval.
56
+ - Apply the approved plan, summarize diffs/tests, and ask about opening a PR.
57
+ 8. Recheck status.
58
+ - After changes, suggest re-running the relevant tests and `gh pr checks` to confirm.
59
+
60
+ ## Bundled Resources
61
+
62
+ ### scripts/inspect_pr_checks.py
63
+
64
+ Fetch failing PR checks, pull GitHub Actions logs, and extract a failure snippet. Exits non-zero when failures remain so it can be used in automation.
65
+
66
+ Usage examples:
67
+
68
+ - `python "<path-to-skill>/scripts/inspect_pr_checks.py" --repo "." --pr "123"`
69
+ - `python "<path-to-skill>/scripts/inspect_pr_checks.py" --repo "." --pr "https://github.com/org/repo/pull/123" --json`
70
+ - `python "<path-to-skill>/scripts/inspect_pr_checks.py" --repo "." --max-lines 200 --context 40`
@@ -0,0 +1,509 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from shutil import which
11
+ from typing import Any, Iterable, Sequence
12
+
13
+ FAILURE_CONCLUSIONS = {
14
+ "failure",
15
+ "cancelled",
16
+ "timed_out",
17
+ "action_required",
18
+ }
19
+
20
+ FAILURE_STATES = {
21
+ "failure",
22
+ "error",
23
+ "cancelled",
24
+ "timed_out",
25
+ "action_required",
26
+ }
27
+
28
+ FAILURE_BUCKETS = {"fail"}
29
+
30
+ FAILURE_MARKERS = (
31
+ "error",
32
+ "fail",
33
+ "failed",
34
+ "traceback",
35
+ "exception",
36
+ "assert",
37
+ "panic",
38
+ "fatal",
39
+ "timeout",
40
+ "segmentation fault",
41
+ )
42
+
43
+ DEFAULT_MAX_LINES = 160
44
+ DEFAULT_CONTEXT_LINES = 30
45
+ PENDING_LOG_MARKERS = (
46
+ "still in progress",
47
+ "log will be available when it is complete",
48
+ )
49
+
50
+
51
+ class GhResult:
52
+ def __init__(self, returncode: int, stdout: str, stderr: str):
53
+ self.returncode = returncode
54
+ self.stdout = stdout
55
+ self.stderr = stderr
56
+
57
+
58
+ def run_gh_command(args: Sequence[str], cwd: Path) -> GhResult:
59
+ process = subprocess.run(
60
+ ["gh", *args],
61
+ cwd=cwd,
62
+ text=True,
63
+ capture_output=True,
64
+ )
65
+ return GhResult(process.returncode, process.stdout, process.stderr)
66
+
67
+
68
+ def run_gh_command_raw(args: Sequence[str], cwd: Path) -> tuple[int, bytes, str]:
69
+ process = subprocess.run(
70
+ ["gh", *args],
71
+ cwd=cwd,
72
+ capture_output=True,
73
+ )
74
+ stderr = process.stderr.decode(errors="replace")
75
+ return process.returncode, process.stdout, stderr
76
+
77
+
78
+ def parse_args() -> argparse.Namespace:
79
+ parser = argparse.ArgumentParser(
80
+ description=(
81
+ "Inspect failing GitHub PR checks, fetch GitHub Actions logs, and extract a "
82
+ "failure snippet."
83
+ ),
84
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
85
+ )
86
+ parser.add_argument("--repo", default=".", help="Path inside the target Git repository.")
87
+ parser.add_argument(
88
+ "--pr", default=None, help="PR number or URL (defaults to current branch PR)."
89
+ )
90
+ parser.add_argument("--max-lines", type=int, default=DEFAULT_MAX_LINES)
91
+ parser.add_argument("--context", type=int, default=DEFAULT_CONTEXT_LINES)
92
+ parser.add_argument("--json", action="store_true", help="Emit JSON instead of text output.")
93
+ return parser.parse_args()
94
+
95
+
96
+ def main() -> int:
97
+ args = parse_args()
98
+ repo_root = find_git_root(Path(args.repo))
99
+ if repo_root is None:
100
+ print("Error: not inside a Git repository.", file=sys.stderr)
101
+ return 1
102
+
103
+ if not ensure_gh_available(repo_root):
104
+ return 1
105
+
106
+ pr_value = resolve_pr(args.pr, repo_root)
107
+ if pr_value is None:
108
+ return 1
109
+
110
+ checks = fetch_checks(pr_value, repo_root)
111
+ if checks is None:
112
+ return 1
113
+
114
+ failing = [c for c in checks if is_failing(c)]
115
+ if not failing:
116
+ print(f"PR #{pr_value}: no failing checks detected.")
117
+ return 0
118
+
119
+ results = []
120
+ for check in failing:
121
+ results.append(
122
+ analyze_check(
123
+ check,
124
+ repo_root=repo_root,
125
+ max_lines=max(1, args.max_lines),
126
+ context=max(1, args.context),
127
+ )
128
+ )
129
+
130
+ if args.json:
131
+ print(json.dumps({"pr": pr_value, "results": results}, indent=2))
132
+ else:
133
+ render_results(pr_value, results)
134
+
135
+ return 1
136
+
137
+
138
+ def find_git_root(start: Path) -> Path | None:
139
+ result = subprocess.run(
140
+ ["git", "rev-parse", "--show-toplevel"],
141
+ cwd=start,
142
+ text=True,
143
+ capture_output=True,
144
+ )
145
+ if result.returncode != 0:
146
+ return None
147
+ return Path(result.stdout.strip())
148
+
149
+
150
+ def ensure_gh_available(repo_root: Path) -> bool:
151
+ if which("gh") is None:
152
+ print("Error: gh is not installed or not on PATH.", file=sys.stderr)
153
+ return False
154
+ result = run_gh_command(["auth", "status"], cwd=repo_root)
155
+ if result.returncode == 0:
156
+ return True
157
+ message = (result.stderr or result.stdout or "").strip()
158
+ print(message or "Error: gh not authenticated.", file=sys.stderr)
159
+ return False
160
+
161
+
162
+ def resolve_pr(pr_value: str | None, repo_root: Path) -> str | None:
163
+ if pr_value:
164
+ return pr_value
165
+ result = run_gh_command(["pr", "view", "--json", "number"], cwd=repo_root)
166
+ if result.returncode != 0:
167
+ message = (result.stderr or result.stdout or "").strip()
168
+ print(message or "Error: unable to resolve PR.", file=sys.stderr)
169
+ return None
170
+ try:
171
+ data = json.loads(result.stdout or "{}")
172
+ except json.JSONDecodeError:
173
+ print("Error: unable to parse PR JSON.", file=sys.stderr)
174
+ return None
175
+ number = data.get("number")
176
+ if not number:
177
+ print("Error: no PR number found.", file=sys.stderr)
178
+ return None
179
+ return str(number)
180
+
181
+
182
+ def fetch_checks(pr_value: str, repo_root: Path) -> list[dict[str, Any]] | None:
183
+ primary_fields = ["name", "state", "conclusion", "detailsUrl", "startedAt", "completedAt"]
184
+ result = run_gh_command(
185
+ ["pr", "checks", pr_value, "--json", ",".join(primary_fields)],
186
+ cwd=repo_root,
187
+ )
188
+ if result.returncode != 0:
189
+ message = "\n".join(filter(None, [result.stderr, result.stdout])).strip()
190
+ available_fields = parse_available_fields(message)
191
+ if available_fields:
192
+ fallback_fields = [
193
+ "name",
194
+ "state",
195
+ "bucket",
196
+ "link",
197
+ "startedAt",
198
+ "completedAt",
199
+ "workflow",
200
+ ]
201
+ selected_fields = [field for field in fallback_fields if field in available_fields]
202
+ if not selected_fields:
203
+ print("Error: no usable fields available for gh pr checks.", file=sys.stderr)
204
+ return None
205
+ result = run_gh_command(
206
+ ["pr", "checks", pr_value, "--json", ",".join(selected_fields)],
207
+ cwd=repo_root,
208
+ )
209
+ if result.returncode != 0:
210
+ message = (result.stderr or result.stdout or "").strip()
211
+ print(message or "Error: gh pr checks failed.", file=sys.stderr)
212
+ return None
213
+ else:
214
+ print(message or "Error: gh pr checks failed.", file=sys.stderr)
215
+ return None
216
+ try:
217
+ data = json.loads(result.stdout or "[]")
218
+ except json.JSONDecodeError:
219
+ print("Error: unable to parse checks JSON.", file=sys.stderr)
220
+ return None
221
+ if not isinstance(data, list):
222
+ print("Error: unexpected checks JSON shape.", file=sys.stderr)
223
+ return None
224
+ return data
225
+
226
+
227
+ def is_failing(check: dict[str, Any]) -> bool:
228
+ conclusion = normalize_field(check.get("conclusion"))
229
+ if conclusion in FAILURE_CONCLUSIONS:
230
+ return True
231
+ state = normalize_field(check.get("state") or check.get("status"))
232
+ if state in FAILURE_STATES:
233
+ return True
234
+ bucket = normalize_field(check.get("bucket"))
235
+ return bucket in FAILURE_BUCKETS
236
+
237
+
238
+ def analyze_check(
239
+ check: dict[str, Any],
240
+ repo_root: Path,
241
+ max_lines: int,
242
+ context: int,
243
+ ) -> dict[str, Any]:
244
+ url = check.get("detailsUrl") or check.get("link") or ""
245
+ run_id = extract_run_id(url)
246
+ job_id = extract_job_id(url)
247
+ base: dict[str, Any] = {
248
+ "name": check.get("name", ""),
249
+ "detailsUrl": url,
250
+ "runId": run_id,
251
+ "jobId": job_id,
252
+ }
253
+
254
+ if run_id is None:
255
+ base["status"] = "external"
256
+ base["note"] = "No GitHub Actions run id detected in detailsUrl."
257
+ return base
258
+
259
+ metadata = fetch_run_metadata(run_id, repo_root)
260
+ log_text, log_error, log_status = fetch_check_log(
261
+ run_id=run_id,
262
+ job_id=job_id,
263
+ repo_root=repo_root,
264
+ )
265
+
266
+ if log_status == "pending":
267
+ base["status"] = "log_pending"
268
+ base["note"] = log_error or "Logs are not available yet."
269
+ if metadata:
270
+ base["run"] = metadata
271
+ return base
272
+
273
+ if log_error:
274
+ base["status"] = "log_unavailable"
275
+ base["error"] = log_error
276
+ if metadata:
277
+ base["run"] = metadata
278
+ return base
279
+
280
+ snippet = extract_failure_snippet(log_text, max_lines=max_lines, context=context)
281
+ base["status"] = "ok"
282
+ base["run"] = metadata or {}
283
+ base["logSnippet"] = snippet
284
+ base["logTail"] = tail_lines(log_text, max_lines)
285
+ return base
286
+
287
+
288
+ def extract_run_id(url: str) -> str | None:
289
+ if not url:
290
+ return None
291
+ for pattern in (r"/actions/runs/(\d+)", r"/runs/(\d+)"):
292
+ match = re.search(pattern, url)
293
+ if match:
294
+ return match.group(1)
295
+ return None
296
+
297
+
298
+ def extract_job_id(url: str) -> str | None:
299
+ if not url:
300
+ return None
301
+ match = re.search(r"/actions/runs/\d+/job/(\d+)", url)
302
+ if match:
303
+ return match.group(1)
304
+ match = re.search(r"/job/(\d+)", url)
305
+ if match:
306
+ return match.group(1)
307
+ return None
308
+
309
+
310
+ def fetch_run_metadata(run_id: str, repo_root: Path) -> dict[str, Any] | None:
311
+ fields = [
312
+ "conclusion",
313
+ "status",
314
+ "workflowName",
315
+ "name",
316
+ "event",
317
+ "headBranch",
318
+ "headSha",
319
+ "url",
320
+ ]
321
+ result = run_gh_command(["run", "view", run_id, "--json", ",".join(fields)], cwd=repo_root)
322
+ if result.returncode != 0:
323
+ return None
324
+ try:
325
+ data = json.loads(result.stdout or "{}")
326
+ except json.JSONDecodeError:
327
+ return None
328
+ if not isinstance(data, dict):
329
+ return None
330
+ return data
331
+
332
+
333
+ def fetch_check_log(
334
+ run_id: str,
335
+ job_id: str | None,
336
+ repo_root: Path,
337
+ ) -> tuple[str, str, str]:
338
+ log_text, log_error = fetch_run_log(run_id, repo_root)
339
+ if not log_error:
340
+ return log_text, "", "ok"
341
+
342
+ if is_log_pending_message(log_error) and job_id:
343
+ job_log, job_error = fetch_job_log(job_id, repo_root)
344
+ if job_log:
345
+ return job_log, "", "ok"
346
+ if job_error and is_log_pending_message(job_error):
347
+ return "", job_error, "pending"
348
+ if job_error:
349
+ return "", job_error, "error"
350
+ return "", log_error, "pending"
351
+
352
+ if is_log_pending_message(log_error):
353
+ return "", log_error, "pending"
354
+
355
+ return "", log_error, "error"
356
+
357
+
358
+ def fetch_run_log(run_id: str, repo_root: Path) -> tuple[str, str]:
359
+ result = run_gh_command(["run", "view", run_id, "--log"], cwd=repo_root)
360
+ if result.returncode != 0:
361
+ error = (result.stderr or result.stdout or "").strip()
362
+ return "", error or "gh run view failed"
363
+ return result.stdout, ""
364
+
365
+
366
+ def fetch_job_log(job_id: str, repo_root: Path) -> tuple[str, str]:
367
+ repo_slug = fetch_repo_slug(repo_root)
368
+ if not repo_slug:
369
+ return "", "Error: unable to resolve repository name for job logs."
370
+ endpoint = f"/repos/{repo_slug}/actions/jobs/{job_id}/logs"
371
+ returncode, stdout_bytes, stderr = run_gh_command_raw(["api", endpoint], cwd=repo_root)
372
+ if returncode != 0:
373
+ message = (stderr or stdout_bytes.decode(errors="replace")).strip()
374
+ return "", message or "gh api job logs failed"
375
+ if is_zip_payload(stdout_bytes):
376
+ return "", "Job logs returned a zip archive; unable to parse."
377
+ return stdout_bytes.decode(errors="replace"), ""
378
+
379
+
380
+ def fetch_repo_slug(repo_root: Path) -> str | None:
381
+ result = run_gh_command(["repo", "view", "--json", "nameWithOwner"], cwd=repo_root)
382
+ if result.returncode != 0:
383
+ return None
384
+ try:
385
+ data = json.loads(result.stdout or "{}")
386
+ except json.JSONDecodeError:
387
+ return None
388
+ name_with_owner = data.get("nameWithOwner")
389
+ if not name_with_owner:
390
+ return None
391
+ return str(name_with_owner)
392
+
393
+
394
+ def normalize_field(value: Any) -> str:
395
+ if value is None:
396
+ return ""
397
+ return str(value).strip().lower()
398
+
399
+
400
+ def parse_available_fields(message: str) -> list[str]:
401
+ if "Available fields:" not in message:
402
+ return []
403
+ fields: list[str] = []
404
+ collecting = False
405
+ for line in message.splitlines():
406
+ if "Available fields:" in line:
407
+ collecting = True
408
+ continue
409
+ if not collecting:
410
+ continue
411
+ field = line.strip()
412
+ if not field:
413
+ continue
414
+ fields.append(field)
415
+ return fields
416
+
417
+
418
+ def is_log_pending_message(message: str) -> bool:
419
+ lowered = message.lower()
420
+ return any(marker in lowered for marker in PENDING_LOG_MARKERS)
421
+
422
+
423
+ def is_zip_payload(payload: bytes) -> bool:
424
+ return payload.startswith(b"PK")
425
+
426
+
427
+ def extract_failure_snippet(log_text: str, max_lines: int, context: int) -> str:
428
+ lines = log_text.splitlines()
429
+ if not lines:
430
+ return ""
431
+
432
+ marker_index = find_failure_index(lines)
433
+ if marker_index is None:
434
+ return "\n".join(lines[-max_lines:])
435
+
436
+ start = max(0, marker_index - context)
437
+ end = min(len(lines), marker_index + context)
438
+ window = lines[start:end]
439
+ if len(window) > max_lines:
440
+ window = window[-max_lines:]
441
+ return "\n".join(window)
442
+
443
+
444
+ def find_failure_index(lines: Sequence[str]) -> int | None:
445
+ for idx in range(len(lines) - 1, -1, -1):
446
+ lowered = lines[idx].lower()
447
+ if any(marker in lowered for marker in FAILURE_MARKERS):
448
+ return idx
449
+ return None
450
+
451
+
452
+ def tail_lines(text: str, max_lines: int) -> str:
453
+ if max_lines <= 0:
454
+ return ""
455
+ lines = text.splitlines()
456
+ return "\n".join(lines[-max_lines:])
457
+
458
+
459
+ def render_results(pr_number: str, results: Iterable[dict[str, Any]]) -> None:
460
+ results_list = list(results)
461
+ print(f"PR #{pr_number}: {len(results_list)} failing checks analyzed.")
462
+ for result in results_list:
463
+ print("-" * 60)
464
+ print(f"Check: {result.get('name', '')}")
465
+ if result.get("detailsUrl"):
466
+ print(f"Details: {result['detailsUrl']}")
467
+ run_id = result.get("runId")
468
+ if run_id:
469
+ print(f"Run ID: {run_id}")
470
+ job_id = result.get("jobId")
471
+ if job_id:
472
+ print(f"Job ID: {job_id}")
473
+ status = result.get("status", "unknown")
474
+ print(f"Status: {status}")
475
+
476
+ run_meta = result.get("run", {})
477
+ if run_meta:
478
+ branch = run_meta.get("headBranch", "")
479
+ sha = (run_meta.get("headSha") or "")[:12]
480
+ workflow = run_meta.get("workflowName") or run_meta.get("name") or ""
481
+ conclusion = run_meta.get("conclusion") or run_meta.get("status") or ""
482
+ print(f"Workflow: {workflow} ({conclusion})")
483
+ if branch or sha:
484
+ print(f"Branch/SHA: {branch} {sha}")
485
+ if run_meta.get("url"):
486
+ print(f"Run URL: {run_meta['url']}")
487
+
488
+ if result.get("note"):
489
+ print(f"Note: {result['note']}")
490
+
491
+ if result.get("error"):
492
+ print(f"Error fetching logs: {result['error']}")
493
+ continue
494
+
495
+ snippet = result.get("logSnippet") or ""
496
+ if snippet:
497
+ print("Failure snippet:")
498
+ print(indent_block(snippet, prefix=" "))
499
+ else:
500
+ print("No snippet available.")
501
+ print("-" * 60)
502
+
503
+
504
+ def indent_block(text: str, prefix: str = " ") -> str:
505
+ return "\n".join(f"{prefix}{line}" for line in text.splitlines())
506
+
507
+
508
+ if __name__ == "__main__":
509
+ raise SystemExit(main())
@@ -30,6 +30,71 @@ Create clear and informative pull requests to ensure that reviewers can understa
30
30
  - How they were tested.
31
31
  - Any breaking changes or new dependencies.
32
32
 
33
+ ### Merge Strategy
34
+
35
+ This repo uses **squash merge** for all PRs. The resulting commit on `main` follows a strict format:
36
+
37
+ - **Commit title:** `<conventional-commit-title> (#<pr-number>)`
38
+ - Example: `feat(cli): implement live search filtering for item selection (#24)`
39
+ - **Extended description:** A concise summary paragraph followed by bullet-point details of key changes.
40
+ - The PR description body becomes the commit message body during squash merge.
41
+ - Write the description so it reads well as a git log entry — focus on **what** changed and **why**, not **how**.
42
+ - Use bullet points for listing specific file-level or feature-level changes.
43
+ - Avoid checklist boilerplate in the final description; it clutters the git history.
44
+
45
+ #### Commit Message Examples
46
+
47
+ **Complex feature** — Summary paragraph + "Key changes:" section:
48
+
49
+ ```
50
+ feat(scope): add feature description (#N)
51
+
52
+ One or two sentences describing what changed and why it matters. Focus on
53
+ the user-facing impact or developer experience improvement.
54
+
55
+ Key changes:
56
+ - Updated dependency X to version Y for improved performance.
57
+ - Refactored module A to support the new behavior.
58
+ - Added tests covering edge cases in the new logic.
59
+ - Updated documentation to reflect the new capability.
60
+ ```
61
+
62
+ **Multi-part refactor** — Summary paragraph + plain bullets:
63
+
64
+ ```
65
+ refactor(scope): describe the refactoring goal
66
+
67
+ Brief explanation of the refactoring goal and what it improves
68
+ (maintainability, performance, clarity, etc.).
69
+
70
+ - Split monolithic module X into focused submodules A, B, and C.
71
+ - Module A: handles responsibility 1 with clear public API.
72
+ - Module B: handles responsibility 2, extracted for reuse.
73
+ - Ensured all modules follow progressive disclosure principles.
74
+ ```
75
+
76
+ **Architecture change** — Bold-prefixed bullets for scannability:
77
+
78
+ ```
79
+ feat: describe the architectural change (#N)
80
+
81
+ - **Component A**: Implemented the new subsystem for handling X.
82
+ - **Component B**: Added transformation logic between formats Y and Z.
83
+ - **Unified Entry**: Refactored the main entry point to support both
84
+ interactive and programmatic usage.
85
+ - **Test Coverage**: Achieved and verified full coverage for all modules.
86
+ ```
87
+
88
+ **Simple change** — Minimal bullets, no summary paragraph:
89
+
90
+ ```
91
+ feat(scope): add or update something
92
+
93
+ - Add configuration file with default settings
94
+ - Add reference documentation for the new feature
95
+ - Add example usage in the project README
96
+ ```
97
+
33
98
  ### Best Practices
34
99
 
35
100
  - **Keep it focused**: One PR per logical change. Avoid "mega-PRs".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurokeita/add-skill",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "description": "CLI to install AI agent skills to various platforms",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "license": "GPL-3.0",
38
38
  "packageManager": "pnpm@10.28.0",
39
39
  "dependencies": {
40
- "@clack/prompts": "^0.11.0",
40
+ "@clack/prompts": "^1.2.0",
41
41
  "commander": "^14.0.2",
42
42
  "fs-extra": "^11.3.3",
43
43
  "js-yaml": "^4.1.1",