diff --git a/.github/workflows/cli-skill-review-reusable.yml b/.github/workflows/cli-skill-review-reusable.yml index 6d1125c..b2758ea 100644 --- a/.github/workflows/cli-skill-review-reusable.yml +++ b/.github/workflows/cli-skill-review-reusable.yml @@ -41,6 +41,21 @@ on: required: false type: boolean default: true + cli_paths_include: + description: "Optional include path filters (comma or newline separated globs)" + required: false + type: string + default: "" + cli_paths_exclude: + description: "Optional exclude path filters (comma or newline separated globs)" + required: false + type: string + default: "" + enforce_cli_metadata: + description: "Require knowledge_base/CLI.md metadata when no include filters are provided" + required: false + type: boolean + default: false secrets: llm_token: description: "LLM provider token used by Pi" @@ -61,25 +76,55 @@ jobs: cancel-in-progress: true steps: - - name: Checkout + - name: Checkout repository under review uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Resolve workflow source repository and ref + id: workflow_source + env: + WORKFLOW_REF: ${{ github.workflow_ref }} + run: | + set -euo pipefail + repo_with_path="${WORKFLOW_REF%@*}" + source_ref="${WORKFLOW_REF##*@}" + source_repo="${repo_with_path%/.github/workflows/*}" + + if [ -z "${source_repo}" ] || [ -z "${source_ref}" ]; then + echo "Unable to parse workflow source from WORKFLOW_REF=${WORKFLOW_REF}" + exit 1 + fi + + echo "repo=${source_repo}" >> "$GITHUB_OUTPUT" + echo "ref=${source_ref}" >> "$GITHUB_OUTPUT" + + - name: Checkout workflow source repository + uses: actions/checkout@v4 + with: + repository: ${{ steps.workflow_source.outputs.repo }} + ref: ${{ steps.workflow_source.outputs.ref }} + path: .cli-skill-infra + token: ${{ secrets.gh_token }} + + - name: Verify internal cli-skill assets + run: | + set -euo pipefail + test -f .cli-skill-infra/scripts/check-cli-scope.py + test -f .cli-skill-infra/scripts/build-pr-report.py + test -d .cli-skill-infra/cli-skill + - name: Check CLI metadata and changed paths id: cli_scope env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CLI_PATHS_INCLUDE: ${{ inputs.cli_paths_include }} + CLI_PATHS_EXCLUDE: ${{ inputs.cli_paths_exclude }} + ENFORCE_CLI_METADATA: ${{ inputs.enforce_cli_metadata }} run: | set -euo pipefail - python3 scripts/check-cli-scope.py - - - name: Setup Node - if: ${{ steps.cli_scope.outputs.proceed == 'true' }} - uses: actions/setup-node@v6 - with: - node-version: 24 + python3 .cli-skill-infra/scripts/check-cli-scope.py - name: Configure custom LLM provider if: ${{ steps.cli_scope.outputs.proceed == 'true' }} @@ -111,7 +156,7 @@ jobs: if: ${{ steps.cli_scope.outputs.proceed == 'true' }} run: | mkdir -p ~/.pi/skills - cp -R cli-skill ~/.pi/skills/ + cp -R .cli-skill-infra/cli-skill ~/.pi/skills/ - name: Run Pi agent if: ${{ steps.cli_scope.outputs.proceed == 'true' }} @@ -130,7 +175,7 @@ jobs: Scope rules: - If command is /cli-review, perform only CLI standard compliance checks. - - Use cli-skill/references/cli-standard.md as the sole review standard. + - Use .cli-skill-infra/cli-skill/references/cli-standard.md as the sole review standard. Repository target path: ${{ inputs.target_path }} @@ -163,7 +208,7 @@ jobs: COMMAND: ${{ inputs.command }} run: | set -euo pipefail - python3 scripts/build-pr-report.py + python3 .cli-skill-infra/scripts/build-pr-report.py - name: Fail when agent run is unsuccessful if: ${{ steps.cli_scope.outputs.proceed == 'true' && inputs.fail_on_agent_error && steps.pi.outputs.success != 'true' }} diff --git a/README.md b/README.md index 67ec86e..c7e1406 100644 --- a/README.md +++ b/README.md @@ -86,3 +86,85 @@ Optional inputs: - /cli-review is standards compliance only and evaluates against [cli-skill/references/cli-standard.md](cli-skill/references/cli-standard.md). - Session exports are uploaded as workflow artifacts. + +## Use from Another Repository (One-File Setup) + +Another repository can consume the reusable workflow by adding a single workflow file. No helper scripts, skill files, or metadata files are required in the consumer repository. + +### 1. Add a workflow in the consumer repository + +Create a workflow file in the consumer repository (for example, .github/workflows/cli-review.yml): + +```yaml +name: CLI Skill Review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: write + +jobs: + cli-review: + uses: shaftoe/cli-skill/.github/workflows/cli-skill-review-reusable.yml@v1 + with: + command: /cli-review + provider: openrouter + model: moonshotai/kimi-k2.6 + thinking_level: medium + pr_number: ${{ github.event.pull_request.number }} + post_pr_comment: true + fail_on_agent_error: true + + # Optional input-based path filters (comma or newline separated globs) + cli_paths_include: | + cmd/** + internal/cli/** + scripts/demo_cli.py + cli_paths_exclude: | + docs/archive/** + **/*.md + + # Optional strict mode. When true, knowledge_base/CLI.md is required + # if cli_paths_include is not provided. + enforce_cli_metadata: false + secrets: + llm_token: ${{ secrets.OPENROUTER_API_KEY }} + gh_token: ${{ secrets.GITHUB_TOKEN }} +``` + +### 2. Configure secrets in the consumer repository + +- OPENROUTER_API_KEY + +The workflow uses the repository-provided GITHUB_TOKEN for PR APIs. + +### 3. Inputs contract + +Required inputs: + +- provider + +Optional inputs: + +- command (default: /cli-review) +- target_path (default: .) +- model (default: moonshotai/kimi-k2.6) +- thinking_level (default: medium) +- pr_number +- post_pr_comment (default: true) +- fail_on_agent_error (default: true) +- cli_paths_include (default: empty) +- cli_paths_exclude (default: empty) +- enforce_cli_metadata (default: false) + +Required secrets: + +- llm_token +- gh_token + +### 4. Version pinning + +Use a tagged major version (for example, @v1) for a stable interface. Use a commit SHA pin for maximum reproducibility. diff --git a/cli-skill/commands/cli-check-help.md b/cli-skill/commands/cli-check-help.md index 4defea8..6f49dbd 100644 --- a/cli-skill/commands/cli-check-help.md +++ b/cli-skill/commands/cli-check-help.md @@ -4,10 +4,10 @@ Analyze the CLI help system only, using canonical help rules. ## Execution Order -1. Run `shared/cli-discovery-preflight.md` -2. Read `cli-skill/references/cli-help.md` in full -3. Read `cli-skill/references/cli-help-examples.md` in full -4. Use parse targets relevant to help generation and command registration +1. Check for the existence of `cli-review/0-cli-discovery-preflight/`. If it does not exist, run `shared/cli-discovery-preflight.md` +2. Use preflight outputs from `cli-review/0-cli-discovery-preflight/` +3. Read `cli-skill/references/cli-help.md` in full +4. Read `cli-skill/references/cli-help-examples.md` in full 5. Evaluate help against `cli-help.md` only ## Scope diff --git a/scripts/check-cli-scope.py b/scripts/check-cli-scope.py index 2bf89f3..9531cf3 100644 --- a/scripts/check-cli-scope.py +++ b/scripts/check-cli-scope.py @@ -21,25 +21,45 @@ def fail(message: str) -> None: sys.exit(1) -def find_cli_doc(root: pathlib.Path) -> pathlib.Path: +def write_proceed(output_path: pathlib.Path, proceed: bool) -> None: + with output_path.open("a", encoding="utf-8") as file_handle: + file_handle.write(f"proceed={'true' if proceed else 'false'}\n") + + +def parse_pattern_list(value: str) -> list[str]: + if not value: + return [] + + parts = [] + for line in value.splitlines(): + for item in line.split(","): + stripped = item.strip() + if stripped: + parts.append(stripped) + return parts + + +def find_cli_doc(root: pathlib.Path, enforce_metadata: bool) -> pathlib.Path | None: kb_dir = root / "knowledge_base" if not kb_dir.exists(): + if not enforce_metadata: + return None fail( "Missing knowledge_base/CLI.md. This workflow only runs for repositories that declare CLI scope metadata. " - "Add knowledge_base/CLI.md. For repositories with a CLI, include a YAML entry like " - "'paths: [cmd/, internal/cli/, docs/cli/]' listing all repo paths that affect CLI behavior, help, or command UX. " - "If the repository has no CLI, declare 'has_cli: false' or include a clear sentence such as 'This repository has no CLI.'" + "Either add knowledge_base/CLI.md (with 'paths: [...]' for CLI repositories or 'has_cli: false' for non-CLI repositories) " + "or provide path filters via CLI_PATHS_INCLUDE." ) cli_candidates = sorted( path for path in kb_dir.iterdir() if path.is_file() and path.name.lower() == "cli.md" ) if not cli_candidates: + if not enforce_metadata: + return None fail( "Missing knowledge_base/CLI.md. This workflow only runs for repositories that declare CLI scope metadata. " - "Add knowledge_base/CLI.md. For repositories with a CLI, include a YAML entry like " - "'paths: [cmd/, internal/cli/, docs/cli/]' listing all repo paths that affect CLI behavior, help, or command UX. " - "If the repository has no CLI, declare 'has_cli: false' or include a clear sentence such as 'This repository has no CLI.'" + "Either add knowledge_base/CLI.md (with 'paths: [...]' for CLI repositories or 'has_cli: false' for non-CLI repositories) " + "or provide path filters via CLI_PATHS_INCLUDE." ) return cli_candidates[0] @@ -87,6 +107,15 @@ def matches(spec: str, changed_file: str) -> bool: return changed_file == spec or changed_file.startswith(spec + "/") +def select_matching( + changed: list[str], include_specs: list[str], exclude_specs: list[str] +) -> list[str]: + selected = [item for item in changed if any(matches(spec, item) for spec in include_specs)] + if not exclude_specs: + return selected + return [item for item in selected if not any(matches(spec, item) for spec in exclude_specs)] + + def changed_files() -> list[str]: base_sha = os.environ.get("BASE_SHA", "").strip() head_sha = os.environ.get("HEAD_SHA", "").strip() @@ -118,39 +147,71 @@ def main() -> None: fail("GITHUB_OUTPUT is not set.") output_path = pathlib.Path(output_env) - cli_doc = find_cli_doc(root) + base_sha = os.environ.get("BASE_SHA", "").strip() + head_sha = os.environ.get("HEAD_SHA", "").strip() + if not base_sha or not head_sha: + write_proceed(output_path, True) + print("Success: no pull request diff context; proceeding by default") + return + + include_specs = parse_pattern_list(os.environ.get("CLI_PATHS_INCLUDE", "")) + exclude_specs = parse_pattern_list(os.environ.get("CLI_PATHS_EXCLUDE", "")) + enforce_metadata = os.environ.get("ENFORCE_CLI_METADATA", "false").lower() == "true" + changed = changed_files() + + if include_specs: + matched = select_matching(changed, include_specs, exclude_specs) + write_proceed(output_path, bool(matched)) + if matched: + print("CLI-relevant changes detected via input path filters:") + for item in matched: + print(f"- {item}") + else: + print("Success: no CLI changes matched input path filters") + return + + cli_doc = find_cli_doc(root, enforce_metadata=enforce_metadata) + if cli_doc is None: + write_proceed(output_path, True) + print( + "Success: no CLI metadata found; proceeding by default. " + "Set ENFORCE_CLI_METADATA=true to require knowledge_base/CLI.md." + ) + return + text = cli_doc.read_text(encoding="utf-8") cli_present = has_cli(text) - with output_path.open("a", encoding="utf-8") as file_handle: - if cli_present is False: - file_handle.write("proceed=false\n") - print("Success: no CLI found") - return + if cli_present is False: + write_proceed(output_path, False) + print("Success: no CLI found") + return paths = parse_paths(text) if not paths: + if not enforce_metadata: + write_proceed(output_path, True) + print( + "Success: CLI metadata does not declare paths; proceeding by default. " + "Set ENFORCE_CLI_METADATA=true to require a YAML 'paths' entry." + ) + return fail( f"{cli_doc} does not declare a YAML 'paths' entry. For CLI repositories, add a line like " "'paths: [cmd/, internal/cli/, docs/cli/]' so the workflow can determine whether a pull request changes CLI-relevant files. " "If the repository has no CLI, declare 'has_cli: false' or include a clear sentence such as 'This repository has no CLI.'" ) - changed = changed_files() - matched = [item for item in changed if any(matches(spec, item) for spec in paths)] + matched = select_matching(changed, paths, exclude_specs) - with output_path.open("a", encoding="utf-8") as file_handle: - if matched: - file_handle.write("proceed=true\n") - else: - file_handle.write("proceed=false\n") + write_proceed(output_path, bool(matched)) if matched: print("CLI-relevant changes detected:") for item in matched: print(f"- {item}") else: - print("success: no CLI changes") + print("Success: no CLI changes") if __name__ == "__main__":