From 147606b4f374928f2c6d60213c7e596a3d04b414 Mon Sep 17 00:00:00 2001 From: Jan Scheffler Date: Tue, 24 Feb 2026 11:53:51 +0100 Subject: [PATCH 1/3] feat: add `install --skills` command for AI agent skill files Bundles SKILL.md and reference docs in the package, copyable to .claude/skills/qodev-gitlab/ via `qodev-gitlab install --skills`. Teaches AI coding agents the full CLI surface through progressive disclosure. Co-Authored-By: Claude Opus 4.6 --- src/qodev_gitlab_cli/app.py | 3 + src/qodev_gitlab_cli/commands/install.py | 58 +++++++ src/qodev_gitlab_cli/skills/SKILL.md | 144 ++++++++++++++++++ src/qodev_gitlab_cli/skills/__init__.py | 0 .../skills/references/__init__.py | 0 .../skills/references/mr-workflows.md | 100 ++++++++++++ .../skills/references/pipeline-monitoring.md | 74 +++++++++ tests/test_install.py | 54 +++++++ 8 files changed, 433 insertions(+) create mode 100644 src/qodev_gitlab_cli/commands/install.py create mode 100644 src/qodev_gitlab_cli/skills/SKILL.md create mode 100644 src/qodev_gitlab_cli/skills/__init__.py create mode 100644 src/qodev_gitlab_cli/skills/references/__init__.py create mode 100644 src/qodev_gitlab_cli/skills/references/mr-workflows.md create mode 100644 src/qodev_gitlab_cli/skills/references/pipeline-monitoring.md create mode 100644 tests/test_install.py diff --git a/src/qodev_gitlab_cli/app.py b/src/qodev_gitlab_cli/app.py index 09d0d11..86cd047 100644 --- a/src/qodev_gitlab_cli/app.py +++ b/src/qodev_gitlab_cli/app.py @@ -22,6 +22,7 @@ # --------------------------------------------------------------------------- # Import and register command groups # --------------------------------------------------------------------------- +from qodev_gitlab_cli.commands.install import install_app # noqa: E402 from qodev_gitlab_cli.commands.issues import issues_app # noqa: E402 from qodev_gitlab_cli.commands.jobs import jobs_app # noqa: E402 from qodev_gitlab_cli.commands.mrs import mrs_app # noqa: E402 @@ -36,6 +37,8 @@ app.command(_sub) _sub.help_epilogue = "" # prevent epilogue from propagating to sub-command help +app.command(install_app) + from qodev_gitlab_cli.help_reference import build_command_reference # noqa: E402 app.help_epilogue = build_command_reference(_sub_apps) diff --git a/src/qodev_gitlab_cli/commands/install.py b/src/qodev_gitlab_cli/commands/install.py new file mode 100644 index 0000000..fb90a85 --- /dev/null +++ b/src/qodev_gitlab_cli/commands/install.py @@ -0,0 +1,58 @@ +"""Install CLI resources (skills for AI agents).""" + +from __future__ import annotations + +import shutil +from importlib.abc import Traversable +from importlib.resources import files +from pathlib import Path +from typing import Annotated + +from cyclopts import App, Parameter + +from qodev_gitlab_cli.output import console + +install_app = App(name="install", help="Install CLI resources.") + + +def _install_skills(target_root: Path | None = None) -> Path: + """Copy bundled skill files to .claude/skills/qodev-gitlab/.""" + root = target_root or Path.cwd() + dest = root / ".claude" / "skills" / "qodev-gitlab" + + source = files("qodev_gitlab_cli") / "skills" + + if dest.exists(): + shutil.rmtree(dest) + dest.mkdir(parents=True) + + _copy_traversable(source, dest) + return dest + + +def _copy_traversable(source: Traversable, dest: Path) -> None: + """Recursively copy from a Traversable (importlib.resources) to a Path.""" + for item in source.iterdir(): + if item.name.startswith("__"): + continue + target = dest / item.name + if item.is_file(): + target.write_bytes(item.read_bytes()) + elif item.is_dir(): + target.mkdir(exist_ok=True) + _copy_traversable(item, target) + + +@install_app.default +def install( + *, + skills: Annotated[bool, Parameter(name="--skills", help="Install AI agent skill files", negative="")] = False, +) -> None: + """Install CLI resources into the current workspace.""" + if skills: + dest = _install_skills() + console.print(f"[green]Installed skills to {dest}[/green]") + else: + console.print("Usage: qodev-gitlab install --skills") + console.print("") + console.print(" --skills Copy AI agent skill files to .claude/skills/qodev-gitlab/") diff --git a/src/qodev_gitlab_cli/skills/SKILL.md b/src/qodev_gitlab_cli/skills/SKILL.md new file mode 100644 index 0000000..432706a --- /dev/null +++ b/src/qodev_gitlab_cli/skills/SKILL.md @@ -0,0 +1,144 @@ +# qodev-gitlab CLI + +Agent-friendly CLI for the GitLab API. Designed for AI coding agents with structured JSON output and predictable exit codes. + +## Setup + +```bash +pip install qodev-gitlab-cli +export GITLAB_TOKEN="glpat-..." +``` + +The CLI auto-detects the current GitLab project from the git remote. Override with `--project GROUP/NAME` or `-p GROUP/NAME`. + +## Global Options + +| Flag | Description | +|------|-------------| +| `--json` | Output as JSON (default: rich Markdown) | +| `--project`, `-p` | Project ID or path (default: auto-detected) | +| `--limit` | Results per page (default: 25) | +| `--page` | Page number (default: 1) | +| `--token` | GitLab token (overrides GITLAB_TOKEN) | +| `--url` | GitLab URL (overrides GITLAB_URL) | + +## Command Reference + +### projects + +| Command | Description | +|---------|-------------| +| `projects list [--owned]` | List projects | +| `projects get [ID]` | Get project details (default: current) | + +### mrs (Merge Requests) + +| Command | Description | +|---------|-------------| +| `mrs list [--state STATE]` | List MRs (default: opened) | +| `mrs get IID` | Get MR details | +| `mrs create --title TITLE [--source BRANCH] [--target BRANCH] [--description TEXT] [--labels L] [--squash]` | Create MR | +| `mrs update IID [--title T] [--description D] [--labels L] [--target B]` | Update MR | +| `mrs merge IID [--squash] [--when-pipeline-succeeds]` | Merge MR | +| `mrs close IID` | Close MR | +| `mrs discussions IID` | List MR discussions | +| `mrs changes IID` | Show MR diff | +| `mrs commits IID` | List MR commits | +| `mrs approvals IID` | Show approval status | +| `mrs comment IID --body TEXT` | Comment on MR | +| `mrs pipelines IID` | List MR pipelines | + +### pipelines + +| Command | Description | +|---------|-------------| +| `pipelines list [--ref BRANCH] [--limit N]` | List pipelines | +| `pipelines get ID` | Get pipeline details | +| `pipelines jobs ID` | List pipeline jobs | +| `pipelines wait ID [--timeout S] [--interval S]` | Wait for pipeline to complete | + +### jobs + +| Command | Description | +|---------|-------------| +| `jobs get ID` | Get job details | +| `jobs log ID` | Get job log output | +| `jobs retry ID` | Retry a failed job | + +### issues + +| Command | Description | +|---------|-------------| +| `issues list [--state STATE] [--labels L] [--milestone M]` | List issues | +| `issues get IID` | Get issue details | +| `issues create --title TITLE [--description D] [--labels L]` | Create issue | +| `issues update IID [--title T] [--description D] [--labels L]` | Update issue | +| `issues close IID` | Close issue | +| `issues comment IID --body TEXT` | Comment on issue | +| `issues notes IID` | List issue comments | + +### releases + +| Command | Description | +|---------|-------------| +| `releases list` | List releases | +| `releases get TAG` | Get release details | +| `releases create --tag TAG [--name N] [--description D] [--ref REF]` | Create release | + +### variables + +| Command | Description | +|---------|-------------| +| `variables list` | List CI/CD variables (values hidden) | +| `variables get KEY` | Get a CI/CD variable | +| `variables set KEY VALUE [--protected] [--masked]` | Set a CI/CD variable | + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 80 | Authentication error (bad/missing token) | +| 81 | Not found | +| 82 | API error | +| 83 | Validation error | +| 84 | Configuration error | + +## JSON Output + +All commands support `--json` for structured output. Lists return: + +```json +{"items": [...], "total": 10, "page": 1, "limit": 25} +``` + +Single resources return the raw API object. Errors return: + +```json +{"error": "message", "code": "error_code"} +``` + +## Common Patterns + +```bash +# Get current project info +qodev-gitlab projects get + +# Create MR from current branch +qodev-gitlab mrs create --title "feat: add feature" + +# Check pipeline status as JSON +qodev-gitlab --json pipelines list --limit 5 + +# Wait for pipeline then check result +qodev-gitlab pipelines wait 12345 --timeout 600 + +# Review MR discussions +qodev-gitlab mrs discussions 42 +``` + +## References + +For detailed workflow patterns, see: +- [MR Workflows](references/mr-workflows.md) — Create, review, and merge MRs +- [Pipeline Monitoring](references/pipeline-monitoring.md) — CI/CD monitoring patterns diff --git a/src/qodev_gitlab_cli/skills/__init__.py b/src/qodev_gitlab_cli/skills/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/qodev_gitlab_cli/skills/references/__init__.py b/src/qodev_gitlab_cli/skills/references/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/qodev_gitlab_cli/skills/references/mr-workflows.md b/src/qodev_gitlab_cli/skills/references/mr-workflows.md new file mode 100644 index 0000000..ff69060 --- /dev/null +++ b/src/qodev_gitlab_cli/skills/references/mr-workflows.md @@ -0,0 +1,100 @@ +# Merge Request Workflows + +## Create and Submit MR + +```bash +# Create MR from current branch targeting main +qodev-gitlab mrs create --title "feat: add user auth" + +# Create MR with full details +qodev-gitlab mrs create \ + --title "fix: resolve login timeout" \ + --source feature-branch \ + --target main \ + --description "Fixes #42. Increases timeout from 5s to 30s." \ + --labels "bug,priority::high" + +# Create MR with squash enabled +qodev-gitlab mrs create --title "refactor: clean up utils" --squash +``` + +## Review MR + +```bash +# Get MR overview +qodev-gitlab mrs get 42 + +# Check what changed +qodev-gitlab mrs changes 42 + +# Read discussions/review comments +qodev-gitlab mrs discussions 42 + +# Check approval status +qodev-gitlab mrs approvals 42 + +# Check associated pipelines +qodev-gitlab mrs pipelines 42 +``` + +## Update and Respond + +```bash +# Update MR title or description +qodev-gitlab mrs update 42 --title "feat: improved title" +qodev-gitlab mrs update 42 --description "Updated description with more context" + +# Add labels +qodev-gitlab mrs update 42 --labels "reviewed,ready-to-merge" + +# Leave a comment +qodev-gitlab mrs comment 42 --body "Addressed all review comments" +``` + +## Merge + +```bash +# Merge immediately +qodev-gitlab mrs merge 42 + +# Squash and merge +qodev-gitlab mrs merge 42 --squash + +# Merge when pipeline succeeds +qodev-gitlab mrs merge 42 --when-pipeline-succeeds +``` + +## Full Lifecycle Example + +```bash +# 1. Create MR +qodev-gitlab mrs create --title "feat: add caching layer" --labels "enhancement" + +# 2. Check pipeline status +qodev-gitlab mrs pipelines 1 + +# 3. Review feedback +qodev-gitlab mrs discussions 1 + +# 4. Address feedback and comment +qodev-gitlab mrs comment 1 --body "Fixed the race condition in cache invalidation" + +# 5. Check approvals +qodev-gitlab mrs approvals 1 + +# 6. Merge when pipeline passes +qodev-gitlab mrs merge 1 --when-pipeline-succeeds +``` + +## JSON Workflows (for Automation) + +```bash +# Get MR state for conditional logic +STATE=$(qodev-gitlab --json mrs get 42 | jq -r '.state') + +# List all open MRs as JSON +qodev-gitlab --json mrs list --state opened + +# Check if MR has conflicts +qodev-gitlab --json mrs get 42 | jq '.has_conflicts' +``` diff --git a/src/qodev_gitlab_cli/skills/references/pipeline-monitoring.md b/src/qodev_gitlab_cli/skills/references/pipeline-monitoring.md new file mode 100644 index 0000000..13fbb5f --- /dev/null +++ b/src/qodev_gitlab_cli/skills/references/pipeline-monitoring.md @@ -0,0 +1,74 @@ +# Pipeline Monitoring + +## Check Pipeline Status + +```bash +# List recent pipelines +qodev-gitlab pipelines list + +# List pipelines for a specific branch +qodev-gitlab pipelines list --ref main --limit 5 + +# Get details for a specific pipeline +qodev-gitlab pipelines get 12345 +``` + +## Inspect Pipeline Jobs + +```bash +# List all jobs in a pipeline +qodev-gitlab pipelines jobs 12345 + +# Get details for a specific job +qodev-gitlab jobs get 67890 + +# Read job logs (useful for debugging failures) +qodev-gitlab jobs log 67890 +``` + +## Wait for Pipeline Completion + +```bash +# Wait with default timeout (1 hour) +qodev-gitlab pipelines wait 12345 + +# Wait with custom timeout and check interval +qodev-gitlab pipelines wait 12345 --timeout 600 --interval 30 +``` + +## Handle Failures + +```bash +# 1. Check which jobs failed +qodev-gitlab --json pipelines jobs 12345 | jq '.items[] | select(.status == "failed")' + +# 2. Read the failed job's log +qodev-gitlab jobs log 67890 + +# 3. Retry a failed job +qodev-gitlab jobs retry 67890 +``` + +## MR Pipeline Monitoring + +```bash +# List pipelines associated with an MR +qodev-gitlab mrs pipelines 42 + +# Combine with wait: get latest pipeline ID then wait +PIPELINE_ID=$(qodev-gitlab --json mrs pipelines 42 | jq '.items[0].id') +qodev-gitlab pipelines wait "$PIPELINE_ID" +``` + +## JSON Automation Patterns + +```bash +# Get pipeline status for scripting +STATUS=$(qodev-gitlab --json pipelines get 12345 | jq -r '.status') + +# Count failed jobs +qodev-gitlab --json pipelines jobs 12345 | jq '[.items[] | select(.status == "failed")] | length' + +# Get all job names and statuses +qodev-gitlab --json pipelines jobs 12345 | jq '.items[] | {name, status}' +``` diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..0906842 --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,54 @@ +"""Tests for the install command.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from qodev_gitlab_cli.commands.install import _install_skills + + +class TestInstallSkills: + def test_copies_skill_files(self, tmp_path: Path) -> None: + _install_skills(target_root=tmp_path) + + dest = tmp_path / ".claude" / "skills" / "qodev-gitlab" + assert dest.exists() + assert (dest / "SKILL.md").is_file() + assert (dest / "references" / "mr-workflows.md").is_file() + assert (dest / "references" / "pipeline-monitoring.md").is_file() + + def test_does_not_copy_dunder_files(self, tmp_path: Path) -> None: + _install_skills(target_root=tmp_path) + + dest = tmp_path / ".claude" / "skills" / "qodev-gitlab" + for path in dest.rglob("*"): + assert not path.name.startswith("__"), f"Unexpected dunder file: {path}" + + def test_skill_md_has_content(self, tmp_path: Path) -> None: + _install_skills(target_root=tmp_path) + + skill_md = tmp_path / ".claude" / "skills" / "qodev-gitlab" / "SKILL.md" + content = skill_md.read_text() + assert "qodev-gitlab" in content + assert "Command Reference" in content + + def test_replaces_existing_directory(self, tmp_path: Path) -> None: + dest = tmp_path / ".claude" / "skills" / "qodev-gitlab" + dest.mkdir(parents=True) + stale = dest / "old-file.txt" + stale.write_text("should be removed") + + _install_skills(target_root=tmp_path) + + assert not stale.exists() + assert (dest / "SKILL.md").is_file() + + def test_no_flag_shows_guidance(self) -> None: + from qodev_gitlab_cli.commands.install import install + + with patch("qodev_gitlab_cli.commands.install.console") as mock_console: + install(skills=False) + + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("--skills" in c for c in calls) From b8b13386393faf174e2ddb26023bc5124bf33a55 Mon Sep 17 00:00:00 2001 From: Jan Scheffler Date: Tue, 24 Feb 2026 13:45:40 +0100 Subject: [PATCH 2/3] fix: address review findings for install command - No-flag usage now exits with code 83 (validation error) instead of 0 - Print "Replacing existing skills" message before rmtree - Add integration test for install(skills=True) happy path - Mention `install --skills` in SKILL.md setup section Co-Authored-By: Claude Opus 4.6 --- src/qodev_gitlab_cli/commands/install.py | 15 ++++++++------- src/qodev_gitlab_cli/skills/SKILL.md | 3 +++ tests/test_install.py | 18 ++++++++++++------ 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/qodev_gitlab_cli/commands/install.py b/src/qodev_gitlab_cli/commands/install.py index fb90a85..8844d5a 100644 --- a/src/qodev_gitlab_cli/commands/install.py +++ b/src/qodev_gitlab_cli/commands/install.py @@ -23,6 +23,7 @@ def _install_skills(target_root: Path | None = None) -> Path: source = files("qodev_gitlab_cli") / "skills" if dest.exists(): + console.print(f"Replacing existing skills at {dest}") shutil.rmtree(dest) dest.mkdir(parents=True) @@ -49,10 +50,10 @@ def install( skills: Annotated[bool, Parameter(name="--skills", help="Install AI agent skill files", negative="")] = False, ) -> None: """Install CLI resources into the current workspace.""" - if skills: - dest = _install_skills() - console.print(f"[green]Installed skills to {dest}[/green]") - else: - console.print("Usage: qodev-gitlab install --skills") - console.print("") - console.print(" --skills Copy AI agent skill files to .claude/skills/qodev-gitlab/") + if not skills: + from qodev_gitlab_cli.output import error + + error("No install target specified. Use: qodev-gitlab install --skills", code="validation", exit_code=83) + + dest = _install_skills() + console.print(f"[green]Installed skills to {dest}[/green]") diff --git a/src/qodev_gitlab_cli/skills/SKILL.md b/src/qodev_gitlab_cli/skills/SKILL.md index 432706a..b5a46b3 100644 --- a/src/qodev_gitlab_cli/skills/SKILL.md +++ b/src/qodev_gitlab_cli/skills/SKILL.md @@ -7,6 +7,9 @@ Agent-friendly CLI for the GitLab API. Designed for AI coding agents with struct ```bash pip install qodev-gitlab-cli export GITLAB_TOKEN="glpat-..." + +# Install skill files into the current workspace +qodev-gitlab install --skills ``` The CLI auto-detects the current GitLab project from the git remote. Override with `--project GROUP/NAME` or `-p GROUP/NAME`. diff --git a/tests/test_install.py b/tests/test_install.py index 0906842..be9288e 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -5,7 +5,9 @@ from pathlib import Path from unittest.mock import patch -from qodev_gitlab_cli.commands.install import _install_skills +import pytest + +from qodev_gitlab_cli.commands.install import _install_skills, install class TestInstallSkills: @@ -44,11 +46,15 @@ def test_replaces_existing_directory(self, tmp_path: Path) -> None: assert not stale.exists() assert (dest / "SKILL.md").is_file() - def test_no_flag_shows_guidance(self) -> None: - from qodev_gitlab_cli.commands.install import install - - with patch("qodev_gitlab_cli.commands.install.console") as mock_console: + def test_no_flag_exits_with_validation_error(self) -> None: + with pytest.raises(SystemExit, match="83"): install(skills=False) + def test_skills_flag_prints_success(self, tmp_path: Path) -> None: + with patch("qodev_gitlab_cli.commands.install.console") as mock_console, \ + patch("qodev_gitlab_cli.commands.install.Path") as mock_path: + mock_path.cwd.return_value = tmp_path + install(skills=True) + calls = [str(c) for c in mock_console.print.call_args_list] - assert any("--skills" in c for c in calls) + assert any("Installed skills" in c for c in calls) From eb0e661725f75290c88bcb073b0e23cde3f1b43a Mon Sep 17 00:00:00 2001 From: Jan Scheffler Date: Tue, 24 Feb 2026 14:06:48 +0100 Subject: [PATCH 3/3] style: fix ruff formatting in test_install.py Co-Authored-By: Claude Opus 4.6 --- tests/test_install.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_install.py b/tests/test_install.py index be9288e..35c9f88 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -51,8 +51,10 @@ def test_no_flag_exits_with_validation_error(self) -> None: install(skills=False) def test_skills_flag_prints_success(self, tmp_path: Path) -> None: - with patch("qodev_gitlab_cli.commands.install.console") as mock_console, \ - patch("qodev_gitlab_cli.commands.install.Path") as mock_path: + with ( + patch("qodev_gitlab_cli.commands.install.console") as mock_console, + patch("qodev_gitlab_cli.commands.install.Path") as mock_path, + ): mock_path.cwd.return_value = tmp_path install(skills=True)