From 4419239eded13a82bba7318b0a1547de30cfd8f6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 5 Jul 2026 20:56:57 +0000 Subject: [PATCH 01/22] =?UTF-8?q?chore:=20runtime=20plugin=20split=20relea?= =?UTF-8?q?se=20PR=20=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From f3351d2d99505b98192d045940eade60224801f1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 5 Jul 2026 20:57:16 +0000 Subject: [PATCH 02/22] =?UTF-8?q?chore:=20runtime-split-shared-builder=20D?= =?UTF-8?q?raft=20PR=20=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 80d008296e241f9a0da42d2d16c18b7c9cec7e1b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 5 Jul 2026 21:03:01 +0000 Subject: [PATCH 03/22] =?UTF-8?q?feat:=20ndf-shared=E3=81=A8runtime=20buil?= =?UTF-8?q?d=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/ndf-shared/README.md | 27 + .../ndf-shared/manifests/claude-skills.txt | 28 + plugins/ndf-shared/manifests/codex-skills.txt | 30 + plugins/ndf-shared/manifests/kiro-skills.txt | 28 + .../ndf-shared/scripts/codex-slack-notify.js | 330 ++++ .../ndf-shared/scripts/ensure-retention.sh | 66 + .../scripts/install-official-skills.sh | 183 ++ plugins/ndf-shared/scripts/slack-notify.js | 777 +++++++++ .../ndf-shared/scripts/statusline-switch.sh | 214 +++ plugins/ndf-shared/scripts/statusline.sh | 60 + .../skills/branch-fix-strategy/SKILL.md | 87 + .../ndf-shared/skills/browser-test/SKILL.md | 159 ++ .../ndf-shared/skills/cherry-pick-pr/SKILL.md | 120 ++ plugins/ndf-shared/skills/clean/SKILL.md | 20 + plugins/ndf-shared/skills/codex/SKILL.md | 473 ++++++ .../ndf-shared/skills/cross-review/SKILL.md | 475 ++++++ .../cross-review/docs/01-state-and-review.md | 254 +++ .../cross-review/docs/02-fix-and-rotation.md | 384 +++++ .../skills/cross-review/scripts/_tmpdir.sh | 23 + .../cross-review/scripts/launch-codex.sh | 126 ++ .../cross-review/scripts/launch-gemini.sh | 193 +++ .../skills/cross-review/scripts/monitor.py | 806 +++++++++ .../skills/cross-review/scripts/rotate-pr.sh | 362 ++++ .../skills/cross-review/scripts/state.py | 1482 +++++++++++++++++ .../cross-review/scripts/wait-review.sh | 30 + .../skills/cross-review}/tests/__init__.py | 0 .../skills/cross-review/tests/conftest.py | 47 + .../tests/test_default_worktree_base.py | 41 + .../tests/test_monitor_early_error.py | 126 ++ .../tests/test_monitor_import_safety.py | 105 ++ .../tests/test_monitor_progress_log.py | 23 + .../tests/test_monitor_result_age.py | 330 ++++ .../tests/test_monitor_stall_default.py | 119 ++ .../cross-review/tests/test_monitor_zombie.py | 91 + .../tests/test_state_auto_review_templates.py | 204 +++ .../tests/test_state_check_oscillation.py | 101 ++ .../test_state_extra_review_instructions.py | 35 + .../tests/test_state_merge_fix.py | 776 +++++++++ .../tests/test_state_read_result.py | 160 ++ .../skills/data-analyst-export/01-formats.md | 0 .../skills/data-analyst-export/02-examples.md | 0 .../skills/data-analyst-export/SKILL.md | 0 .../data-analyst-export/scripts/export-csv.js | 0 .../01-patterns.md | 0 .../02-examples.md | 0 .../data-analyst-sql-optimization/SKILL.md | 0 .../skills/deepwiki-transfer/SKILL.md | 0 .../deepwiki-transfer/scripts/fetch_wiki.py | 0 .../deepwiki-transfer/scripts/pyproject.toml | 0 .../deepwiki-transfer/scripts/split_pages.py | 0 .../deepwiki-transfer/scripts/validate_gfm.py | 0 plugins/ndf-shared/skills/deploy/SKILL.md | 114 ++ .../01-environment-detection.md | 84 + .../docker-container-access/02-dood-access.md | 134 ++ .../03-troubleshooting.md | 63 + .../skills/docker-container-access/SKILL.md | 76 + plugins/ndf-shared/skills/fix/SKILL.md | 303 ++++ .../skills/fix/scripts/fetch-pr-comments.sh | 47 + .../skills/gemini/SKILL.md | 0 .../git-gh-operations/01-common-errors.md | 145 ++ .../skills/git-gh-operations/SKILL.md | 228 +++ .../skills/google-auth/SKILL.md | 0 .../skills/google-auth/pyproject.toml | 0 .../skills/google-auth/scripts/google_auth.py | 0 .../skills/google-auth/uv.lock | 0 .../skills/google-chat/SKILL.md | 0 .../skills/google-chat/pyproject.toml | 0 .../skills/google-chat/scripts/gchat_read.py | 0 .../skills/google-chat/uv.lock | 0 .../skills/google-drive/SKILL.md | 0 .../skills/google-drive/pyproject.toml | 0 .../google-drive/scripts/gdrive_fetch.py | 0 .../skills/google-drive/uv.lock | 0 .../skills/implementation-plan/SKILL.md | 98 ++ .../skills/investigation-rules/SKILL.md | 105 ++ .../skills/issue-plan-strategy/SKILL.md | 335 ++++ .../skills/knowledge-reorg/SKILL.md | 0 .../skills/logging-guidelines/SKILL.md | 112 ++ .../markdown-writing/01-diagram-guide.md | 144 ++ .../skills/markdown-writing/SKILL.md | 58 + .../skills/mcp-builder/LICENSE.txt | 0 .../skills/mcp-builder/SKILL.md | 0 .../mcp-builder/reference/evaluation.md | 0 .../reference/mcp_best_practices.md | 0 .../mcp-builder/reference/node_mcp_server.md | 0 .../reference/python_mcp_server.md | 0 .../skills/mcp-builder/scripts/connections.py | 0 .../skills/mcp-builder/scripts/evaluation.py | 0 .../scripts/example_evaluation.xml | 0 .../mcp-builder/scripts/requirements.txt | 0 plugins/ndf-shared/skills/merged/SKILL.md | 29 + .../skills/ml-model-structure/SKILL.md | 0 .../references/inference-and-contract.md | 0 .../ndf-shared/skills/ndf-policies/SKILL.md | 10 + .../official-skills-autoloader/SKILL.md | 0 .../ndf-shared/skills/plan-to-spec/SKILL.md | 182 ++ .../playwright-browser-connect/SKILL.md | 0 .../scripts/start-host-chrome.sh | 0 .../skills/playwright-evidence-drive/SKILL.md | 0 .../skills/playwright-execution/SKILL.md | 0 .../skills/playwright-kit-ops/.gitignore | 0 .../skills/playwright-kit-ops/SKILL.md | 0 .../playwright_kit/__init__.py | 0 .../playwright_kit/accessibility.py | 0 .../playwright_kit/body_check.py | 0 .../playwright_kit/config.py | 0 .../playwright_kit/fixtures/__init__.py | 0 .../playwright_kit/fixtures/accessibility.py | 0 .../playwright_kit/fixtures/auth.py | 0 .../playwright_kit/fixtures/body_check.py | 0 .../playwright_kit/fixtures/evidence.py | 0 .../playwright_kit/fixtures/web_vitals.py | 0 .../playwright_kit/overlay.py | 0 .../playwright_kit/pytest_plugin.py | 0 .../playwright_kit/pytest_report.py | 0 .../playwright_kit/uploaders/__init__.py | 0 .../playwright_kit/video.py | 0 .../playwright_kit/web_vitals.py | 0 .../skills/playwright-kit-ops/pyproject.toml | 0 .../playwright-kit-ops/scripts/_drive_auth.py | 0 .../scripts/build_gdoc_with_drive_links.py | 0 .../playwright-kit-ops/scripts/check_cwv.py | 0 .../scripts/classify_page_role.py | 0 .../scripts/gdrive_upload_dir.py | 0 .../scripts/init_project.bat | 0 .../scripts/init_project.sh | 0 .../scripts/record_scenario.py | 0 .../scripts/run_a11y_scan.py | 0 .../scripts/upload_evidence.py | 0 .../scripts/upload_md_as_gdoc.py | 0 .../templates/conftest.py.template | 0 .../templates/pyproject.toml.runtime | 0 .../playwright-kit-ops/templates/run.bat | 0 .../playwright-kit-ops/templates/run.sh | 0 .../templates/runtime-README.md | 0 .../templates/runtime-gitignore | 0 .../templates/scenario.config.yaml | 0 .../templates/test_auth.py.template | 0 .../templates/test_dashboard.py.template | 0 .../templates/test_form.py.template | 0 .../templates/test_list.py.template | 0 .../playwright-kit-ops/tests/__init__.py | 0 .../playwright-kit-ops/tests/conftest.py | 0 .../tests/test_a11y_cwv_routing.py | 0 .../tests/test_auth_cache.py | 0 .../tests/test_body_check.py | 0 .../tests/test_config_basic_auth.py | 0 .../tests/test_evidence_fixture.py | 0 .../tests/test_makereport_user_properties.py | 0 .../tests/test_pytest_plugin_bootstrap.py | 0 .../tests/test_pytest_report.py | 0 .../tests/test_pytest_terminal_summary.py | 0 .../tests/test_pytester_integration.py | 0 .../tests/test_same_origin.py | 0 .../tests/test_upload_evidence.py | 0 .../tests/test_video_default.py | 0 .../skills/playwright-kit-ops/uv.lock | 0 .../skills/playwright-report/SKILL.md | 0 .../skills/playwright-scenario-test/SKILL.md | 0 .../playwright-script-creation/SKILL.md | 0 .../skills/playwright-test-planning/SKILL.md | 0 .../docs/01-methodology.md | 0 .../docs/02-page-roles.md | 0 .../docs/03-test-techniques.md | 0 .../docs/04-playwright-mapping.md | 0 .../docs/05-bug-report.md | 0 .../docs/06-pytest-playwright.md | 0 .../playwright-test-planning/docs/README.md | 0 .../docs/checklists/checklist-auth.md | 0 .../checklists/checklist-cart-checkout.md | 0 .../docs/checklists/checklist-common.md | 0 .../docs/checklists/checklist-dashboard.md | 0 .../docs/checklists/checklist-edit.md | 0 .../docs/checklists/checklist-form.md | 0 .../docs/checklists/checklist-item.md | 0 .../docs/checklists/checklist-list.md | 0 .../docs/checklists/checklist-lp.md | 0 .../docs/checklists/checklist-modal-wizard.md | 0 .../docs/checklists/checklist-search.md | 0 plugins/ndf-shared/skills/pr-tests/SKILL.md | 31 + plugins/ndf-shared/skills/pr/SKILL.md | 161 ++ .../skills/problem-solving/SKILL.md | 162 ++ .../skills/python-execution/01-uv-setup.md | 85 + .../python-execution/02-troubleshooting.md | 112 ++ .../skills/python-execution/SKILL.md | 86 + .../qa-security-scan/01-owasp-checklist.md | 0 .../qa-security-scan/02-auth-checklist.md | 0 .../qa-security-scan/03-report-template.md | 0 .../skills/qa-security-scan/SKILL.md | 0 .../skills/resolve-pr-comments/SKILL.md | 146 ++ .../ndf-shared/skills/review-branch/SKILL.md | 129 ++ .../skills/review-pr-comments/SKILL.md | 110 ++ plugins/ndf-shared/skills/review/SKILL.md | 337 ++++ .../skills/skill-stats/SKILL.md | 0 .../skills/skill-stats/scripts/skill-stats.py | 0 plugins/ndf-shared/skills/statusline/SKILL.md | 51 + .../skills/statusline/tests/__init__.py | 0 .../tests/test_statusline_switch.py | 150 ++ plugins/ndf-shared/skills/sync-main/SKILL.md | 48 + scripts/build-runtime-plugins.sh | 164 ++ 200 files changed, 12934 insertions(+) create mode 100644 plugins/ndf-shared/README.md create mode 100644 plugins/ndf-shared/manifests/claude-skills.txt create mode 100644 plugins/ndf-shared/manifests/codex-skills.txt create mode 100644 plugins/ndf-shared/manifests/kiro-skills.txt create mode 100755 plugins/ndf-shared/scripts/codex-slack-notify.js create mode 100755 plugins/ndf-shared/scripts/ensure-retention.sh create mode 100755 plugins/ndf-shared/scripts/install-official-skills.sh create mode 100755 plugins/ndf-shared/scripts/slack-notify.js create mode 100755 plugins/ndf-shared/scripts/statusline-switch.sh create mode 100755 plugins/ndf-shared/scripts/statusline.sh create mode 100644 plugins/ndf-shared/skills/branch-fix-strategy/SKILL.md create mode 100644 plugins/ndf-shared/skills/browser-test/SKILL.md create mode 100644 plugins/ndf-shared/skills/cherry-pick-pr/SKILL.md create mode 100644 plugins/ndf-shared/skills/clean/SKILL.md create mode 100644 plugins/ndf-shared/skills/codex/SKILL.md create mode 100644 plugins/ndf-shared/skills/cross-review/SKILL.md create mode 100644 plugins/ndf-shared/skills/cross-review/docs/01-state-and-review.md create mode 100644 plugins/ndf-shared/skills/cross-review/docs/02-fix-and-rotation.md create mode 100755 plugins/ndf-shared/skills/cross-review/scripts/_tmpdir.sh create mode 100755 plugins/ndf-shared/skills/cross-review/scripts/launch-codex.sh create mode 100755 plugins/ndf-shared/skills/cross-review/scripts/launch-gemini.sh create mode 100755 plugins/ndf-shared/skills/cross-review/scripts/monitor.py create mode 100755 plugins/ndf-shared/skills/cross-review/scripts/rotate-pr.sh create mode 100755 plugins/ndf-shared/skills/cross-review/scripts/state.py create mode 100755 plugins/ndf-shared/skills/cross-review/scripts/wait-review.sh rename plugins/{ndf/skills/playwright-kit-ops => ndf-shared/skills/cross-review}/tests/__init__.py (100%) create mode 100644 plugins/ndf-shared/skills/cross-review/tests/conftest.py create mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_default_worktree_base.py create mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_monitor_early_error.py create mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_monitor_import_safety.py create mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_monitor_progress_log.py create mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_monitor_result_age.py create mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_monitor_stall_default.py create mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_monitor_zombie.py create mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_state_auto_review_templates.py create mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_state_check_oscillation.py create mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_state_extra_review_instructions.py create mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_state_merge_fix.py create mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_state_read_result.py rename plugins/{ndf => ndf-shared}/skills/data-analyst-export/01-formats.md (100%) rename plugins/{ndf => ndf-shared}/skills/data-analyst-export/02-examples.md (100%) rename plugins/{ndf => ndf-shared}/skills/data-analyst-export/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/data-analyst-export/scripts/export-csv.js (100%) rename plugins/{ndf => ndf-shared}/skills/data-analyst-sql-optimization/01-patterns.md (100%) rename plugins/{ndf => ndf-shared}/skills/data-analyst-sql-optimization/02-examples.md (100%) rename plugins/{ndf => ndf-shared}/skills/data-analyst-sql-optimization/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/deepwiki-transfer/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/deepwiki-transfer/scripts/fetch_wiki.py (100%) rename plugins/{ndf => ndf-shared}/skills/deepwiki-transfer/scripts/pyproject.toml (100%) rename plugins/{ndf => ndf-shared}/skills/deepwiki-transfer/scripts/split_pages.py (100%) rename plugins/{ndf => ndf-shared}/skills/deepwiki-transfer/scripts/validate_gfm.py (100%) create mode 100644 plugins/ndf-shared/skills/deploy/SKILL.md create mode 100644 plugins/ndf-shared/skills/docker-container-access/01-environment-detection.md create mode 100644 plugins/ndf-shared/skills/docker-container-access/02-dood-access.md create mode 100644 plugins/ndf-shared/skills/docker-container-access/03-troubleshooting.md create mode 100644 plugins/ndf-shared/skills/docker-container-access/SKILL.md create mode 100644 plugins/ndf-shared/skills/fix/SKILL.md create mode 100755 plugins/ndf-shared/skills/fix/scripts/fetch-pr-comments.sh rename plugins/{ndf => ndf-shared}/skills/gemini/SKILL.md (100%) create mode 100644 plugins/ndf-shared/skills/git-gh-operations/01-common-errors.md create mode 100644 plugins/ndf-shared/skills/git-gh-operations/SKILL.md rename plugins/{ndf => ndf-shared}/skills/google-auth/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/google-auth/pyproject.toml (100%) rename plugins/{ndf => ndf-shared}/skills/google-auth/scripts/google_auth.py (100%) rename plugins/{ndf => ndf-shared}/skills/google-auth/uv.lock (100%) rename plugins/{ndf => ndf-shared}/skills/google-chat/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/google-chat/pyproject.toml (100%) rename plugins/{ndf => ndf-shared}/skills/google-chat/scripts/gchat_read.py (100%) rename plugins/{ndf => ndf-shared}/skills/google-chat/uv.lock (100%) rename plugins/{ndf => ndf-shared}/skills/google-drive/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/google-drive/pyproject.toml (100%) rename plugins/{ndf => ndf-shared}/skills/google-drive/scripts/gdrive_fetch.py (100%) rename plugins/{ndf => ndf-shared}/skills/google-drive/uv.lock (100%) create mode 100644 plugins/ndf-shared/skills/implementation-plan/SKILL.md create mode 100644 plugins/ndf-shared/skills/investigation-rules/SKILL.md create mode 100644 plugins/ndf-shared/skills/issue-plan-strategy/SKILL.md rename plugins/{ndf => ndf-shared}/skills/knowledge-reorg/SKILL.md (100%) create mode 100644 plugins/ndf-shared/skills/logging-guidelines/SKILL.md create mode 100644 plugins/ndf-shared/skills/markdown-writing/01-diagram-guide.md create mode 100644 plugins/ndf-shared/skills/markdown-writing/SKILL.md rename plugins/{ndf => ndf-shared}/skills/mcp-builder/LICENSE.txt (100%) rename plugins/{ndf => ndf-shared}/skills/mcp-builder/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/mcp-builder/reference/evaluation.md (100%) rename plugins/{ndf => ndf-shared}/skills/mcp-builder/reference/mcp_best_practices.md (100%) rename plugins/{ndf => ndf-shared}/skills/mcp-builder/reference/node_mcp_server.md (100%) rename plugins/{ndf => ndf-shared}/skills/mcp-builder/reference/python_mcp_server.md (100%) rename plugins/{ndf => ndf-shared}/skills/mcp-builder/scripts/connections.py (100%) rename plugins/{ndf => ndf-shared}/skills/mcp-builder/scripts/evaluation.py (100%) rename plugins/{ndf => ndf-shared}/skills/mcp-builder/scripts/example_evaluation.xml (100%) rename plugins/{ndf => ndf-shared}/skills/mcp-builder/scripts/requirements.txt (100%) create mode 100644 plugins/ndf-shared/skills/merged/SKILL.md rename plugins/{ndf => ndf-shared}/skills/ml-model-structure/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/ml-model-structure/references/inference-and-contract.md (100%) create mode 100644 plugins/ndf-shared/skills/ndf-policies/SKILL.md rename plugins/{ndf => ndf-shared}/skills/official-skills-autoloader/SKILL.md (100%) create mode 100644 plugins/ndf-shared/skills/plan-to-spec/SKILL.md rename plugins/{ndf => ndf-shared}/skills/playwright-browser-connect/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-browser-connect/scripts/start-host-chrome.sh (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-evidence-drive/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-execution/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/.gitignore (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/__init__.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/accessibility.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/body_check.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/config.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/overlay.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/pytest_report.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/video.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/playwright_kit/web_vitals.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/pyproject.toml (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/scripts/_drive_auth.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/scripts/check_cwv.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/scripts/classify_page_role.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/scripts/init_project.bat (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/scripts/init_project.sh (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/scripts/record_scenario.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/scripts/run_a11y_scan.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/scripts/upload_evidence.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/templates/conftest.py.template (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/templates/pyproject.toml.runtime (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/templates/run.bat (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/templates/run.sh (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/templates/runtime-README.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/templates/runtime-gitignore (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/templates/scenario.config.yaml (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/templates/test_auth.py.template (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/templates/test_dashboard.py.template (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/templates/test_form.py.template (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/templates/test_list.py.template (100%) create mode 100644 plugins/ndf-shared/skills/playwright-kit-ops/tests/__init__.py rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/tests/conftest.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/tests/test_auth_cache.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/tests/test_body_check.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/tests/test_config_basic_auth.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/tests/test_evidence_fixture.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/tests/test_makereport_user_properties.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/tests/test_pytest_report.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/tests/test_pytester_integration.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/tests/test_same_origin.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/tests/test_upload_evidence.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/tests/test_video_default.py (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-kit-ops/uv.lock (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-report/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-scenario-test/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-script-creation/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/01-methodology.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/02-page-roles.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/03-test-techniques.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/04-playwright-mapping.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/05-bug-report.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/06-pytest-playwright.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/README.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/checklists/checklist-auth.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/checklists/checklist-cart-checkout.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/checklists/checklist-common.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/checklists/checklist-dashboard.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/checklists/checklist-edit.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/checklists/checklist-form.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/checklists/checklist-item.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/checklists/checklist-list.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/checklists/checklist-lp.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/checklists/checklist-modal-wizard.md (100%) rename plugins/{ndf => ndf-shared}/skills/playwright-test-planning/docs/checklists/checklist-search.md (100%) create mode 100644 plugins/ndf-shared/skills/pr-tests/SKILL.md create mode 100644 plugins/ndf-shared/skills/pr/SKILL.md create mode 100644 plugins/ndf-shared/skills/problem-solving/SKILL.md create mode 100644 plugins/ndf-shared/skills/python-execution/01-uv-setup.md create mode 100644 plugins/ndf-shared/skills/python-execution/02-troubleshooting.md create mode 100644 plugins/ndf-shared/skills/python-execution/SKILL.md rename plugins/{ndf => ndf-shared}/skills/qa-security-scan/01-owasp-checklist.md (100%) rename plugins/{ndf => ndf-shared}/skills/qa-security-scan/02-auth-checklist.md (100%) rename plugins/{ndf => ndf-shared}/skills/qa-security-scan/03-report-template.md (100%) rename plugins/{ndf => ndf-shared}/skills/qa-security-scan/SKILL.md (100%) create mode 100644 plugins/ndf-shared/skills/resolve-pr-comments/SKILL.md create mode 100644 plugins/ndf-shared/skills/review-branch/SKILL.md create mode 100644 plugins/ndf-shared/skills/review-pr-comments/SKILL.md create mode 100644 plugins/ndf-shared/skills/review/SKILL.md rename plugins/{ndf => ndf-shared}/skills/skill-stats/SKILL.md (100%) rename plugins/{ndf => ndf-shared}/skills/skill-stats/scripts/skill-stats.py (100%) create mode 100644 plugins/ndf-shared/skills/statusline/SKILL.md create mode 100644 plugins/ndf-shared/skills/statusline/tests/__init__.py create mode 100644 plugins/ndf-shared/skills/statusline/tests/test_statusline_switch.py create mode 100644 plugins/ndf-shared/skills/sync-main/SKILL.md create mode 100755 scripts/build-runtime-plugins.sh diff --git a/plugins/ndf-shared/README.md b/plugins/ndf-shared/README.md new file mode 100644 index 0000000..3401d84 --- /dev/null +++ b/plugins/ndf-shared/README.md @@ -0,0 +1,27 @@ +# NDF Shared Source + +`plugins/ndf-shared` is the editable source for NDF runtime plugins. + +Runtime plugin directories are generated artifacts. Edit shared skills and +scripts here first, then run: + +```bash +bash scripts/build-runtime-plugins.sh +``` + +Check generated files without modifying the working tree: + +```bash +bash scripts/build-runtime-plugins.sh --check +``` + +## Layout + +- `skills/` - shared Skill implementations. +- `scripts/` - shared helper scripts copied into runtime plugins. +- `manifests/claude-skills.txt` - Claude Code published Skill set. +- `manifests/codex-skills.txt` - Codex published Skill set. +- `manifests/kiro-skills.txt` - Kiro published Skill set. + +Generated runtime files must be committed with shared source changes so users +can install plugins without running the build script. diff --git a/plugins/ndf-shared/manifests/claude-skills.txt b/plugins/ndf-shared/manifests/claude-skills.txt new file mode 100644 index 0000000..b58dc6b --- /dev/null +++ b/plugins/ndf-shared/manifests/claude-skills.txt @@ -0,0 +1,28 @@ +pr +pr-tests +fix +review +cross-review +merged +clean +ndf-policies +markdown-writing +python-execution +docker-container-access +git-gh-operations +branch-fix-strategy +implementation-plan +investigation-rules +problem-solving +logging-guidelines +sync-main +cherry-pick-pr +deploy +review-branch +review-pr-comments +resolve-pr-comments +browser-test +codex +statusline +issue-plan-strategy +plan-to-spec diff --git a/plugins/ndf-shared/manifests/codex-skills.txt b/plugins/ndf-shared/manifests/codex-skills.txt new file mode 100644 index 0000000..403632f --- /dev/null +++ b/plugins/ndf-shared/manifests/codex-skills.txt @@ -0,0 +1,30 @@ +branch-fix-strategy +cherry-pick-pr +clean +cross-review +deploy +docker-container-access +fix +git-gh-operations +implementation-plan +investigation-rules +issue-plan-strategy +logging-guidelines +markdown-writing +merged +ndf-policies +plan-to-spec +playwright-execution +playwright-kit-ops +playwright-report +playwright-script-creation +playwright-test-planning +pr +pr-tests +problem-solving +python-execution +resolve-pr-comments +review +review-branch +review-pr-comments +sync-main diff --git a/plugins/ndf-shared/manifests/kiro-skills.txt b/plugins/ndf-shared/manifests/kiro-skills.txt new file mode 100644 index 0000000..b58dc6b --- /dev/null +++ b/plugins/ndf-shared/manifests/kiro-skills.txt @@ -0,0 +1,28 @@ +pr +pr-tests +fix +review +cross-review +merged +clean +ndf-policies +markdown-writing +python-execution +docker-container-access +git-gh-operations +branch-fix-strategy +implementation-plan +investigation-rules +problem-solving +logging-guidelines +sync-main +cherry-pick-pr +deploy +review-branch +review-pr-comments +resolve-pr-comments +browser-test +codex +statusline +issue-plan-strategy +plan-to-spec diff --git a/plugins/ndf-shared/scripts/codex-slack-notify.js b/plugins/ndf-shared/scripts/codex-slack-notify.js new file mode 100755 index 0000000..af01f50 --- /dev/null +++ b/plugins/ndf-shared/scripts/codex-slack-notify.js @@ -0,0 +1,330 @@ +#!/usr/bin/env node +/** + * Slack notification script for Codex Stop hooks. + * + * Opt-in only: set NDF_CODEX_SLACK_NOTIFY=true plus the same Slack variables + * used by the Claude Code hook: SLACK_BOT_TOKEN, SLACK_CHANNEL_ID, and + * optional SLACK_USER_MENTION. The script avoids model calls and summarizes + * from Codex's local session JSONL when available. + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const { spawnSync } = require('child_process'); +const os = require('os'); +const crypto = require('crypto'); + +const CONFIG = { + MAX_SESSION_FILES: 80, + MAX_LINES: 240, + MAX_TEXT: 180, + LOCK_TIMEOUT_MS: 30000, + COOLDOWN_MS: 5000, + DELETE_DELAY_MS: 500, + FALLBACK_SUMMARY: 'Codexの作業が完了しました', + LOG_DIR: path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'log'), +}; + +const RUN_ID = crypto.randomBytes(4).toString('hex'); + +const isEnabled = () => /^(1|true|yes|on)$/i.test(process.env.NDF_CODEX_SLACK_NOTIFY || ''); +const isDebug = () => /^(1|true|yes|on)$/i.test(process.env.DEBUG_CODEX_SLACK_NOTIFY || ''); + +function log(message, ...args) { + if (!isDebug()) return; + const line = `[${new Date().toISOString()}] [codex-slack:${RUN_ID}] ${message} ${args.map(String).join(' ')}\n`; + process.stderr.write(line); + try { + fs.mkdirSync(CONFIG.LOG_DIR, { recursive: true }); + fs.appendFileSync(path.join(CONFIG.LOG_DIR, 'ndf-codex-slack-notify.log'), line); + } catch (_) { + // Debug logging must never break hook execution. + } +} + +function safeJsonParse(text) { + try { + return JSON.parse(text); + } catch (_) { + return null; + } +} + +async function readStdinJson() { + if (process.stdin.isTTY) return {}; + let input = ''; + for await (const chunk of process.stdin) input += chunk; + log('stdin bytes:', input.length); + return safeJsonParse(input) || {}; +} + +function loadEnvFile() { + let current = process.cwd(); + while (current && current !== path.dirname(current)) { + const envFile = path.join(current, '.env'); + if (fs.existsSync(envFile)) { + for (const rawLine of fs.readFileSync(envFile, 'utf8').split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const match = line.match(/^([^=]+)=(.*)$/); + if (!match) continue; + const key = match[1].trim(); + let value = match[2].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + process.env[key] ??= value; + } + return; + } + if (fs.existsSync(path.join(current, '.git'))) return; + current = path.dirname(current); + } +} + +function lockPath() { + const id = `${process.cwd()}:${process.env.CODEX_SESSION_ID || ''}`; + const hash = crypto.createHash('sha256').update(id).digest('hex').slice(0, 12); + return path.join(os.tmpdir(), `ndf-codex-slack-${hash}.lock`); +} + +function acquireLock() { + const file = lockPath(); + const now = Date.now(); + const existing = fs.existsSync(file) ? safeJsonParse(fs.readFileSync(file, 'utf8')) : null; + if (existing?.completedAt && now - existing.completedAt < CONFIG.COOLDOWN_MS) return false; + if (existing?.timestamp && !existing.completedAt && now - existing.timestamp < CONFIG.LOCK_TIMEOUT_MS) return false; + fs.writeFileSync(file, JSON.stringify({ pid: process.pid, timestamp: now, completedAt: null })); + return true; +} + +function releaseLock() { + const file = lockPath(); + const data = fs.existsSync(file) ? safeJsonParse(fs.readFileSync(file, 'utf8')) || {} : {}; + data.completedAt = Date.now(); + fs.writeFileSync(file, JSON.stringify(data)); +} + +function gitValue(args, fallback = '') { + const result = spawnSync('git', args, { cwd: process.cwd(), encoding: 'utf8' }); + return result.status === 0 ? result.stdout.trim() : fallback; +} + +function repoInfo() { + const root = gitValue(['rev-parse', '--show-toplevel'], process.cwd()); + const branch = gitValue(['branch', '--show-current'], ''); + return { + name: path.basename(root || process.cwd()), + root, + branch, + }; +} + +function codexHome() { + return process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); +} + +function findSessionFiles() { + const base = path.join(codexHome(), 'sessions'); + if (!fs.existsSync(base)) return []; + const files = []; + const stack = [base]; + while (stack.length) { + const dir = stack.pop(); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) stack.push(p); + else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push({ path: p, mtimeMs: fs.statSync(p).mtimeMs }); + } + } + } + return files.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, CONFIG.MAX_SESSION_FILES); +} + +function textFromContent(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.map((item) => item?.text || item?.content || '').filter(Boolean).join('\n'); + } + if (content && typeof content === 'object') return content.text || content.message || ''; + return ''; +} + +function textFromItem(item) { + const payload = item?.payload || {}; + if (payload.type === 'message') { + return textFromContent(payload.content); + } + if (payload.type === 'agent_message') return payload.message || ''; + if (item.type === 'event_msg' && payload.message) return payload.message; + return ''; +} + +function summarizeText(text) { + const oneLine = String(text || '') + .replace(/```[\s\S]*?```/g, '') + .replace(/\s+/g, ' ') + .trim(); + if (!oneLine) return null; + return oneLine.length > CONFIG.MAX_TEXT ? `${oneLine.slice(0, CONFIG.MAX_TEXT - 1)}…` : oneLine; +} + +function readSessionSummary(hookInput) { + const explicitPath = hookInput.transcript_path || hookInput.session_path || hookInput.session_file; + const candidates = explicitPath ? [{ path: explicitPath }] : findSessionFiles(); + const cwd = hookInput.cwd || process.cwd(); + const sessionId = hookInput.session_id || hookInput.thread_id || process.env.CODEX_SESSION_ID || ''; + const lastAssistantSummary = summarizeText(textFromContent(hookInput.last_assistant_message)); + + for (const candidate of candidates) { + if (!candidate.path || !fs.existsSync(candidate.path)) continue; + const lines = fs.readFileSync(candidate.path, 'utf8').trim().split('\n').slice(-CONFIG.MAX_LINES); + const parsed = lines.map(safeJsonParse).filter(Boolean); + const meta = parsed.find((item) => item.type === 'session_meta')?.payload || {}; + if (!explicitPath && meta.cwd && meta.cwd !== cwd) continue; + if (!explicitPath && sessionId && meta.session_id && meta.session_id !== sessionId) continue; + + const final = [...parsed].reverse().find((item) => { + const payload = item.payload || {}; + return payload.phase === 'final_answer' || payload.type === 'agent_message'; + }); + const user = [...parsed].reverse().find((item) => { + const payload = item.payload || {}; + return payload.type === 'message' && payload.role === 'user'; + }); + const tokenEvent = [...parsed].reverse().find((item) => item.type === 'event_msg' && item.payload?.type === 'token_count'); + + return { + sessionId: meta.session_id || sessionId, + summary: lastAssistantSummary || summarizeText(textFromItem(final)) || summarizeText(textFromItem(user)), + model: meta.model || meta.model_slug || '', + tokenInfo: tokenEvent?.payload?.info || null, + file: candidate.path, + }; + } + + return { sessionId, summary: lastAssistantSummary, model: '', tokenInfo: null, file: null }; +} + +function formatTokenInfo(info) { + const usage = info?.total_token_usage || info?.last_token_usage; + const window = info?.model_context_window; + if (!usage?.total_tokens || !window) return ''; + const pct = Math.round((usage.total_tokens / window) * 100); + return `tokens: ${usage.total_tokens}/${window} (${pct}%)`; +} + +function formatMessage(repo, session, includeMention = false) { + const mention = includeMention && process.env.SLACK_USER_MENTION ? `${process.env.SLACK_USER_MENTION} ` : ''; + const parts = [ + `${mention}[${repo.name}] Codex: ${session.summary || CONFIG.FALLBACK_SUMMARY}`, + repo.branch ? `branch: ${repo.branch}` : '', + session.model ? `model: ${session.model}` : '', + formatTokenInfo(session.tokenInfo), + session.sessionId ? `session: ${session.sessionId}` : '', + `cwd: ${process.cwd()}`, + ].filter(Boolean); + return parts.join('\n'); +} + +function slackApiRequest(apiPath, data) { + const token = process.env.SLACK_BOT_TOKEN; + if (!token) return Promise.resolve(null); + + return new Promise((resolve) => { + const body = JSON.stringify(data); + const req = https.request({ + hostname: 'slack.com', + port: 443, + path: apiPath, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }, + }, (res) => { + let response = ''; + res.on('data', (chunk) => { response += chunk; }); + res.on('end', () => { + const json = safeJsonParse(response); + log('slack api:', apiPath, 'ok:', json?.ok, 'error:', json?.error || ''); + resolve(json?.ok === true ? json : null); + }); + }); + req.on('error', (error) => { + log('slack error:', error.message); + resolve(null); + }); + req.write(body); + req.end(); + }); +} + +function postSlack(text) { + const channel = process.env.SLACK_CHANNEL_ID; + if (!channel) return Promise.resolve(null); + return slackApiRequest('/api/chat.postMessage', { channel, text }); +} + +function deleteSlack(ts) { + const channel = process.env.SLACK_CHANNEL_ID; + if (!channel || !ts) return Promise.resolve(null); + return slackApiRequest('/api/chat.delete', { channel, ts }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function main() { + loadEnvFile(); + if (!isEnabled()) { + log('disabled'); + return; + } + if (!process.env.SLACK_BOT_TOKEN || !process.env.SLACK_CHANNEL_ID) { + log('missing slack env'); + return; + } + if (!acquireLock()) { + log('lock rejected'); + return; + } + try { + const hookInput = await readStdinJson(); + const repo = repoInfo(); + const session = readSessionSummary(hookInput); + if (!process.env.SLACK_USER_MENTION) { + await postSlack(formatMessage(repo, session, false)); + return; + } + + const mentionResult = await postSlack(formatMessage(repo, session, true)); + if (!mentionResult) { + log('failed to send mention message'); + return; + } + + await sleep(CONFIG.DELETE_DELAY_MS); + + const cleanResult = await postSlack(formatMessage(repo, session, false)); + if (!cleanResult) { + log('failed to send clean message'); + return; + } + + const deleteResult = await deleteSlack(mentionResult.ts); + if (!deleteResult) log('failed to delete mention message'); + } finally { + releaseLock(); + } +} + +main().catch((error) => { + log('fatal:', error.stack || error.message); + releaseLock(); + process.exit(0); +}); diff --git a/plugins/ndf-shared/scripts/ensure-retention.sh b/plugins/ndf-shared/scripts/ensure-retention.sh new file mode 100755 index 0000000..e511363 --- /dev/null +++ b/plugins/ndf-shared/scripts/ensure-retention.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# NDF plugin: ~/.claude/settings.json の cleanupPeriodDays を最低 90 日に保つ。 +# - 既存値 >= 90 なら何もしない +# - 既存値 < 90 or 未設定 なら 90 に更新 +# - 前回チェックから 7 日経っていなければスキップ (多重実行防止) +# - 複数セッション同時起動時の race condition を flock で回避 (flock 不在時は atomic rename のみ) +set -euo pipefail + +MIN_DAYS=90 +GUARD_DAYS=7 +SETTINGS="$HOME/.claude/settings.json" +FLAG="$HOME/.claude/.ndf-retention-checked" +LOCK="$HOME/.claude/.ndf-retention.lock" + +# jq 必須 +if ! command -v jq >/dev/null 2>&1; then + exit 0 +fi + +# 7 日以内に実行済みならスキップ +if [ -f "$FLAG" ]; then + if find "$FLAG" -mtime "-${GUARD_DAYS}" -print -quit 2>/dev/null | grep -q .; then + exit 0 + fi +fi + +# settings.json が存在しない場合は新規作成 +mkdir -p "$(dirname "$SETTINGS")" +if [ ! -f "$SETTINGS" ]; then + echo "{}" > "$SETTINGS" +fi + +# 更新処理をひとつの関数にまとめ、ロック内で呼び出す +update_retention() { + # ロック取得後に再チェック (並行セッションが先に更新済みの可能性) + local current tmp + current=$(jq -r '.cleanupPeriodDays // 0' "$SETTINGS" 2>/dev/null || echo 0) + case "$current" in + ''|*[!0-9]*) current=0 ;; + esac + if [ "$current" -ge "$MIN_DAYS" ]; then + return 0 + fi + tmp="$(mktemp)" + if jq --argjson v "$MIN_DAYS" '.cleanupPeriodDays = $v' "$SETTINGS" > "$tmp"; then + mv "$tmp" "$SETTINGS" + echo "[ndf] cleanupPeriodDays を ${current} → ${MIN_DAYS} に更新しました (~/.claude/settings.json)" + else + rm -f "$tmp" + return 1 + fi +} + +# ロック取得: flock があればアトミック更新、無ければ atomic rename のみに依存 +if command -v flock >/dev/null 2>&1; then + ( + # 最大 5 秒待機。取得できなければ他プロセスが更新中なので何もしない + flock -x -w 5 200 || exit 0 + update_retention || true + ) 200>"$LOCK" +else + # flock 不在環境 (macOS 標準など): mv 自体は POSIX で atomic なので大きな破損は起きない + update_retention || true +fi + +touch "$FLAG" diff --git a/plugins/ndf-shared/scripts/install-official-skills.sh b/plugins/ndf-shared/scripts/install-official-skills.sh new file mode 100755 index 0000000..bba6467 --- /dev/null +++ b/plugins/ndf-shared/scripts/install-official-skills.sh @@ -0,0 +1,183 @@ +#!/bin/bash +# Anthropic公式Skillsのインストーラ +# +# Usage: +# bash install-official-skills.sh [SKILL_NAMES...] +# bash install-official-skills.sh --all +# bash install-official-skills.sh --list +# bash install-official-skills.sh --scope project [SKILL_NAMES...] +# bash install-official-skills.sh --update +# +# Examples: +# bash install-official-skills.sh docx pptx xlsx # 指定Skillをインストール (~/.claude/skills/) +# bash install-official-skills.sh --scope project pdf # プロジェクト .claude/skills/ に配置 +# bash install-official-skills.sh --all # 全Skillインストール +# bash install-official-skills.sh --update # 公式リポジトリを最新化 + +set -euo pipefail + +REPO_URL="https://github.com/anthropics/skills.git" +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/anthropic-skills" +USER_SKILLS_DIR="$HOME/.claude/skills" +PROJECT_SKILLS_DIR=".claude/skills" + +SCOPE="user" +DO_ALL=false +DO_LIST=false +DO_UPDATE=false +SKILLS=() + +# 引数パース +while [[ $# -gt 0 ]]; do + case "$1" in + --scope) + SCOPE="$2" + shift 2 + ;; + --all) + DO_ALL=true + shift + ;; + --list) + DO_LIST=true + shift + ;; + --update) + DO_UPDATE=true + shift + ;; + --help|-h) + sed -n '2,13p' "$0" | sed 's/^# \?//' + exit 0 + ;; + --*) + echo "ERROR: unknown option $1" >&2 + exit 1 + ;; + *) + SKILLS+=("$1") + shift + ;; + esac +done + +# --- クローン or 更新 --- +if [ ! -d "$CACHE_DIR/.git" ]; then + echo "==> 公式Skillsリポジトリを取得中: $CACHE_DIR" + mkdir -p "$(dirname "$CACHE_DIR")" + git clone --depth 1 "$REPO_URL" "$CACHE_DIR" +elif [ "$DO_UPDATE" = true ]; then + echo "==> 公式Skillsを最新化中" + git -C "$CACHE_DIR" pull --ff-only +fi + +# --- --list: 利用可能Skill一覧を表示 --- +if [ "$DO_LIST" = true ]; then + echo "" + echo "=== 利用可能なSkill ===" + for dir in "$CACHE_DIR"/skills/*/; do + name=$(basename "$dir") + license_file="$dir/LICENSE.txt" + if [ -f "$license_file" ]; then + if grep -q "Apache License" "$license_file"; then + license="Apache-2.0" + elif grep -q "Proprietary\|All rights reserved" "$license_file"; then + license="Proprietary" + else + license="?" + fi + else + license="-" + fi + printf " %-25s [%s]\n" "$name" "$license" + done + echo "" + echo "Proprietary Skillは各自の環境にインストール可能ですが、再配布は禁止です。" + exit 0 +fi + +# --- インストール先を決定 --- +case "$SCOPE" in + user) + DEST="$USER_SKILLS_DIR" + ;; + project) + if [ ! -d .git ] && [ ! -f .claude-plugin/plugin.json ] && [ ! -f package.json ] && [ ! -f pyproject.toml ]; then + echo "WARN: プロジェクトルートらしきファイルが見当たりません (.git, package.json 等)。カレントディレクトリ: $(pwd)" + fi + DEST="$PROJECT_SKILLS_DIR" + ;; + *) + echo "ERROR: --scope は user / project のいずれか" >&2 + exit 1 + ;; +esac + +mkdir -p "$DEST" +echo "==> インストール先: $DEST ($SCOPE scope)" + +# --- 対象Skill決定 --- +if [ "$DO_ALL" = true ]; then + SKILLS=() + for dir in "$CACHE_DIR"/skills/*/; do + SKILLS+=("$(basename "$dir")") + done +fi + +if [ "${#SKILLS[@]}" -eq 0 ]; then + echo "ERROR: インストールするSkillが指定されていません。" >&2 + echo "利用可能な一覧は: bash $0 --list" >&2 + exit 1 +fi + +# --- シンボリックリンク作成 --- +INSTALLED=() +SKIPPED=() +NOT_FOUND=() +for skill in "${SKILLS[@]}"; do + src="$CACHE_DIR/skills/$skill" + dst="$DEST/$skill" + + if [ ! -d "$src" ]; then + NOT_FOUND+=("$skill") + continue + fi + + if [ -e "$dst" ] || [ -L "$dst" ]; then + if [ -L "$dst" ] && [ "$(readlink "$dst")" = "$src" ]; then + SKIPPED+=("$skill (既にリンク済み)") + continue + fi + echo "WARN: $dst が既に存在します。上書きしますか? [y/N]" + read -r ans + if [ "$ans" != "y" ] && [ "$ans" != "Y" ]; then + SKIPPED+=("$skill (ユーザーキャンセル)") + continue + fi + rm -rf "$dst" + fi + + ln -s "$src" "$dst" + INSTALLED+=("$skill") +done + +# --- 結果報告 --- +echo "" +echo "=== インストール結果 ===" +if [ "${#INSTALLED[@]}" -gt 0 ]; then + echo "インストール済み (${#INSTALLED[@]}個):" + for s in "${INSTALLED[@]}"; do echo " ✓ $s"; done +fi +if [ "${#SKIPPED[@]}" -gt 0 ]; then + echo "スキップ (${#SKIPPED[@]}個):" + for s in "${SKIPPED[@]}"; do echo " - $s"; done +fi +if [ "${#NOT_FOUND[@]}" -gt 0 ]; then + echo "見つからない (${#NOT_FOUND[@]}個):" + for s in "${NOT_FOUND[@]}"; do echo " ✗ $s"; done + echo "" + echo "一覧確認: bash $0 --list" +fi + +echo "" +echo "Claude Codeを再起動するか /plugin reload で反映してください。" diff --git a/plugins/ndf-shared/scripts/slack-notify.js b/plugins/ndf-shared/scripts/slack-notify.js new file mode 100755 index 0000000..38b9991 --- /dev/null +++ b/plugins/ndf-shared/scripts/slack-notify.js @@ -0,0 +1,777 @@ +#!/usr/bin/env node +/** + * Slack notification script for Claude Code session completion + * + * Reads transcript_path from stdin (hook input JSON) and generates + * a session-specific summary using Claude CLI. + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const { spawn } = require('child_process'); +const os = require('os'); +const crypto = require('crypto'); + +// ============================================================================ +// Constants +// ============================================================================ + +// Generate unique run ID for this execution to trace logs +const RUN_ID = crypto.randomBytes(4).toString('hex'); + +const CONFIG = { + CLI_TIMEOUT_MS: 60000, + MAX_RESPONSES: 5, + MAX_TRANSCRIPT_LINES: 100, + MAX_FILE_SIZE_BYTES: 10 * 1024 * 1024, // 10MB + MIN_TEXT_LENGTH: 5, + MIN_RESPONSE_LENGTH: 10, + MAX_CONTENT_LENGTH: 500, + MAX_CONTEXT_LENGTH: 2500, + FALLBACK_REPO_NAME: 'unknown', + NO_SUMMARY_MESSAGE: 'Claude Codeのセッションが終了しました(要約なし)', + LOCK_TIMEOUT_MS: 30000, // Lock expires after 30 seconds + NOTIFICATION_COOLDOWN_MS: 5000, // Prevent duplicate notifications within 5 seconds + DELETE_DELAY_MS: 500, // Wait before deleting mention message + LOG_DIR: path.join(os.homedir(), '.claude', 'logs') +}; + +const META_CONTENT_PREFIXES = ['Caveat:', '']; + +// ============================================================================ +// Utilities +// ============================================================================ + +/** + * Get log file path for today's date + * Format: ~/.claude/logs/slack-notify-YYYY-MM-DD.log + */ +function getLogFilePath() { + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + return path.join(CONFIG.LOG_DIR, `slack-notify-${today}.log`); +} + +/** + * Ensure log directory exists + */ +function ensureLogDirectory() { + if (!fs.existsSync(CONFIG.LOG_DIR)) { + fs.mkdirSync(CONFIG.LOG_DIR, { recursive: true }); + } +} + +const isDebugMode = () => process.env.DEBUG_SLACK_NOTIFY === 'true'; + +const debugLog = (message, ...args) => { + if (isDebugMode()) { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] [RUN:${RUN_ID}] [PID:${process.pid}] ${message}`; + + // Format additional arguments + const formattedArgs = args.map(arg => { + if (typeof arg === 'object') { + return JSON.stringify(arg); + } + return String(arg); + }).join(' '); + + const fullMessage = formattedArgs ? `${logMessage} ${formattedArgs}\n` : `${logMessage}\n`; + + // Write to stderr for backward compatibility + process.stderr.write(fullMessage); + + // Write to log file + try { + ensureLogDirectory(); + fs.appendFileSync(getLogFilePath(), fullMessage, 'utf8'); + } catch (error) { + // If file write fails, at least stderr output is available + process.stderr.write(`[ERROR] Failed to write to log file: ${error.message}\n`); + } + } +}; + +const safeJsonParse = (str) => { + try { + return JSON.parse(str); + } catch (e) { + debugLog('JSON parse error:', e.message, 'Input:', str?.substring(0, 100)); + return null; + } +}; + +const readFileLines = (filePath) => { + if (!fs.existsSync(filePath)) { + debugLog('File does not exist:', filePath); + return null; + } + try { + const stats = fs.statSync(filePath); + debugLog('Transcript file stats:', { + path: filePath, + size: stats.size, + mtime: stats.mtime.toISOString(), + mtimeMs: stats.mtimeMs + }); + if (stats.size > CONFIG.MAX_FILE_SIZE_BYTES) { + debugLog('Transcript file too large:', stats.size, 'bytes'); + return null; + } + const content = fs.readFileSync(filePath, 'utf8').trim(); + const lines = content.split('\n'); + debugLog('Read', lines.length, 'lines from transcript | File size:', stats.size); + return lines; + } catch (error) { + debugLog('Error reading transcript file:', error.message); + return null; + } +}; + +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +// ============================================================================ +// Lock Management (prevent duplicate execution) +// ============================================================================ + +function getLockFilePath() { + // Use working directory hash instead of session ID for robust deduplication + // This prevents multiple Claude Code instances in the same project from sending duplicate notifications + const cwd = process.cwd(); + const hash = crypto.createHash('md5').update(cwd).digest('hex').substring(0, 8); + return path.join(os.tmpdir(), `slack-notify-${hash}.lock`); +} + +function acquireLock() { + const lockFile = getLockFilePath(); + const now = Date.now(); + + debugLog('Attempting to acquire lock:', lockFile); + + try { + // Check if lock exists + if (fs.existsSync(lockFile)) { + const lockData = safeJsonParse(fs.readFileSync(lockFile, 'utf8')); + debugLog('Existing lock found:', lockData); + if (lockData) { + // Check if notification was sent recently (cooldown period) + if (lockData.completedAt && (now - lockData.completedAt) < CONFIG.NOTIFICATION_COOLDOWN_MS) { + const elapsed = now - lockData.completedAt; + debugLog('LOCK REJECTED: Notification sent recently | Elapsed:', elapsed, 'ms | Cooldown:', CONFIG.NOTIFICATION_COOLDOWN_MS, 'ms | Previous PID:', lockData.pid); + return false; + } + // Check if another process is still holding the lock + if (!lockData.completedAt && (now - lockData.timestamp) < CONFIG.LOCK_TIMEOUT_MS) { + const elapsed = now - lockData.timestamp; + debugLog('LOCK REJECTED: Lock held by another process | PID:', lockData.pid, '| Elapsed:', elapsed, 'ms | Timeout:', CONFIG.LOCK_TIMEOUT_MS, 'ms'); + return false; + } + } + debugLog('Lock expired or cooldown ended, proceeding'); + } else { + debugLog('No existing lock file found'); + } + + // Create lock + const lockData = { + pid: process.pid, + runId: RUN_ID, + timestamp: now, + completedAt: null + }; + fs.writeFileSync(lockFile, JSON.stringify(lockData)); + debugLog('LOCK ACQUIRED:', lockData); + return true; + } catch (error) { + debugLog('Lock acquisition error:', error.message); + return false; + } +} + +function releaseLock() { + const lockFile = getLockFilePath(); + try { + if (fs.existsSync(lockFile)) { + // Update lock with completion time instead of deleting + // This enables cooldown period to prevent duplicate notifications + const lockData = safeJsonParse(fs.readFileSync(lockFile, 'utf8')) || {}; + lockData.completedAt = Date.now(); + lockData.releasedByRunId = RUN_ID; + fs.writeFileSync(lockFile, JSON.stringify(lockData)); + debugLog('LOCK RELEASED:', lockData); + } else { + debugLog('Lock file not found during release'); + } + } catch (error) { + debugLog('Lock release error:', error.message); + } +} + +// ============================================================================ +// Environment Management +// ============================================================================ + +function loadEnvFile() { + let currentDir = __dirname; + + while (currentDir !== path.dirname(currentDir)) { + currentDir = path.dirname(currentDir); + const envFile = path.join(currentDir, '.env'); + + if (fs.existsSync(envFile)) { + debugLog('Loading env file:', envFile); + parseEnvFile(envFile); + break; + } + if (fs.existsSync(path.join(currentDir, '.git'))) break; + } +} + +function parseEnvFile(envFile) { + const content = fs.readFileSync(envFile, 'utf8'); + + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + const match = line.match(/^([^=]+)=(.*)$/); + if (!match) continue; + + const key = match[1].trim(); + let value = match[2].trim(); + + // Remove surrounding quotes + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + process.env[key] ??= value; + } +} + +// ============================================================================ +// Transcript Processing +// ============================================================================ + +function isMetaContent(content) { + return typeof content === 'string' && + META_CONTENT_PREFIXES.some(prefix => content.startsWith(prefix)); +} + +function extractTextFromContent(content) { + if (typeof content === 'string') { + return isMetaContent(content) ? null : content.substring(0, CONFIG.MAX_CONTENT_LENGTH); + } + + if (Array.isArray(content)) { + const textItem = content.find(item => + item?.type === 'text' && typeof item.text === 'string' + ); + return textItem?.text?.substring(0, CONFIG.MAX_CONTENT_LENGTH) ?? null; + } + + return null; +} + +function parseTranscriptData(transcriptPath) { + debugLog('Parsing transcript:', transcriptPath); + const allLines = readFileLines(transcriptPath); + if (!allLines) { + debugLog('No lines read from transcript'); + return { firstRequest: null, assistantResponses: [] }; + } + + // Process only recent lines to avoid memory issues with large files + const lines = allLines.slice(-CONFIG.MAX_TRANSCRIPT_LINES); + debugLog('Processing', lines.length, 'recent lines'); + + let firstRequest = null; + const assistantResponses = []; + let parsedCount = 0; + let userCount = 0; + let assistantCount = 0; + + for (const line of lines) { + const data = safeJsonParse(line); + if (!data?.message?.role) continue; + parsedCount++; + + const { role, content } = data.message; + const text = extractTextFromContent(content); + + // Get first valid user request (within recent lines) + if (!firstRequest && + role === 'user' && + data.type === 'user' && + !data.isMeta && + text?.length >= CONFIG.MIN_TEXT_LENGTH) { + firstRequest = text; + userCount++; + debugLog('Found user request:', text.substring(0, 50) + '...'); + } + + // Collect assistant responses + if (role === 'assistant' && text?.length >= CONFIG.MIN_RESPONSE_LENGTH) { + assistantResponses.push(text); + assistantCount++; + } + } + + debugLog('Parsed messages:', parsedCount, '| User:', userCount, '| Assistant:', assistantCount); + + return { + firstRequest, + assistantResponses: assistantResponses.slice(-CONFIG.MAX_RESPONSES) + }; +} + +function formatContextForSummary({ firstRequest, assistantResponses }) { + if (!firstRequest && assistantResponses.length === 0) { + debugLog('No content for summary context'); + return null; + } + + const parts = []; + if (firstRequest) { + parts.push(`【ユーザーのリクエスト】\n${firstRequest}`); + } + if (assistantResponses.length > 0) { + parts.push(`【アシスタントの応答】\n${assistantResponses.join('\n\n')}`); + } + + const context = parts.join('\n\n'); + debugLog('Context length:', context.length); + return context.length >= CONFIG.MIN_RESPONSE_LENGTH ? context : null; +} + +function createSummarizationPrompt(context) { + return `以下の情報から、**このセッションで実施した作業**を日本語で40文字以内で要約してください。 + +## 重要 +- ユーザーのリクエストとアシスタントの応答から、このセッション固有の作業内容を特定してください +- 「調査」「確認」などの一般的な表現より、具体的な作業内容(例:「Slack通知の要約生成を修正」)を優先してください + +## 出力形式 +日本語の要約文のみ(1行、40文字以内) + +## 必須事項 +- 必ず日本語で出力すること +- このセッションで実際に行った具体的な作業を要約すること +- 技術的な作業内容を含めること(ファイル名、機能名など) + +## 禁止事項 +- 英語での出力 +- 「ユーザーさん」「調査結果」などの前置き +- 「要約:」「作業内容:」などのラベル +- 「〜を確認しました」「〜を調査しました」などの曖昧な表現(具体的な結果を書く) +- 挨拶文や説明文 +- 複数行の出力 + +## 良い例 +- Slack通知の要約生成ロジックを修正 +- BigQueryクエリのパフォーマンスを最適化 +- JWT認証のセキュリティレビューを実施 +- PRマージ後のブランチクリーンアップを実行 + +## 悪い例 +- Added new MCP server(英語は禁止) +- コードを調査しました(曖昧すぎる) +- ユーザーさん、調査結果をまとめます: +- 要約:〜を実施しました + +## セッション情報 +${context.substring(0, CONFIG.MAX_CONTEXT_LENGTH)} + +## 日本語の要約(40文字以内):`; +} + +// ============================================================================ +// Claude CLI Integration (with MCP tools disabled) +// ============================================================================ + +// Auth priority: OAuth > Bedrock > API Key (cost optimization) +const AUTH_CHECKS = [ + { + name: 'oauth', + detect: () => { + try { + const creds = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', '.credentials.json'), 'utf8')); + const oauth = creds?.claudeAiOauth; + return oauth?.accessToken && oauth?.expiresAt > Date.now(); + } catch (e) { + debugLog('OAuth detection failed:', e.message); + return false; + } + }, + removeKeys: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_USE_BEDROCK'], + }, + { + name: 'bedrock', + detect: () => !!process.env.CLAUDE_CODE_USE_BEDROCK, + removeKeys: ['ANTHROPIC_API_KEY'], + }, + { + name: 'apikey', + detect: () => process.env.ANTHROPIC_API_KEY?.startsWith('sk-ant-'), + removeKeys: ['CLAUDE_CODE_USE_BEDROCK'], + }, +]; + +const SESSION_VARS = ['CLAUDECODE', 'CLAUDE_CODE_SSE_PORT', 'CLAUDE_CODE_ENTRYPOINT']; + +function buildCleanEnv(skip = []) { + const env = { ...process.env }; + for (const key of SESSION_VARS) delete env[key]; + + const auth = AUTH_CHECKS.find(a => !skip.includes(a.name) && a.detect()) || { name: 'none', removeKeys: [] }; + for (const key of auth.removeKeys) delete env[key]; + if (auth.name === 'none' && env.ANTHROPIC_API_KEY && !env.ANTHROPIC_API_KEY.startsWith('sk-ant-')) { + delete env.ANTHROPIC_API_KEY; + } + debugLog(`Auth: ${auth.name} | removed: [${[...SESSION_VARS, ...auth.removeKeys].join(', ')}]`); + return { env, authName: auth.name }; +} + +function spawnClaudeCLI(prompt, env) { + return new Promise((resolve) => { + const args = [ + '--print', '--no-session-persistence', + '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', + '--tools', '', '--model', 'haiku', '-p', prompt + ]; + + const claude = spawn('claude', args, { stdio: ['ignore', 'pipe', 'pipe'], env }); + if (claude.pid) debugLog('Claude CLI spawned with PID:', claude.pid); + + let stdout = '', stderr = '', resolved = false; + const safeResolve = (value, reason) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + debugLog('CLI resolved:', reason, '| Output length:', value?.length || 0); + resolve(value); + }; + + claude.stdout.on('data', (d) => { stdout += d; }); + claude.stderr.on('data', (d) => { stderr += d; }); + + const timeoutId = setTimeout(() => { + debugLog('Claude CLI timeout after', CONFIG.CLI_TIMEOUT_MS, 'ms'); + claude.kill('SIGTERM'); + safeResolve(null, 'timeout'); + }, CONFIG.CLI_TIMEOUT_MS); + + claude.on('close', (code, signal) => { + if (signal) { safeResolve(null, 'signal:' + signal); return; } + debugLog('Claude CLI exit code:', code); + debugLog('CLI stdout:', stdout.substring(0, 200)); + debugLog('CLI stderr:', stderr.substring(0, 200)); + (code === 0 && stdout.trim()) ? safeResolve(stdout.trim(), 'success') + : safeResolve(null, 'error:' + code); + }); + + claude.on('error', (err) => { + debugLog('Claude CLI spawn error:', err.message); + safeResolve(null, 'spawn-error'); + }); + }); +} + +async function callClaudeCLI(prompt) { + debugLog('=== Calling Claude CLI for summary generation ==='); + const skip = []; + // Try each auth method in priority order, falling back on failure + for (let i = 0; i <= AUTH_CHECKS.length; i++) { + const { env, authName } = buildCleanEnv(skip); + const result = await spawnClaudeCLI(prompt, env); + if (result) return result; + if (authName === 'none') break; + debugLog(`Auth ${authName} failed, trying next`); + skip.push(authName); + } + return null; +} + +function cleanSummaryResponse(summary) { + if (!summary) { + debugLog('No summary to clean'); + return null; + } + + debugLog('Raw summary:', summary); + + const cleaned = summary + .replace(/^(要約[::]?\s*|作業内容[::]?\s*)/i, '') + .replace(/^[\s\n]+/, '') + .split('\n')[0] + .trim(); + + debugLog('Cleaned summary:', cleaned); + return cleaned.length >= CONFIG.MIN_TEXT_LENGTH ? cleaned : null; +} + +async function generateSummary(transcriptPath) { + debugLog('=== Starting summary generation ==='); + + const transcriptData = parseTranscriptData(transcriptPath); + debugLog('Transcript data:', { + hasFirstRequest: !!transcriptData.firstRequest, + responseCount: transcriptData.assistantResponses.length + }); + + const context = formatContextForSummary(transcriptData); + if (!context) { + debugLog('No context available for summary'); + return null; + } + + const prompt = createSummarizationPrompt(context); + debugLog('Prompt length:', prompt.length); + + const output = await callClaudeCLI(prompt); + const result = cleanSummaryResponse(output); + + debugLog('=== Summary generation complete:', result ? 'SUCCESS' : 'FAILED', '==='); + return result; +} + +// ============================================================================ +// Slack API Integration +// ============================================================================ + +function makeSlackApiRequest(apiPath, data, token) { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(data); + debugLog('Slack API request:', apiPath, '| Payload size:', payload.length); + + const req = https.request({ + hostname: 'slack.com', + port: 443, + path: apiPath, + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(payload) + } + }, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => { + const result = safeJsonParse(body); + debugLog('Slack API response:', apiPath, '| ok:', result?.ok, '| error:', result?.error || 'none'); + if (result?.ok) { + resolve(result); + } else { + debugLog('Slack API error details:', result); + resolve(null); + } + }); + }); + + req.on('error', (error) => { + debugLog('Slack API network error:', error.message); + reject(error); + }); + req.write(payload); + req.end(); + }); +} + +const sendSlackMessage = (channelId, token, text) => + makeSlackApiRequest('/api/chat.postMessage', { channel: channelId, text }, token); + +const deleteSlackMessage = (channelId, token, ts) => + makeSlackApiRequest('/api/chat.delete', { channel: channelId, ts }, token); + +// ============================================================================ +// Git Integration +// ============================================================================ + +function getRepositoryName() { + return new Promise((resolve) => { + const git = spawn('git', ['rev-parse', '--show-toplevel']); + let output = ''; + + git.stdout.on('data', (data) => { output += data; }); + git.on('close', (code) => { + const repoName = code === 0 && output.trim() + ? path.basename(output.trim()) + : process.env.GIT_REPO || CONFIG.FALLBACK_REPO_NAME; + debugLog('Repository name:', repoName); + resolve(repoName); + }); + git.on('error', () => resolve(process.env.GIT_REPO || CONFIG.FALLBACK_REPO_NAME)); + }); +} + +// ============================================================================ +// Hook Input Processing +// ============================================================================ + +async function readHookInput() { + debugLog('=== Reading hook input from stdin ==='); + if (process.stdin.isTTY) { + debugLog('stdin is TTY, no hook input available'); + return { transcriptPath: null, stopHookActive: false }; + } + + let input = ''; + for await (const chunk of process.stdin) { + input += chunk; + } + + debugLog('Raw hook input length:', input.length, '| Preview:', input.substring(0, 200)); + const parsed = safeJsonParse(input); + if (parsed) { + debugLog('Parsed hook input:', JSON.stringify(parsed, null, 2)); + } else { + debugLog('Failed to parse hook input'); + } + + const result = { + transcriptPath: parsed?.transcript_path ?? null, + stopHookActive: parsed?.stop_hook_active === true, + assistantResponse: parsed?.assistant_response ?? null + }; + debugLog('Extracted hook input values:', result); + + return result; +} + +// ============================================================================ +// Message Formatting +// ============================================================================ + +function formatNotificationMessage(repoName, summary, includeMention = false) { + const userMention = process.env.SLACK_USER_MENTION; + const content = summary || CONFIG.NO_SUMMARY_MESSAGE; + + // Add debug info to message if in debug mode + const debugInfo = isDebugMode() + ? ` [RUN:${RUN_ID}|PID:${process.pid}]` + : ''; + + const message = `[${repoName}] ${content}${debugInfo}`; + + return includeMention && userMention ? `${userMention} ${message}` : message; +} + +// ============================================================================ +// Main Flow +// ============================================================================ + +async function main() { + debugLog('=== Slack notify script started ==='); + debugLog('RUN_ID:', RUN_ID); + debugLog('PID:', process.pid); + debugLog('CWD:', process.cwd()); + debugLog('ENV - CLAUDE_SESSION_ID:', process.env.CLAUDE_SESSION_ID || 'not set'); + debugLog('ENV - DEBUG_SLACK_NOTIFY:', process.env.DEBUG_SLACK_NOTIFY || 'not set'); + + loadEnvFile(); + + const { SLACK_CHANNEL_ID: channelId, SLACK_BOT_TOKEN: token } = process.env; + if (!channelId || !token) { + debugLog('Missing required env vars - SLACK_CHANNEL_ID:', !!channelId, '| SLACK_BOT_TOKEN:', !!token); + process.exit(0); + } + + // Prevent duplicate execution + if (!acquireLock()) { + debugLog('EXITING: Could not acquire lock'); + process.exit(0); + } + + try { + const [hookInput, repoName] = await Promise.all([ + readHookInput(), + getRepositoryName() + ]); + + const { transcriptPath, stopHookActive, assistantResponse } = hookInput; + + debugLog('Hook input received:', { + transcriptPath, + stopHookActive, + assistantResponse: assistantResponse ? assistantResponse.substring(0, 100) : null, + hasTranscriptPath: !!transcriptPath + }); + + // Skip if this is a recursive hook call (e.g., from internal Claude CLI for summary generation) + if (stopHookActive) { + debugLog('EXITING: stop_hook_active is true (preventing recursive execution)'); + releaseLock(); + process.exit(0); + } + + debugLog('Transcript path:', transcriptPath); + debugLog('Repository name:', repoName); + + const summaryStartTime = Date.now(); + let summary = null; + if (assistantResponse) { + // Kiro CLI: assistant_responseから直接要約生成 + const context = assistantResponse.length > CONFIG.MAX_CONTEXT_LENGTH + ? assistantResponse.substring(0, CONFIG.MAX_CONTEXT_LENGTH) + : assistantResponse; + const prompt = createSummarizationPrompt(`【アシスタントの応答】\n${context}`); + const output = await callClaudeCLI(prompt); + summary = cleanSummaryResponse(output); + } else if (transcriptPath) { + // Claude Code: transcript_pathから要約生成 + summary = await generateSummary(transcriptPath); + } + const summaryDuration = Date.now() - summaryStartTime; + debugLog('Final summary:', summary, '| Generation time:', summaryDuration, 'ms'); + + // Send message with mention to trigger notification + const mentionMessage = formatNotificationMessage(repoName, summary, true); + debugLog('=== Sending mention message ===', mentionMessage.substring(0, 100)); + const mentionSendTime = Date.now(); + const mentionResult = await sendSlackMessage(channelId, token, mentionMessage); + if (!mentionResult) { + debugLog('FAILED to send mention message'); + return; + } + debugLog('Mention message sent | ts:', mentionResult.ts, '| Duration:', Date.now() - mentionSendTime, 'ms'); + + // Wait a bit before sending clean message and deleting + debugLog('Waiting', CONFIG.DELETE_DELAY_MS, 'ms before cleanup'); + await sleep(CONFIG.DELETE_DELAY_MS); + + // Send clean message without mention + const cleanMessage = formatNotificationMessage(repoName, summary, false); + debugLog('=== Sending clean message ===', cleanMessage.substring(0, 100)); + const cleanSendTime = Date.now(); + const cleanResult = await sendSlackMessage(channelId, token, cleanMessage); + if (!cleanResult) { + debugLog('FAILED to send clean message'); + } else { + debugLog('Clean message sent | ts:', cleanResult.ts, '| Duration:', Date.now() - cleanSendTime, 'ms'); + } + + // Delete the mention message + if (mentionResult.ts) { + debugLog('=== Deleting mention message | ts:', mentionResult.ts, '==='); + const deleteStartTime = Date.now(); + const deleteResult = await deleteSlackMessage(channelId, token, mentionResult.ts); + if (deleteResult) { + debugLog('Mention message deleted successfully | Duration:', Date.now() - deleteStartTime, 'ms'); + } else { + debugLog('FAILED to delete mention message'); + } + } + + debugLog('=== Slack notify script completed successfully | Total runtime:', Date.now() - summaryStartTime + summaryDuration, 'ms ==='); + } finally { + releaseLock(); + } +} + +main().catch((error) => { + debugLog('Fatal error:', error.message, error.stack); + releaseLock(); + process.exit(0); +}); diff --git a/plugins/ndf-shared/scripts/statusline-switch.sh b/plugins/ndf-shared/scripts/statusline-switch.sh new file mode 100755 index 0000000..bccb616 --- /dev/null +++ b/plugins/ndf-shared/scripts/statusline-switch.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# NDF plugin: statusline の導入・切替・復元を行う。 +# +# Usage: statusline-switch.sh +# ensure : SessionStart hook 用。statusLine 未設定の場合のみ NDF 版を設定する +# (既存設定があれば何もしない)。NDF 版利用中はスクリプト更新のみ追従。 +# set : 既存の statusLine 設定をバックアップした上で NDF 版に切り替える。 +# restore : バックアップから元の statusLine 設定を復元する +# (バックアップが無ければ statusLine 設定を削除しデフォルト表示に戻す)。 +# status : 現在の statusLine 設定とバックアップの有無を表示する。 +set -euo pipefail + +CMD="${1:-status}" +SETTINGS="$HOME/.claude/settings.json" +TARGET="$HOME/.claude/ndf-statusline.sh" +BACKUP="$HOME/.claude/.ndf-statusline-backup.json" +LOCK="$HOME/.claude/.ndf-statusline.lock" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC="$SCRIPT_DIR/statusline.sh" +NDF_COMMAND="bash ~/.claude/ndf-statusline.sh" + +# jq 必須 (ensure は SessionStart hook から毎回呼ばれるためノイズ防止で黙ってスキップ) +if ! command -v jq >/dev/null 2>&1; then + if [ "$CMD" != "ensure" ]; then + echo "[ndf:statusline] jq が見つからないため処理をスキップしました" >&2 + fi + exit 0 +fi + +mkdir -p "$HOME/.claude" +if [ ! -f "$SETTINGS" ]; then + echo "{}" > "$SETTINGS" +fi + +# プラグイン同梱の statusline.sh を ~/.claude/ndf-statusline.sh に配置 (差分時のみ) +deploy_script() { + if [ ! -f "$TARGET" ] || ! cmp -s "$SRC" "$TARGET"; then + cp "$SRC" "$TARGET" + chmod +x "$TARGET" + fi +} + +current_command() { + jq -r '.statusLine.command // empty' "$SETTINGS" 2>/dev/null || true +} + +is_ndf_statusline() { + case "$(current_command)" in + *ndf-statusline.sh*) return 0 ;; + *) return 1 ;; + esac +} + +# current_command から実行スクリプトのパスを抽出し、先頭の ~ を $HOME に展開する。 +# 例: "bash ~/.claude/statusline-command.sh" -> "/home/user/.claude/statusline-command.sh" +current_script_path() { + local cmd path + cmd="$(current_command)" + [ -z "$cmd" ] && return 0 + # 末尾の *.sh トークンを実行対象スクリプトとみなす。 + # grep -o は GNU 拡張のため、POSIX 準拠かつ BusyBox でも動く sed で抽出する。 + # NDF が配置するコマンドは "bash ~/.claude/.sh" 形式 (パスにスペースを含まない) を想定。 + # 1つ目の置換がマッチしたら t で分岐して二重出力を防ぐ (tail -n1 のパイプを削減)。 + path="$(printf '%s\n' "$cmd" | sed -n 's/.*[[:space:]]\([^[:space:]]*\.sh\).*/\1/p; t; s/^\([^[:space:]]*\.sh\)$/\1/p')" + [ -z "$path" ] && return 0 + case "$path" in + "~/"*) path="$HOME/${path#\~/}" ;; + "~") path="$HOME" ;; + esac + printf '%s\n' "$path" +} + +# 指定スクリプトが NDF 由来 (マーカー付き、または既知のレガシーコピー) か判定する。 +# ユーザー独自の statusline を誤って上書きしないため、判定は厳格に行う。 +is_ndf_managed_copy() { + local path="$1" + [ -f "$path" ] || return 1 + # ① マーカーがあれば NDF 管理コピー確定 (今後配置される全コピーが該当) + if grep -Fq 'ndf-statusline: managed' "$path" 2>/dev/null; then + return 0 + fi + # ② レガシー救済: マーカー導入前の既知の旧コピー名で、かつ NDF statusline 特有の + # ロジック (ctx ラベル + コンテナ名取得) を両方含む場合のみ移行対象とする + case "$(basename "$path")" in + statusline-command.sh) + if grep -Fq '[ctx:' "$path" 2>/dev/null && grep -Fq 'container_name' "$path" 2>/dev/null; then + return 0 + fi + ;; + esac + return 1 +} + +# settings.json を atomic に書き換える。引数: jq フィルタ +update_settings() { + local tmp + tmp="$(mktemp)" + if jq "$@" "$SETTINGS" > "$tmp"; then + mv "$tmp" "$SETTINGS" + else + rm -f "$tmp" + return 1 + fi +} + +set_ndf_statusline() { + update_settings --arg cmd "$NDF_COMMAND" \ + '.statusLine = {type: "command", command: $cmd}' +} + +# NDF 由来の旧 statusline を検出した際に、既存設定をバックアップした上で +# 正規パス (~/.claude/ndf-statusline.sh) 参照へ移行する。 +migrate_to_ndf_statusline() { + local existing + existing=$(jq -c '.statusLine // empty' "$SETTINGS" 2>/dev/null || true) + if [ -n "$existing" ] && [ ! -f "$BACKUP" ]; then + printf '%s\n' "$existing" > "$BACKUP" + fi + set_ndf_statusline + echo "[ndf:statusline] NDF 由来の旧 statusline を検出したため正規パス (~/.claude/ndf-statusline.sh) へ移行しました (旧設定は $BACKUP に退避)" +} + +cmd_ensure() { + deploy_script + # 既に statusLine が設定されている場合 + if [ -n "$(jq -r '.statusLine // empty' "$SETTINGS" 2>/dev/null)" ]; then + # 正規パスを指していれば deploy_script で本体が追従済み (何もしない) + if is_ndf_statusline; then + return 0 + fi + # NDF が過去に配置したコピー (マーカー付き or レガシー statusline-command.sh) を + # 指している場合のみ、正規パス参照へ移行してバージョンアップ追従を回復する + local cur_path + cur_path="$(current_script_path)" + if [ -n "$cur_path" ] && is_ndf_managed_copy "$cur_path"; then + migrate_to_ndf_statusline + return 0 + fi + # それ以外はユーザー独自設定として尊重し、何もしない + return 0 + fi + set_ndf_statusline + echo "[ndf] statusLine を NDF 標準 statusline に設定しました (~/.claude/settings.json)" +} + +cmd_set() { + deploy_script + if is_ndf_statusline; then + echo "[ndf:statusline] 既に NDF 標準 statusline が設定されています" + return 0 + fi + # 既存設定があればバックアップしてから切り替え + local existing + existing=$(jq -c '.statusLine // empty' "$SETTINGS" 2>/dev/null || true) + if [ -n "$existing" ]; then + printf '%s\n' "$existing" > "$BACKUP" + echo "[ndf:statusline] 既存設定をバックアップしました: $BACKUP" + fi + set_ndf_statusline + echo "[ndf:statusline] NDF 標準 statusline に切り替えました" +} + +cmd_restore() { + if [ -f "$BACKUP" ]; then + update_settings --slurpfile bk "$BACKUP" '.statusLine = $bk[0]' + rm -f "$BACKUP" + echo "[ndf:statusline] バックアップから statusLine 設定を復元しました" + elif is_ndf_statusline; then + update_settings 'del(.statusLine)' + echo "[ndf:statusline] バックアップが無いため statusLine 設定を削除しました (デフォルト表示に戻ります)" + else + echo "[ndf:statusline] 復元対象がありません (NDF statusline は未使用、バックアップ無し)" + fi +} + +cmd_status() { + local cur + cur=$(current_command) + if [ -z "$cur" ]; then + echo "statusLine: 未設定 (デフォルト表示)" + elif is_ndf_statusline; then + echo "statusLine: NDF 標準 statusline ($cur)" + else + echo "statusLine: カスタム設定 ($cur)" + fi + if [ -f "$BACKUP" ]; then + echo "バックアップ: あり ($(cat "$BACKUP"))" + else + echo "バックアップ: なし" + fi +} + +run_cmd() { + case "$CMD" in + ensure) cmd_ensure ;; + set) cmd_set ;; + restore) cmd_restore ;; + status) cmd_status ;; + *) + echo "Usage: $(basename "$0") " >&2 + exit 1 + ;; + esac +} + +# 複数セッション同時起動時の race condition を flock で回避 (flock 不在時は atomic rename のみ) +if command -v flock >/dev/null 2>&1; then + ( + flock -x -w 5 200 || exit 0 + run_cmd + ) 200>"$LOCK" +else + run_cmd +fi diff --git a/plugins/ndf-shared/scripts/statusline.sh b/plugins/ndf-shared/scripts/statusline.sh new file mode 100755 index 0000000..19d7c09 --- /dev/null +++ b/plugins/ndf-shared/scripts/statusline.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# ndf-statusline: managed (do not edit; auto-updated by ndf:statusline) +# NDF plugin 標準 statusline: +# <コンテナ名 or ホスト名> [<モデル名>: 使用トークン / 全体 (使用率%)] +input=$(cat) + +# コンテナ名を取得する。コンテナでなければホスト名にフォールバック +container_name="" + +# Docker/コンテナ環境かどうかを /.dockerenv で判定 +if [ -f /.dockerenv ]; then + # CONTAINER_NAME 環境変数が明示設定されていればそちらを優先 + container_name="${CONTAINER_NAME:-}" + + # Docker ソケットが使えれば docker inspect で compose 上のコンテナ名を取得 + # (/etc/hostname はコンテナIDなので、それをキーに引く) + if [ -z "$container_name" ] && [ -S /var/run/docker.sock ] && command -v docker >/dev/null 2>&1; then + container_id=$(cat /etc/hostname 2>/dev/null | tr -d '[:space:]') + if [ -n "$container_id" ]; then + container_name=$(docker inspect --format '{{.Name}}' "$container_id" 2>/dev/null | sed 's|^/||') + fi + fi + + # 取れなければ /etc/hostname(コンテナID)をフォールバックとして使用 + if [ -z "$container_name" ] && [ -f /etc/hostname ]; then + container_name=$(cat /etc/hostname 2>/dev/null | tr -d '[:space:]') + fi +fi + +# コンテナ名が取れなければ hostname コマンドにフォールバック +if [ -z "$container_name" ]; then + container_name=$(hostname -s 2>/dev/null || hostname) +fi + +dir="$container_name" + +# claude root のパスを取得(project_dir を優先し、なければ current_dir を使用) +# jq 不在や無効な JSON 入力時に stderr が statusLine 描画に漏れないよう 2>/dev/null で抑制 +claude_root=$(echo "$input" | jq -r '.workspace.project_dir // .workspace.current_dir // empty' 2>/dev/null) + +total_input=$(echo "$input" | jq -r '.context_window.total_input_tokens // empty' 2>/dev/null) +ctx_size=$(echo "$input" | jq -r '.context_window.context_window_size // empty' 2>/dev/null) +used_pct=$(echo "$input" | jq -r '.context_window.used_percentage // empty' 2>/dev/null) + +# モデル表示名を取得(ラベルとして使用)。取れなければ "ctx" にフォールバック +model_name=$(echo "$input" | jq -r '.model.display_name // .model.id // empty' 2>/dev/null) +ctx_label="${model_name:-ctx}" + +ctx_info="" +if [ -n "$total_input" ] && [ -n "$ctx_size" ] && [ -n "$used_pct" ]; then + total_input_k=$(awk "BEGIN { printf \"%.1f\", $total_input / 1000 }") + ctx_size_k=$(awk "BEGIN { printf \"%.0f\", $ctx_size / 1000 }") + ctx_info=$(printf " \033[0;36m[%s: %sk / %sk tokens (%.0f%%)]" "$ctx_label" "$total_input_k" "$ctx_size_k" "$used_pct") +fi + +if [ -n "$claude_root" ]; then + printf "\033[01;34m%s\033[00m \033[0;33m%s\033[00m%s" "$dir" "$claude_root" "$ctx_info" +else + printf "\033[01;34m%s\033[00m%s" "$dir" "$ctx_info" +fi diff --git a/plugins/ndf-shared/skills/branch-fix-strategy/SKILL.md b/plugins/ndf-shared/skills/branch-fix-strategy/SKILL.md new file mode 100644 index 0000000..a4714a3 --- /dev/null +++ b/plugins/ndf-shared/skills/branch-fix-strategy/SKILL.md @@ -0,0 +1,87 @@ +--- +name: branch-fix-strategy +description: "Plan multi-branch fixes and cherry-picks." +when_to_use: "同じ修正を複数ブランチ (qa/staging/release等) に適用する必要があるとき。Triggers: 'cherry-pick', '環境ブランチに修正適用', 'qaに反映', 'stagingに反映', 'release branchへ', 'multi-branch fix', 'apply to qa/staging'" +--- + +# ブランチ修正適用戦略 + +## 適用タイミング + +- featureブランチの修正を `qa/*`, `staging/*`, `release/*` 等の環境ブランチにも適用する必要がある場合 +- 同じ修正を複数ブランチに並行適用する場面全般 + +## 核心ルール + +### 1. 修正は feature ブランチに先に commit → cherry-pick で環境ブランチへ + +``` +✅ feature に commit → cherry-pick して短命ブランチ → 環境ブランチへ PR +❌ 短命ブランチに先に commit → feature に手作業で再実装(二重作業・不整合リスク) +``` + +### 2. 環境ブランチを feature ブランチに merge しない(main 汚染禁止) + +``` +❌ feature/xxx ← merge qa/staging(conflict 解消目的でも禁止) +``` + +環境ブランチを featureブランチにmergeすると、後で `feature → main` のPRに環境固有コードが混入する。 + +### 3. origin/main を必ず取り込む + +短命ブランチを push する前に必ず `git merge origin/main` する。CI で最新 main 必須の Workflow があるため。 + +### 4. マージ済みブランチに push しない + +環境ブランチ向けの短命ブランチに push する前に `gh pr list --head ` で PR 状態を確認する。マージ済みなら新ブランチ + 新 PR を作成する(サフィックス `-v2`, `-v3` を付ける)。 + +## 実行手順 + +`/ndf:cherry-pick-pr ` で自動化されている。手動で行う場合のみ以下を参照。 + +```bash +# 1. feature ブランチで修正を commit +git checkout feature/xxx +git add && git commit -m "fix: 修正内容" +git log --oneline -1 # commit hash を記録 + +# 2. 短命ブランチを作成 +git fetch origin qa/staging +git checkout -b feature/xxx-for-staging origin/qa/staging + +# 3. origin/main を取り込む(必須) +git fetch origin main +git merge origin/main --no-edit + +# 4. cherry-pick(-x で元 commit hash を参照に残す) +git cherry-pick -x + +# 5. push して PR 作成 +git push -u origin feature/xxx-for-staging +gh pr create --base qa/staging --title "fix: 修正内容(staging検証用)" + +# 6. 元のブランチに戻る +git checkout feature/xxx +``` + +## なぜこの順序が重要か + +| 観点 | 正しい順序 | 誤った順序 | +|------|-----------|-----------| +| 単一ソース | feature ブランチが唯一の正 | 二箇所で実装 | +| 一貫性 | cherry-pick で完全一致 | 手書き差分でズレる | +| 追跡性 | `-x` で元 commit が明記 | 関連 commit 不明確 | + +## revert 操作の注意 + +revertの連鎖(revert → reapply → revert...)ではなく、**最終的なあるべき状態を直接コミット**するのが望ましい。履歴上の意図が明確になり、後の cherry-pick も簡単になる。 + +## 関連コマンド・スキル + +| リソース | 用途 | +|---------|------| +| `/ndf:cherry-pick-pr` | cherry-pick + 短命ブランチ + origin/main 取り込み + PR 作成を自動化 | +| `/ndf:pr` | 通常のPR作成。非 main ベースは `cherry-pick-pr` に誘導される | +| `/ndf:sync-main` | 現在のブランチに最新 main を取り込む | +| `/ndf:deploy` | 環境ブランチへのデプロイPR作成(ブランチ全体をmerge main経由で適用) | diff --git a/plugins/ndf-shared/skills/browser-test/SKILL.md b/plugins/ndf-shared/skills/browser-test/SKILL.md new file mode 100644 index 0000000..97eb0c8 --- /dev/null +++ b/plugins/ndf-shared/skills/browser-test/SKILL.md @@ -0,0 +1,159 @@ +--- +name: browser-test +description: "Run browser smoke tests for web apps." +argument-hint: "[url]" +disable-model-invocation: true +allowed-tools: + - Bash + - mcp__playwright__browser_navigate + - mcp__playwright__browser_snapshot + - mcp__playwright__browser_click + - mcp__playwright__browser_fill_form + - mcp__playwright__browser_take_screenshot + - mcp__playwright__browser_type + - mcp__playwright__browser_evaluate + - mcp__playwright__browser_console_messages + - mcp__playwright__browser_wait_for + - mcp__playwright__browser_tabs + - mcp__playwright__browser_navigate_back + - mcp__playwright__browser_close + - mcp__playwright__browser_resize + - mcp__playwright__browser_handle_dialog + - mcp__playwright__browser_press_key + - mcp__playwright__browser_hover + - mcp__playwright__browser_select_option + - mcp__playwright__browser_drag + - mcp__playwright__browser_network_requests + - mcp__playwright__browser_file_upload + - mcp__playwright__browser_install + - mcp__chrome-devtools__navigate_page + - mcp__chrome-devtools__take_snapshot + - mcp__chrome-devtools__click + - mcp__chrome-devtools__fill_form + - mcp__chrome-devtools__take_screenshot + - mcp__chrome-devtools__type + - mcp__chrome-devtools__evaluate_script + - mcp__chrome-devtools__list_console_messages + - mcp__chrome-devtools__wait_for + - mcp__chrome-devtools__list_pages + - mcp__chrome-devtools__new_page + - mcp__chrome-devtools__select_page + - mcp__chrome-devtools__close_page + - mcp__chrome-devtools__navigate_page_history + - mcp__chrome-devtools__resize_page + - mcp__chrome-devtools__handle_dialog + - mcp__chrome-devtools__hover + - mcp__chrome-devtools__drag + - mcp__chrome-devtools__list_network_requests + - mcp__chrome-devtools__get_network_request + - mcp__chrome-devtools__upload_file + - mcp__chrome-devtools__emulate_network + - mcp__chrome-devtools__emulate_cpu + - mcp__chrome-devtools__performance_start_trace + - mcp__chrome-devtools__performance_stop_trace + - mcp__chrome-devtools__performance_analyze_insight +--- + +# ブラウザ動作確認コマンド + +現在のブランチで実装されたWeb機能をブラウザで動作確認する。Playwright MCP または Chrome DevTools MCP を利用可能な方を自動選択する。 + +## 前提条件(重要) + +このコマンドは以下のいずれかのMCPサーバが必要: + +- **Playwright MCP**: 自動的にブラウザを起動(要Playwrightインストール) +- **Chrome DevTools MCP**: 既に開いているChromeを操作(Chromeをデバッグモードで起動しておく必要あり) + +どちらも利用できない環境では、手動確認手順を案内する。 + +## 使用方法 + +``` +/ndf:browser-test # 現在のブランチの実装を確認 +/ndf:browser-test http://localhost:8080 # 特定URLを確認 +``` + +## MCPの使い分け + +### Playwright MCP +- 自動的にブラウザを起動 +- 複数ブラウザ対応 (Chromium/Firefox/WebKit) +- 利用可能なら第一選択 + +### Chrome DevTools MCP +- 既に開いているChromeブラウザを操作 +- Chrome デバッグモードでの起動が必要: + - macOS: `/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222` + - Windows: `chrome.exe --remote-debugging-port=9222` + - Linux: `google-chrome --remote-debugging-port=9222` +- DevTools統合でパフォーマンス分析可能 + +## 処理フロー + +### 1. アプリケーション起動確認 + +プロジェクトで使っている起動方法に応じて確認: + +```bash +# Docker Compose の場合 +docker compose ps + +# ネイティブ起動の場合 +curl -fsS http://localhost:/health || echo "NOT RUNNING" +``` + +起動していない場合は、起動手順をユーザーに案内。 + +### 2. ブラウザアクセスと認証 + +- 指定URL(または `/` )にアクセス +- 必要に応じてログイン(資格情報はプロジェクト固有、事前に取得しておく) + +### 3. 機能画面への遷移 + +実装された機能に応じて適切な画面に遷移する。 + +### 4. 動作確認 + +必要に応じて以下の操作を実行: + +- フォーム入力 +- ボタンクリック +- データ表示の確認 +- コンソールエラーの確認 +- ネットワークリクエストの確認 +- スクリーンショット(明示的に指示された場合のみ) + +### 5. 結果報告 + +```markdown +## 動作確認結果 + +### 実施項目 +- [x] ログイン +- [x] 機能画面表示 +- [x] フォーム送信 +- [x] 結果表示 + +### 確認事項 +- コンソールエラー: なし +- ネットワークエラー: なし +- 期待結果との一致: ok + +### 気になる点 +- ...(あれば) +``` + +## 注意事項 + +- **事前にアプリケーション起動が必要** +- **ログイン情報**: プロジェクトの `.env.example` / README 等から確認。機密情報として扱う +- **スクリーンショット**: 必要な場合のみ明示的に指示されたときに取得 +- **Chrome DevTools使用時**: Chromeをデバッグモードで起動しておく必要あり +- **MCP未インストール環境**: 手動での確認手順を案内する + +## 関連 + +- `/ndf:review-branch` — 変更差分のコードレビュー +- `/ndf:pr-tests` — PR Test Plan の自動実行 diff --git a/plugins/ndf-shared/skills/cherry-pick-pr/SKILL.md b/plugins/ndf-shared/skills/cherry-pick-pr/SKILL.md new file mode 100644 index 0000000..b5dc134 --- /dev/null +++ b/plugins/ndf-shared/skills/cherry-pick-pr/SKILL.md @@ -0,0 +1,120 @@ +--- +name: cherry-pick-pr +description: "Create cherry-pick PRs for environment branches." +argument-hint: " (例: qa/staging, release/v2)" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Grep +--- + +# cherry-pick PR 作成コマンド + +featureブランチから指定ベースブランチへ、短命ブランチ経由で cherry-pick PR を作成する。`feature → main` の PR にベースブランチ固有コードが混入するのを防ぐ。 + +## 使用方法 + +``` +/ndf:cherry-pick-pr qa/staging +/ndf:cherry-pick-pr release/v2 +``` + +## なぜ必要か + +featureブランチに環境ブランチ(`qa/staging`等)を merge して conflict を解消すると、`feature → main` の PR に環境ブランチ固有のコードが混入する(main汚染)。短命ブランチ + cherry-pick で、必要なコミットだけを対象ブランチに届ける。 + +詳細な原則は `/ndf:branch-fix-strategy` スキル参照。 + +## 処理フロー + +### 1. 引数・現状確認 +- 引数からベースブランチ名を取得(必須。未指定なら確認) +- `git branch --show-current` で現在ブランチを取得 + +### 2. 既存PRのマージ済みチェック(必須) + +同じベースブランチ向けの短命ブランチに既存PRがないか確認する。 + +```bash +# 同名パターンのブランチでマージ済みPRがないか確認 +gh pr list --head "-for-" --state merged \ + --json number,mergedAt --jq '.[]' +``` + +マージ済みPRが見つかった場合、**同じブランチ名は使えない**。サフィックスを付ける(例: `-v2`, `-v3`)。 + +### 3. コミット一覧の確認 + +```bash +git log --oneline main..HEAD +``` + +ユーザーに cherry-pick 対象コミットを確認(全コミット or 選択)。 + +### 4. 短命ブランチ作成 + +```bash +git fetch origin +git checkout -b -for- origin/ +``` + +- ``: ベースブランチのスラッシュ以降(例: `qa/staging` → `staging`) +- 例: `feature/add-auth-for-staging` + +### 5. origin/main を取り込む(必須) + +```bash +git fetch origin main +git merge origin/main --no-edit +``` + +CIで最新main必須のWorkflowがあるため、取り込み忘れるとconflictやCIエラーになる。 + +### 6. cherry-pick 実行 + +```bash +git cherry-pick -x ... +``` + +`-x` オプションで元のcommit hashが参照として残り、追跡性が向上する。 + +conflict が発生した場合: +- `git diff --name-only --diff-filter=U` でconflictファイル一覧 +- 解消を試み、ユーザーに確認後 `git cherry-pick --continue` + +### 7. push して PR 作成 + +```bash +git push -u origin +gh pr create --base --title "<タイトル>" --body "$(cat <<'EOF' +## Summary +- feature/xxx からcherry-pickした<環境名>向けPR +- 元コミット: + +## Test plan +- [ ] <環境名>で動作確認 + + +EOF +)" +``` + +### 8. 元ブランチに戻る + +```bash +git checkout +``` + +## 注意事項 + +- 短命ブランチは PR マージ後に削除してよい +- `feature → main` の PR には影響しない +- ベースブランチを feature ブランチに merge するのは **禁止**(main汚染の原因) +- `-x` オプションで元commit参照を残す(追跡性) + +## 関連 + +- `/ndf:branch-fix-strategy` — なぜこの手順が必要かの原則 +- `/ndf:pr` — 通常のPR作成(base=main) +- `/ndf:deploy` — ブランチ全体を環境へデプロイ(cherry-pickとは別用途) diff --git a/plugins/ndf-shared/skills/clean/SKILL.md b/plugins/ndf-shared/skills/clean/SKILL.md new file mode 100644 index 0000000..2f75e54 --- /dev/null +++ b/plugins/ndf-shared/skills/clean/SKILL.md @@ -0,0 +1,20 @@ +--- +name: clean +description: "Delete local and remote merged branches." +disable-model-invocation: true +allowed-tools: + - Bash +--- + +# ブランチクリーンアップコマンド + +mainマージ済みブランチをローカル/リモート削除。 + +## 手順 + +1. `git branch --merged main`確認 +2. main・現在ブランチ除外 +3. `git branch -d ` +4. `git push origin --delete ` + +**注意**: 削除前確認・main除外・現在ブランチ除外 diff --git a/plugins/ndf-shared/skills/codex/SKILL.md b/plugins/ndf-shared/skills/codex/SKILL.md new file mode 100644 index 0000000..80060a8 --- /dev/null +++ b/plugins/ndf-shared/skills/codex/SKILL.md @@ -0,0 +1,473 @@ +--- +name: codex +description: "Delegate coding, review, or research to Codex CLI." +when_to_use: "外部 AI へコード生成 / レビュー / 調査を委譲したいとき。Triggers: 'codexで調査', 'codexレビュー', '第二意見レビュー', 'codex exec', 'external AI review'" +--- + +# Codex 外部AI委譲スキル + +## 概要 + +`codex` CLI(OpenAI Codex、通常は `/usr/bin/codex` または `npm` 経由でインストール)を直接実行して、コード生成・独立レビュー・コードベース調査を外部AIに委譲するためのスキル。 + +ローカルファイルの逐語照合レビューや大規模コードベース調査に向いている。 + +## NDFとの関係 + +- NDFプラグインの `corder` エージェントはこの skill の手順に従って Codex CLI を呼び出す +- v4.0.0 で Codex MCP サーバは廃止。`mcp__codex__*` ツールは存在しない +- 使い分け: 軽量な独立レビュー → `corder` エージェントに委譲。手順の詳細を自分で制御したい or 複雑なプロンプトを出したい → 本 skill を参照して直接 `codex exec` 起動 + +## いつ使うか + +### 使うべきケース +- **独立第二意見レビュー**: 設計書・PR・仕様書を外部AIにレビューさせる(メインエージェントの思考バイアスを避ける) +- **コードベース逐語照合**: 「行番号・関数名・重複箇所の件数」を正確に突き合わせる必要がある場合 +- **長時間の調査タスク**: 複数ファイル横断で5〜10分以上かかる調査 +- **実装タスクの並列化**: メインエージェントで他作業を進めつつ、別タスクを codex に走らせたい場合 + +### 使わないべきケース +- 短時間(1〜2分以内)で済むタスク → メインエージェントで直接対応 +- ユーザとの対話が必要な設計相談 → Plan Mode等で対話しながら進める +- 単純な質問回答 → WebFetch / WebSearch で足りる +- 機密情報を含むコード → 外部API送信の可否を確認してから + +## 前提条件 + +```bash +# インストール確認 +which codex +codex --version + +# ログイン状態確認(初回のみ必要) +codex login +``` + +未インストールの場合は以下でセットアップ: + +```bash +# npm 経由 +npm install -g @openai/codex + +# 動作確認 +codex exec --help +``` + +## 基本実行パターン + +### 1. サンドボックス制約(重要) + +codex のデフォルトサンドボックスは `bubblewrap (bwrap)` に依存する。以下の環境では bwrap が動作せず、`exec` で実行するシェルコマンドがすべて失敗する: + +- **WSL2**(カーネルで `unprivileged_userns_clone` が無効) +- **一部の devcontainer / Docker 環境**(user namespace 非対応) + +該当環境では **`--dangerously-bypass-approvals-and-sandbox` を付けて起動**する必要がある。 + +```bash +# ❌ サンドボックス有効(bwrap 失敗で exec コマンドが全滅) +codex exec -s read-only -C "$PWD" + +# ✅ サンドボックスバイパス(外側が既にコンテナ等で隔離されている前提) +codex exec --dangerously-bypass-approvals-and-sandbox -C "$PWD" +``` + +**判断基準**: 既にDocker / devcontainer / VM / CIランナー等で外部的にサンドボックスされているなら `--dangerously-bypass-approvals-and-sandbox` は実用上安全。ホスト直接実行でコード全書き換えされたくない場合はフラグを付けずに対処(後述「bwrap代替」)。 + +#### bwrap 代替の有効化(ホスト直接実行時) + +```bash +# Debian/Ubuntu 系でホスト user namespace を有効化 +sudo sysctl kernel.unprivileged_userns_clone=1 + +# 永続化 +echo 'kernel.unprivileged_userns_clone=1' | sudo tee /etc/sysctl.d/00-local-userns.conf +``` + +### 2. プロンプトは一時ファイル経由で渡す + +長いプロンプトをシェル引数に渡すとエスケープ地獄になるので、**一時ファイル経由でstdinに流す**のが基本。 + +```bash +# Step 1: プロンプトを一時ファイルに書く +cat > /tmp/codex-prompt.md <<'EOF' +## タスク +以下のファイルを読み込み、設計意図とコードの整合性をレビューしてください。 + +## 対象ファイル(絶対パスで指定) +/absolute/path/to/design.md + +## 出力形式 +Markdown で標準出力に吐いてください。 +EOF + +# Step 2: codex exec に stdin で流す(バックグラウンド実行) +codex exec --dangerously-bypass-approvals-and-sandbox -C "$PWD" \ + < /tmp/codex-prompt.md \ + > /tmp/codex-output.md \ + 2> /tmp/codex-err.log & +``` + +**エージェントからの書き方**: ファイル書き込みツールで `/tmp/codex-prompt.md` を作ってから、シェル実行ツールの「バックグラウンド実行」オプションで codex を起動する。 + +### 3. 出力ストリームの扱い + +codex CLI の出力構造: + +| ストリーム | 内容 | +|---|---| +| **stdout** | **最終 assistant message のみ**(Markdown本文)。出ないことがある(後述) | +| **stderr** | プロンプトのエコー + 実行したコマンドと結果 + codexの思考プロセス + `^tokens used$` sentinel | + +**実務上の扱い**: +- 最終成果物が欲しい → `stdout` をそのまま採用…**ただし stdout が空になるケースがあるので必ずファイル出力も併用**(下記 3.5 参照) +- codexが何を調べたか追跡したい → `stderr` をデバッグ用に保存 + +```bash +codex exec ... > /tmp/codex-output.md 2> /tmp/codex-err.log +# 成果物 = /tmp/codex-output.md(stdout が空でないことを必ず確認) +# デバッグ = /tmp/codex-err.log(大きめ、数千行になる) +``` + +### 3.5 最終出力をファイル経由で保証する(重要) + +**Codex CLI(特に `gpt-5-codex` / 高 reasoning_effort)は、長時間調査の末に** +**最終 assistant message を返さずにセッションを終えることがある**。 +このとき stdout は空のままになり、stderr のイベントログ(数十万バイト)には +コードを実際に読んだ痕跡だけが残る。`^tokens used$` は出ているのに stdout が空、という状態。 + +**根本対策: プロンプトに「最終結果は指定ファイルへ書き出すこと」を必須化する。** +Codex は最終 message を返さなくても `apply_patch` ツールでファイルを作成できるため、 +ファイル経由なら確実に結果を回収できる。 + +#### プロンプトに必ず含める指示(テンプレート) + +```markdown +## 出力先(必須) + +最終的なレビュー / 調査結果を以下のファイルに **必ず書き出してください**: + +`/tmp/codex-output-.md` + +書き出しは `apply_patch` で新規ファイル作成してください。 +**stdout への出力だけでは不十分です**(セッション終了で失われる場合があるため)。 +書き出し後、念のため stdout にも同じ内容を出力してください(冪等で問題ありません)。 +``` + +#### 回収側の安全パターン + +```bash +# 1. ファイルが存在するかを最優先で確認(stdout が空でもこちらに本文が残る) +OUTPUT_FILE=/tmp/codex-output-pr13734-review.md +if [ -s "$OUTPUT_FILE" ]; then + cat "$OUTPUT_FILE" +elif [ -s /tmp/codex-stdout.md ]; then + # 2. ファイルがなければ stdout フォールバック + cat /tmp/codex-stdout.md +else + # 3. どちらも空なら stderr の末尾から拾う最後の手段 + echo "WARN: Codex の最終出力を回収できませんでした。stderr 末尾を確認してください:" >&2 + tail -200 /tmp/codex-err.log +fi +``` + +#### 補助対策 + +- **`reasoning_effort` を `medium` に下げる** (`--config reasoning.effort=medium`) + `high` だと思考に偏って最終 message を返さなくなる頻度が上がる +- **`--json` モードでイベント採取** (`codex exec --json`) + JSON Lines で `event.type=assistant_message` を grep すれば確実に取れる +- **強制 summary 指示**: プロンプト末尾に「最後に必ず assistant message として 1 回出力すること、tool 呼び出しのみで終了しないこと」を明記 + +### 4. バックグラウンド実行 + 待機パターン + +codex は **5〜10分かかることが普通**。多くのエージェントハーネスはシェル実行に2〜3分のタイムアウトを課すので、**必ずバックグラウンド実行**する。 + +```bash +# 1. プロンプトファイル書き出し(ファイル書き込みツール) +# -> /tmp/codex-prompt.md + +# 2. codex をバックグラウンドで起動(`&` でシェル自体は即時終了) +codex exec --dangerously-bypass-approvals-and-sandbox -C "$PWD" \ + < /tmp/codex-prompt.md \ + > /tmp/codex-output.md \ + 2> /tmp/codex-err.log & + +# 3. PID を控える +echo "PID: $!" + +# 4. 待機(他の作業を進める or スケジューラで再開) + +# 5. 完了検知 — ps -p は zombie に騙される。stderr の "tokens used" sentinel を見る +until grep -q '^tokens used$' /tmp/codex-err.log 2>/dev/null; do + sleep 30 +done +``` + +**⚠️ 罠**: `&` でバックグラウンド実行するとラッパーシェルは即終了し「タスク完了通知」が発火するが、codex 本体はまだ動いている。**`ps -p $PID` は zombie (defunct) も 0 を返す** ため `until ! ps -p $PID` は永久ループになりうる。`grep -q '^tokens used$' /tmp/codex-err.log` を脱出条件にする (codex が最終回答を吐き終わると stderr 末尾に必ず出る sentinel)。 + +### 5. 待機間隔のチューニング + +エージェントの context cache TTL は通常5分。これを超えると prompt cache がミスして再送料金が発生する: + +- **短い間隔**: 60〜270秒(TTL=5分内に収まる、軽量) +- **長い間隔**: 1200秒以上(1回のキャッシュミスを長時間で償却) +- **避けるべき**: 300秒前後(キャッシュミス+短時間の最悪) + +codex の典型実行時間(5〜10分)に対しては **270秒ポーリング** か **1200秒一括待ち** の二択。 + +### 6. プロセス確認・ログ追跡 + +```bash +# 完了したか (stderr 末尾の "tokens used" が最も信頼できる) +grep -q '^tokens used$' /tmp/codex-err.log && echo DONE + +# 最新の作業内容を覗く +tail -30 /tmp/codex-err.log +``` + +## プロンプト設計のコツ + +### 必須要素 +1. **対象ファイルの絶対パス**(codexは `nl -ba`, `sed -n`, `rg` 等でファイルを読むため) +2. **調査観点を具体化**(箇条書きで3〜5項目に絞る) +3. **出力形式の指定**(Markdownテンプレートを提示) +4. **スコープ外の明示**(codexが脱線しないため) +5. **最終出力先ファイルの指定(必須)**: `/tmp/codex-output-.md` のような明示パスへ + **`apply_patch` で必ず書き出させる**。stdout だけに頼ると最終 message が落ちて空になる事故が起きる(3.5 節参照) +6. **stdout にも同内容を吐く指示**: ファイル書き出し後、念のため stdout にもエコーさせる(冪等) + +### レビュー依頼テンプレート + +```markdown +あなたは<役割(例: シニアバックエンドエンジニア / セキュリティレビュアー)>として、 +以下をレビューしてください。 + +## 対象ファイル(必ず最初に読むこと) +`/absolute/path/to/target.md` + +## 観点 +1. <観点1: 例「仕様とコードの整合性」> +2. <観点2: 例「既存APIとの後方互換性」> + +## 調査対象コード(必要に応じて読む) +- `src/...` +- `lib/...` + +## 背景コンテキスト +- <プロジェクト概要> +- <関連PR / Issue番号> +- <既存レビューで対応済みの事項(重複指摘を避けるため)> + +## 出力形式 + +以下を Markdown で**`/tmp/codex-output-.md` に必ず書き出してください** +(`apply_patch` で新規ファイル作成)。書き出し後、stdout にも同内容を出力してください。 +**stdout のみへの出力は不可**(セッション終了時に失われる場合があるため): + +# <タイトル> + +## 総評 +## 1. <観点1> に関する指摘 +### 1.1 正確な主張 +### 1.2 訂正推奨 +## 2. <観点2> に関する指摘 +## 3. 追加提案 +## 4. 承認可否 + +**必須**: 行番号・ファイルパスに紐付けて具体的に指摘してください。400〜500行程度、日本語で出力してください。 +**必須**: tool 呼び出しのみで終了せず、最後に必ず assistant message として 1 回出力してください。 +``` + +### コード生成依頼テンプレート + +```markdown +以下の実装タスクを実行してください。 + +## タスク +<具体的な実装内容> + +## 制約 +- <技術制約: 言語バージョン、依存ライブラリ> +- <コーディング規約: ESLint / Prettier / rustfmt等> +- <テスト要件: ユニットテスト必須等> + +## 対象ファイル +- <既存ファイルのパス> +- <新規ファイルのパス案> + +## 背景 +<なぜこの実装が必要か、設計判断の経緯> + +## 完了基準 +- [ ] テストがパスする +- [ ] 型チェック / lint がパスする +- [ ] <追加の受け入れ条件> + +**必須**: ファイル編集は実際に行い、最後に変更ファイル一覧と要点を +`/tmp/codex-output-.md` に書き出してください(`apply_patch` で新規作成)。 +書き出し後、stdout にも同内容を出力してください。 +**stdout のみへの出力は不可**(セッション終了時に失われる場合があるため)。 +tool 呼び出しのみで終了せず、最後に必ず assistant message として 1 回出力してください。 +``` + +## 実例: レビュー依頼の完全フロー + +```bash +# === 1. プロンプト書き出し === +# ポイント: 最終出力先ファイルをプロンプト内で明示し、apply_patch で書かせる +FINAL=/tmp/codex-output-api-v2-review.md + +cat > /tmp/review-prompt.md < /tmp/codex-stdout.md \ + 2> /tmp/codex-err.log & + +PID=$! +echo "codex PID: $PID" + +# === 3. 完了確認(^tokens used$ sentinel を待つ) === +until grep -q '^tokens used$' /tmp/codex-err.log 2>/dev/null; do + sleep 30 +done +echo DONE + +# === 4. 成果物を安全に回収(ファイル優先 → stdout fallback) === +if [ -s "$FINAL" ]; then + cp "$FINAL" ./review-result.md + echo "✅ Codex 書き出しファイルから回収" +elif [ -s /tmp/codex-stdout.md ]; then + cp /tmp/codex-stdout.md ./review-result.md + echo "⚠ stdout からフォールバック回収(ファイル書き出しなし)" +else + echo "❌ Codex の最終出力を回収できませんでした。stderr 末尾を確認してください:" >&2 + tail -200 /tmp/codex-err.log + exit 1 +fi +``` + +## トラブルシューティング + +### Q1. stdoutが空でstderrに大量のexecログだけある +**原因**: codex がまだ最終回答を出す前に停止した、または **最終 assistant message を出さずにセッションが終わった**(gpt-5-codex の高 reasoning_effort で発生しやすい既知挙動)。 + +**対処**: +- `grep -q '^tokens used$' /tmp/codex-err.log` で終了 sentinel が出ているか確認(まだなら動作中なので追加待機) +- 出ているのに stdout が空 → セッション終了で最終 message が失われたケース。**3.5 節「最終出力をファイル経由で保証する」のパターンでリトライ必須**: + - プロンプトに `apply_patch` で `/tmp/codex-output-.md` へ必ず書き出させる指示を追加 + - 回収側は「ファイル → stdout → stderr」の三段フォールバックで取りこぼしを防ぐ + - 補助で `--config reasoning.effort=medium` も付けると最終 message を返す傾向が上がる + +### Q2. `bwrap: No permissions to create a new namespace` で exec 失敗 +**原因**: `--dangerously-bypass-approvals-and-sandbox` を付け忘れ、かつ環境が user namespace 非対応。 + +**対処**: +- フラグを追加して再実行 +- `-s read-only` / `-s workspace-write` も bwrap を使うので同じ結果になる点に注意 +- ホストで user namespace を有効化する方法は「サンドボックス制約」節を参照 + +### Q3. codexが「ファイルを読めません」と返してくる +**原因**: +- サンドボックス有効でファイル読み取りに失敗 +- プロンプトで相対パスを指定し、codexの cwd が想定と違った + +**対処**: +- `--dangerously-bypass-approvals-and-sandbox` を追加 +- プロンプトには**絶対パス**を書く +- `-C ` で cwd を明示 + +### Q4. タスク完了通知が来たのに出力が空 / wait loop が抜けない +**原因**: `&` で起動したラッパーシェルが先に終了して通知が出ているだけで、codex 本体は動作中。または既に終わっているが zombie (defunct) として残っており `ps -p $PID` が 0 を返し続けている。 + +**対処**: 検知を「PID の存在」ではなく **stderr の `^tokens used$` sentinel** で行う。codex は最終回答を吐き終えると必ずこの行を stderr に書く。 + +```bash +# ❌ 永久ループ化しうる +until ! ps -p $PID; do sleep 30; done + +# ✅ zombie 安全 +until grep -q '^tokens used$' /tmp/codex-err.log 2>/dev/null; do + sleep 30 +done +``` + +### Q5. codex実行が15分以上かかる +**原因**: プロンプトで広すぎる調査範囲を指定した、または codex が探索ループに入った。 + +**対処**: +- プロンプトで「読むべきファイル」を明示リスト化 +- スコープ外を明記(「〇〇には踏み込まない」) +- 必要なら `kill ` で打ち切り、プロンプトを絞り込んで再実行 + +### Q6. stdoutの末尾が途切れている +**原因**: codex がトークン上限に達した可能性。 + +**対処**: プロンプトで「400行以内」など出力サイズを指定。または観点を絞って再実行。 + +### Q7. 認証エラー (`Unauthorized` / `token expired`) +**原因**: ログインセッション失効。 + +**対処**: +```bash +codex logout +codex login +``` + +## corder エージェント経由との使い分け + +本スキルは CLI を直接呼び出す詳細手順を記述している。簡易に独立レビューを取りたいだけなら `corder` エージェントに委譲した方が手間が少ない: + +| 観点 | corder エージェント | 本スキルで直接 CLI 起動 | +|---|---|---| +| 使い勝手 | `Agent(subagent_type: "corder", ...)` で委譲するだけ | プロンプト書き出し・バックグラウンド起動・PID 管理を自分で制御 | +| プロンプト制御 | corder 側で整形 | 自由に設計可 | +| バックグラウンド実行 | agent 側が制御 | `&` で非同期化、他作業と並列 | +| スケジュール連携 | 難しい | `/schedule` / `Monitor` と組み合わせやすい | + +**指針**: 迷ったら corder 経由。プロンプト細部や非同期タイミングを自分で握りたい場合のみ本スキルの手順で直接起動。 + +## 既知の制約とコスト + +1. **サンドボックス非対応環境**: `--dangerously-bypass-approvals-and-sandbox` で回避必須 +2. **stderrに全思考が書かれる**: 数千行になりうるので必ず `2> /tmp/...` にリダイレクト +3. **ログイン状態**: 初回は `codex login` が必要。未ログインだと即座に失敗する +4. **セッション復旧**: 長時間ジョブで親エージェントが再起動した場合、`codex resume` でセッション再開可能 +5. **APIコスト**: トークン従量課金のため、短時間で済むタスクには使わない。1セッションで数千〜数万トークン消費することがある +6. **機密情報**: 外部APIにコードが送信されるため、社外秘コードの扱いは組織ポリシーに従うこと + +## 関連 + +- **NDF `corder` エージェント**: 本スキルの手順で Codex CLI を呼び出す独立レビュー担当 (v4.0.0 以降は MCP ではなく CLI 経由) +- **OpenAI Codex CLI公式ドキュメント**: `codex --help` / `codex exec --help` +- **他のAI委譲方法**: `gemini`, `claude`, `ollama` 等のCLI も同様のパターンで利用可 diff --git a/plugins/ndf-shared/skills/cross-review/SKILL.md b/plugins/ndf-shared/skills/cross-review/SKILL.md new file mode 100644 index 0000000..5a63867 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/SKILL.md @@ -0,0 +1,475 @@ +--- +name: cross-review +description: "Run iterative Codex and Gemini PR reviews." +argument-hint: "[PR番号] [--max-rounds N] [--rotate-after K] [--rotate-mode light|squash] [--only codex|gemini] [--focus TEXT] [--extra-instructions-file PATH]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Edit + - Write + - Glob + - Grep +--- + +# クロスレビュー収束ループ + +PR を **codex / gemini 両方** にレビューさせ、両者が `APPROVE` を返すまで +`/ndf:review` と `/ndf:fix` を自動で回す。 + +/goalの引数として呼ばれた場合は、codex / gemini が`APPROVE` になるまで/cross-reviewを繰り返す。 + * codex / geminiのいずれかが不具合などで実行できなくなった場合は異常終了とする + * /goalで呼ばれた場合はPR ローテーションは実施しなくてよい。 + * 振動検知した場合はAIが判断して正しい状態を決める。 + +詳細手順は `docs/` 配下に、主要コマンドは `scripts/` 配下に分割している: + +- [docs/01-state-and-review.md](docs/01-state-and-review.md) — Step 0〜4 (state init / round / 並列レビュー / 判定 / 振動検知) +- [docs/02-fix-and-rotation.md](docs/02-fix-and-rotation.md) — Step 5〜8 (サブエージェント修正 / PR ローテーション / 終了処理) +- [scripts/state.py](scripts/state.py) — state.json 操作(uv 自己完結スクリプト、stdlib のみ) +- [scripts/launch-codex.sh](scripts/launch-codex.sh) / [scripts/launch-gemini.sh](scripts/launch-gemini.sh) — レビューランチャ +- [scripts/monitor.py](scripts/monitor.py) — codex/gemini プロセス多軸監視 (sentinel / pidfile / 早期エラー / stall / hard timeout / result.json) +- [scripts/wait-review.sh](scripts/wait-review.sh) — `monitor.py` の薄ラッパ(互換用) +- [scripts/rotate-pr.sh](scripts/rotate-pr.sh) — PR ローテーション + +メインセッションからは `$SCRIPTS/state.py ` 形式で呼ぶだけで、 +state.json の読み書きや AI launcher 起動・完了待ちは全て委譲される。 + +## 設計方針 + +長丁場が予想されるため **メインセッションの context 消費を最小化** する: + +| 観点 | 方針 | +|---|---| +| レビュー投稿 | **AI 自身が `gh api` で PR に直接投稿**。メインはペイロードを保持しない | +| 修正 | **必ずサブエージェント (`general-purpose`) で実行**。メイン context に diff は載せない | +| ユーザ問い合わせ | 自動判断を最大化(`critical`/`major`/`minor` は自動修正、ループ中の `nit` は deferred) | +| 取りこぼし防止 | **ループ終了時(approved / max_rounds / oscillation / error いずれも)に最終スイープを必須実行**。`/ndf:fix` を再実行し、残った open review thread(最終 APPROVE ラウンドの minor/nit インラインコメント含む)を **全て解消**。修正可能なものは修正 + push、判断保留 nit も reply + resolveReviewThread して **open thread 0 で終了** | +| 状態の永続化 | `/.cross_review/cross-review-pr<番号>-state.json` に集約。中断・再開可能 | +| 長尺PR対策 | **`--rotate-after` ラウンドで PR をローテーション**(default=light: 同ブランチで PR 巻き直し / squash: 新ブランチ + squash 統合) | +| 振動検知 | 同じ指摘が 2 round で 50%以上重複したら中断 | + +## 引数 + +| 引数 | 意味 | 既定 | +|---|---|---| +| `[PR番号]` | 対象 PR(省略時は直前 PR / 現在ブランチ) | — | +| `--max-rounds N` | 全体最大ラウンド数(PR ローテーションを含む通算) | `12` | +| `--rotate-after K` | この round 数で未収束なら PR ローテーション | `8` | +| `--rotate-mode light\|squash` | ローテーション方式。`light`: 同ブランチで旧 PR を close → 新 PR (title/body は現状の差分・実装から再生成)。`squash`: squash 統合 + 新ブランチ + `(rotated)` suffix | `light` | +| `--only codex` / `--only gemini` | 片方だけで回す(デバッグ用) | 両方 | +| `--focus TEXT` | 自動レビュー観点に上乗せして codex / gemini 両方に渡す追加観点。短い重点チェック向け | なし | +| `--extra-instructions-file PATH` | 自動レビュー観点に上乗せして codex / gemini 両方に渡す追加観点を UTF-8 テキストファイルから読む。長いチェックリスト向け | なし | + +例: + +``` +/ndf:cross-review 123 +/ndf:cross-review 123 --max-rounds 4 --rotate-after 2 +/ndf:cross-review 123 --rotate-mode squash +/ndf:cross-review 123 --only codex +/ndf:cross-review 123 --focus "ドキュメントとコードの整合性を重点的に確認" +/ndf:cross-review 123 --extra-instructions-file /tmp/review-focus.md +``` + +### 自動レビュー観点テンプレート + +`state.py init` は GitHub API の `pulls//files --paginate` で変更ファイルを全件取得して分類し、 +codex / gemini 両 launcher に同じ追加観点を渡す。`--focus` / +`--extra-instructions-file` は、この自動テンプレートの後ろに上乗せされる。 + +自動カテゴリ: + +- `common`: PR 全体の目的、変更範囲、保守性、テスト、ロールバック容易性 +- `docs_only`: ドキュメントのみ PR。企画・説明の妥当性、コード/設定/コマンド/他 docs との整合性 +- `code`: 設計、正確性、可読性、冗長・重複、言語らしさ、セキュリティ、関数/ファイルの責務とサイズ +- `db_migration`: データ設計、型、NULL/default/制約/index、既存データ、backfill、ロールバック +- `test`: テストの仕様性、境界値、失敗系、flaky リスク +- `dependency`: 依存追加/更新、lockfile、ライセンス、互換性、セキュリティ +- `config_ci`: CI/設定、権限、secret、cache、環境差分 +- `api_contract`: API 契約、互換性、schema、status、エラー形式、認可 +- `auth_security`: 認証/認可、secret/PII、CSRF/CORS/session/JWT/OAuth +- `frontend`: UI 状態、アクセシビリティ、レスポンシブ、状態管理、表示文言 +- `performance`: N+1、I/O、メモリ、ロック、cache、queue、冪等性 +- `deletion_rename`: 削除/リネーム参照漏れ、後方互換、移行手順 +- `generated`: 生成物、lockfile、再生成手順、差分ノイズ +- `i18n`: 翻訳キー、fallback、変数展開、表示幅、文言整合 +- `infra`: IaC / Docker / Kubernetes 等の権限、secret、公開範囲、ロールバック + +### `--rotate-mode` の選び方 + +- **`light` (default)**: PR を読む人 (将来のレビュアー / 後続 PR を作る人) が cross-review の存在を意識せずに済む。release branch 戦略・TODO 参照・コミット単位レビューを破壊しない。**通常はこちらを使う** +- **`squash`**: 巨大 PR を 1 commit に潰したい / `(rotated)` suffix で rotation 履歴を PR title に残したい場合のみ。release branch 戦略を使う運用とは併用しない + +## 前提 + +- `/ndf:review` が **AI 直接投稿**(外部 AI 自身が `gh api` で投稿)に対応 +- `/ndf:fix` が **サブエージェント起動 + 重要度ベース自動修正 + Resolve Conversation** に対応 +- `codex` / `gemini` CLI が動作し、`gh` CLI が認証済み +- `Agent(subagent_type="general-purpose", ...)` でサブエージェントを起動可能 + +## 事前確認(`state.py init` が自動実施) + +ループ開始前に **4 つのプリチェック** が必要だが、すべて `scripts/state.py init` +が内部で実施する。メインは結果を KEY=VALUE 形式で受け取るだけで良い。 + +| # | 対策 | スクリプト側で何をするか | +|---|---|---| +| 1 | 自分の PR 判定(422 回避) | `gh api user` と `gh pr view --json author` を比較し `is_own_pr` / `event_downgrade` を state.json に書く | +| 2 | worktree 分離 | `git worktree add /--/pr ` を冪等実行(`` は `NDF_WORKTREE_BASE` env > `<システム tmpdir>/ndf-worktrees` の優先順で解決)。パスが存在しても現リポジトリの登録済み worktree でなければ `.stale-` に退避して作り直す | +| 3 | gemini trusted directory | `launch-gemini.sh` が `GEMINI_CLI_TRUST_WORKSPACE=true` + `--skip-trust` を必ず併用。**tmp dir は `/.cross_review/`** を採用し、gemini の workspace 制約 (workspace 外の `write_file` がブロックされる) を根本回避 | +| 4 | 既存コメント差分 | `fix/scripts/fetch-pr-comments.sh` で 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を一括取得し `$TMP_DIR/cross-review-pr-existing-comments.txt` に保存。gemini プロンプトには **内容をインライン埋め込み**、codex プロンプトには path を渡す | + +### `` の解決順 + +`state.py init` は worktree の親ディレクトリを以下の優先順で解決する: + +1. `NDF_WORKTREE_BASE` 環境変数(明示オーバーライド) +2. `<システム tmpdir>/ndf-worktrees`(Python `tempfile.gettempdir()`。非永続領域のため + コンテナ再作成で自動消滅し、共有 volume を消費しない) + +worktree の実パスは `/--/pr` 形式で、リポジトリ slug を含める +ことで**他リポジトリの同一 PR 番号と衝突しない**。永続 volume(旧 `/work/worktrees`)を +使っていた頃は別プロジェクトの残骸 worktree を誤って流用する事故があったため、 +パスが存在しても `git worktree list` に登録されていなければ `.stale-` に +退避して作り直すガードも入っている。 + +解決した実パスは `state.json` の `worktree_path` に書かれるため、後続スクリプトや +サブエージェント prompt は state.json から読めば追従できる。 + +### intent / posted_as の両保持(最重要) + +GitHub は **自分の PR には `REQUEST_CHANGES` でレビューを投稿できない** +(`HTTP 422`)。state.json には **両方** を保持する: + +```json +"codex": { + "intent": "REQUEST_CHANGES", // AI の本来判定。ループ収束判定に使う + "posted_as": "COMMENT", // 422 回避でダウングレードした結果 + "comments": 5, "review_url": "..." +} +``` + +`state.py judge` は `intent` を見るので、ダウングレード投稿してもループは続行する。 + +## 全体フロー + +```mermaid +flowchart TD + Start([事前確認 / loop 開始前に 1 回だけ]):::phase --> Init["worktree 作成 + state.json 初期化
・自分の PR 判定 → event downgrade 設定
・<worktree-base>/pr<PR> を用意
・既存コメントスナップショット保存"] + Init --> Round["Round N start
current_pr = PR#"]:::phase + + Round -.並列バックグラウンド.-> Codex["/ndf:review <PR> codex
(AI が gh api で直接投稿)
body 先頭: cross-review / round N / codex / intent
→ result.json (intent + posted_as)"] + Round -.並列バックグラウンド.-> Gemini["/ndf:review <PR> gemini
--skip-trust 必須
body 先頭: cross-review / round N / gemini / intent
→ result.json (intent + posted_as)"] + + Codex --> Decide{"判定 (intent ベース)"} + Gemini --> Decide + + Decide -->|両方 APPROVE / SKIP| Approved([final = approved]):::ok + Decide -->|一方でも REQUEST_CHANGES| Fix["Agent (general-purpose)
/ndf:fix <PR> --defer-nit を worktree 内で実行
・critical/major/minor 修正 + push
・reply + resolveReviewThread
・deferred/rejected は reply のみ
→ $TMP_DIR/fix-pr<#>-result.json"] + + Fix --> Check{収束チェック} + Check -->|max-rounds 到達| MaxR([final = max_rounds]):::stop + Check -->|振動検知 50% 重複| Osc([final = oscillation]):::stop + Check -->|CI failure code-related| Err([final = error]):::stop + Check -->|"CI failure meta-only (Assignees 等)"| Round + Check -->|round_in_pr >= rotate-after| Rotate["PR rotation
light (default): 同ブランチで巻き直し
squash (opt-in): 新ブランチ + squash 統合
light は Agent が title/body 再生成"] + Check -->|それ以外| Round + Rotate --> Round + + Approved --> Sweep["最終スイープ (必須)
Agent (general-purpose)
/ndf:fix <PR> を再実行
・残 open review thread を全て確認
・修正可能な minor/nit は修正 + push
・判断保留 nit も reply + resolveReviewThread
→ open thread 0 で終了"] + MaxR --> Sweep + Osc --> Sweep + Err --> Sweep + Sweep --> Report[ラウンドサマリ報告
+ 残 deferred nit を参考として列挙] + + classDef phase fill:#eef,stroke:#557 + classDef ok fill:#dfd,stroke:#383 + classDef stop fill:#fdd,stroke:#933 +``` + +## 実行ステップ概要(メインの bash 骨組み) + +各ステップの詳細は `docs/` 参照。メインは以下のテンプレートで scripts/ を呼ぶだけ: + +```bash +PLUGIN_ROOT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}" +SCRIPTS="$PLUGIN_ROOT/skills/cross-review/scripts" + +# STATE_PR は state.json のキー (= 最初に init した PR 番号)。 +# rotation 後も state.json のパスは変わらないため、scripts/ への引数には常に +# STATE_PR を渡す。「現在レビュー中の PR」は state.json の current_pr を内部参照する。 +STATE_PR=$INITIAL_PR + +# ROTATE_MODE は引数 --rotate-mode (default=light) から取得。light なら Step 6b で +# Agent (general-purpose) を起動して新 PR の title/body を生成する。 +ROTATE_MODE=${ROTATE_MODE:-light} + +# Step 0: state 初期化 / 再開 +eval "$("$SCRIPTS/state.py" init "$STATE_PR" \ + --max-rounds "$MAX_ROUNDS" --rotate-after "$ROTATE_AFTER" \ + ${ONLY:+--only "$ONLY"} \ + ${FOCUS:+--focus "$FOCUS"} \ + ${EXTRA_INSTRUCTIONS_FILE:+--extra-instructions-file "$EXTRA_INSTRUCTIONS_FILE"})" +# eval で TMP_DIR がセットされる。後続スクリプトに env として伝播させる。 +export CROSS_REVIEW_TMP_DIR="$TMP_DIR" +cd "$WORKTREE" + +while :; do + # Step 1: round 開始判定 (max_rounds 到達で exit 1) + eval "$("$SCRIPTS/state.py" start-round "$STATE_PR")" + + # Step 2: 並列レビュー + [ "$ONLY" != "gemini" ] && "$SCRIPTS/launch-codex.sh" "$STATE_PR" "$ROUND" + [ "$ONLY" != "codex" ] && "$SCRIPTS/launch-gemini.sh" "$STATE_PR" "$ROUND" + # 監視: 既定 timeout=7 分 / stall=3 分。失敗時は対象プロセスを kill して返す。 + "$SCRIPTS/monitor.py" "$STATE_PR" "${ONLY:-both}" || handle_review_failure $? + + [ "$ONLY" != "gemini" ] && "$SCRIPTS/state.py" read-result "$STATE_PR" codex + [ "$ONLY" != "codex" ] && "$SCRIPTS/state.py" read-result "$STATE_PR" gemini + + # Step 3: 判定 (0=approved/2=continue) + if "$SCRIPTS/state.py" judge "$STATE_PR"; then break; fi + + # Step 4: 振動検知 (4=oscillation) + "$SCRIPTS/state.py" check-oscillation "$STATE_PR" || [ $? -eq 2 ] || exit 4 + + # Step 5: 修正サブエージェント起動 (Agent tool) → $TMP_DIR/fix-pr-result.json + # - メインで Agent(subagent_type=general-purpose, ...) を呼ぶ。docs/02 参照 + # - tmp パスは launcher / monitor.py と同じく **STATE_PR ベース** で統一 + # Step 5 後段: fix 戻り値マージ + CI 分類 (3=code-fail で中断) + "$SCRIPTS/state.py" merge-fix "$STATE_PR" + + # Step 6: PR ローテーション判定 (0=rotate/2=keep)。state.json の current_pr を内部更新。 + if "$SCRIPTS/state.py" should-rotate "$STATE_PR"; then + # Step 6a: 旧 PR の素材 (title/body/isDraft + git log/diff stat) を dump + eval "$("$SCRIPTS/rotate-pr.sh" prepare "$STATE_PR")" + + # Step 6b: light モードのみ。**メインセッション側で Agent(subagent_type="general-purpose") を起動して** + # prepare.json を読ませ、現状の差分・実装を反映した title/body を + # $TMP_DIR/rotate-pr-newtext.json に書き出させる。 + # 詳細プロンプトは docs/02-fix-and-rotation.md Step 6b 参照。 + # squash モードでは Step 6b 不要。 + # + # ⚠ Bash 単体では Agent ツールを呼べない。下記のフローは「メイン会話セッション側で」 + # 実行される前提なので、bash の while ループそのものは pseudo-code として読み、 + # 実際には以下の 3 段階を **メインが順に駆動する** こと: + # (1) bash 側で `rotate-pr.sh prepare $STATE_PR` を実行 (これは普通の bash) + # (2) メインが Agent(...) を起動して newtext.json を書かせる (bash の外) + # (3) bash 側で `rotate-pr.sh execute $STATE_PR --mode light` を実行 + # 下記の if exit 10 は **誤ってメイン介在なしで Step 6c に進むことを防ぐガード** であり、 + # exit 10 を観測したらメインは Step 6b の Agent を起動し、newtext.json が + # 生成されてから **同じ STATE_PR で Step 6c (execute) を直接呼び直す**。 + # state.json は完全に再開可能 (prepare.json はそのまま再利用される)。 + NEWTEXT_JSON="$TMP_DIR/rotate-pr$STATE_PR-newtext.json" + if [ "$ROTATE_MODE" = "light" ] && [ ! -s "$NEWTEXT_JSON" ]; then + echo "⏸ light モード: メインセッションで Agent(general-purpose) を起動し" >&2 + echo " $NEWTEXT_JSON を生成してから rotate-pr.sh execute $STATE_PR --mode light を実行してください" >&2 + echo " (docs/02-fix-and-rotation.md Step 6b 参照 / 再開プロトコルは下記 '## 再開プロトコル')" >&2 + exit 10 + fi + + # Step 6c: 実行。NEW_PR / NEW_PR_URL / NEW_BRANCH を eval で取り込む。 + eval "$("$SCRIPTS/rotate-pr.sh" execute "$STATE_PR" --mode "$ROTATE_MODE")" + + "$SCRIPTS/state.py" set-current-pr "$STATE_PR" "$NEW_PR" + # NOTE: STATE_PR は変えない。次ループの scripts も $STATE_PR を渡す。 + fi +done + +# Step 7.5: 最終スイープ (必須) — どの終了経路 (approved / max_rounds / oscillation / +# error) でも、ループを抜けた直後に **メインが Agent(general-purpose) を起動** し、 +# /ndf:fix $STATE_PR を再実行して残った open review thread を全て解消する。 +# ⚠ bash 単体では Agent ツールを呼べないため、while ループを抜けたらメインが +# Step 7.5 の Agent を駆動し、$TMP_DIR/sweep-pr$STATE_PR-result.json を生成させる +# (プロンプトテンプレートは docs/02-fix-and-rotation.md Step 7.5)。 +# 最終 APPROVE ラウンドで投稿された minor/nit インラインコメントはループ内 fix を +# 経由しないため、ここで拾わないと PR 上に未解決スレッドが残る。 +# sweep 結果 (sweep-pr$STATE_PR-result.json) はメインが Step 8 の報告に折り込む。 + +# Step 8: 終了処理 (deferred nit + ラウンドサマリ) +"$SCRIPTS/state.py" report "$STATE_PR" +``` + +各ステップの内容と契約(state.json / result.json スキーマ等)の詳細は: + +- Step 0〜4 — [docs/01-state-and-review.md](docs/01-state-and-review.md) +- Step 5〜8 (最終スイープ Step 7.5 含む) — [docs/02-fix-and-rotation.md](docs/02-fix-and-rotation.md) + +## light モード rotation の再開プロトコル (exit 10 を観測した時) + +bash ループは Agent tool を呼べないため、light モードでは Step 6b の介入が必須。 +メインセッションはループ全体を 1 回の bash で完結させず、以下のように駆動する: + +1. **通常のループ実行** — Step 0〜6a まで bash で進めると、newtext.json が未生成の + ため `exit 10` で停止する。state.json には prepare.json までの状態が + 永続化されているので **そのまま再開可能**。 +2. **Step 6b (Agent 起動)** — メインが Agent(subagent_type=`general-purpose`) を + 起動し、prepare.json を読ませて `$TMP_DIR/rotate-pr$STATE_PR-newtext.json` を + 書き出させる (プロンプトテンプレートは docs/02-fix-and-rotation.md Step 6b)。 +3. **Step 6c (execute) を直接呼ぶ** — メインが bash で以下を実行: + + ```bash + eval "$("$SCRIPTS/rotate-pr.sh" execute "$STATE_PR" --mode light)" + "$SCRIPTS/state.py" set-current-pr "$STATE_PR" "$NEW_PR" + ``` + +4. **ループ再開** — Step 7 (次ラウンド) からループ全体を再開する。`STATE_PR` は + 不変なので、`start-round` 以降は通常通り進む。 + +> ⚠ exit 10 はエラーではなく **メイン介入待ちの一時停止シグナル**。final ステータスには +> 反映しない (中断扱いではない)。次回 round カウントにも影響しない。 + +## レビュー出力の制約 + +**目的**: PR 上に Resolve 義務を伴うインラインコメントを増やさない。 +**修正アクションを伴わない記述は一切出さない** ことを両 launcher プロンプトで強制する。 + +### 1. body 先頭 identifier prefix(必須) + +人間アカウントから AI が投稿するため、GitHub UI 上では誰のレビューか分からない。 +body 先頭に必ず以下を入れる: + +``` +## 🤖 cross-review | round 1 | codex | REQUEST_CHANGES +``` + +書式: `## 🤖 cross-review | round | | ` + +- ``: `codex` / `gemini` のいずれか +- ``: AI の本来の判定(`REQUEST_CHANGES` / `APPROVE` / `COMMENT`) + `posted_as` ではなく `intent` を書く + +### 2. インラインコメントの最小化(最重要) + +インラインコメントは GitHub 上で **Resolve 操作が必須** になるため、本当に直すものだけ作る: + +| 重要度 | インライン化 | 説明 | +|---|---|---| +| `critical` / `major` | ✅ する | 修正必須 | +| `minor` | ✅ する | 明らかな改善のみ。判断が割れるなら出さない | +| `nit` | ❌ **出さない** | 好み・スタイルはコメント化禁止。気になっても無視する | + +**1 インラインコメント = 1 修正アクション** を厳守。 +コメント本文は `[重要度 / カテゴリ] 修正提案` の 1 文で完結させ、 +コード引用ブロック(``` ... ```)や現状説明だけのコメントは作らない。 + +### 3. body(総評)に書かないこと + +- ❌ **「良い点」/「Strengths」/「Positives」/「評価できる点」セクション** — 一切書かない +- ❌ 個別ファイル・関数の褒め言葉 +- ❌ 「特に問題ありません」「概ね良好です」等の評価文 +- ❌ 対応不要な観察コメント(「〜のようです」「〜と思われます」止まり) + +body に書くのは **設計レベル・PR 横断の修正提案** のみ。 +書くことが無ければ body は `## 🤖 cross-review ...` の prefix 行 + 1 行サマリのみで良い。 + +### 4. event 判定 + +- `APPROVE` — 修正必須の指摘なし(minor 以下しか無い場合も APPROVE で良い) +- `REQUEST_CHANGES` — critical / major の指摘あり +- `COMMENT` — **基本使わない**。雑感だけの投稿は禁止 + +## CI failure の分類(誤中断防止) + +「CI 失敗 → 即 `final=error`」は乱暴。`scripts/state.py merge-fix` が +fix 戻り値ファイル (`$TMP_DIR/fix-pr-result.json`) を受け取った際に +`ci_failed_checks` を以下で分類する: + +| 分類 | パターン | 振る舞い | +|---|---|---| +| code-fail | `pint` / `larastan` / `phpstan` / `test` / `lint` / `type` / `build` / `ruff` / `eslint` / `tsc` / `mypy` | `final=error` で中断 (exit 3) | +| meta-only | `check_pr_requirements` / `assignees` / `reviewers` / `labels` / `meta` | `ci_note` に記録して継続 | +| 不明 | 上記以外 | 保守的に **code-fail 扱い** | + +PR メタデータ系の check(Assignees / Reviewers / Labels)は **継続**、 +pint / larastan / test / build などは **中断** を原則とする。 + +## アンチパターン + +- ❌ **修正をメインセッション内で行う** — context が一気に膨れる。必ずサブエージェント +- ❌ **AI に Markdown だけ返させる** — メインがパース・投稿する設計は禁物。AI 直接投稿 +- ❌ **nit を都度ユーザに問う** — ループ中は deferred 記録のみ。最終スイープ (Step 7.5) で Resolve +- ❌ **未解決スレッドを残したまま終了する** — approved/max_rounds 等いずれの終了経路でも + Step 7.5 の最終スイープを必ず実行し、open review thread 0 で終える。特に **最終 APPROVE + ラウンドの minor/nit インラインコメント**はループ内 fix を通らないため取りこぼしやすい +- ❌ **`max-rounds` なしで回す** — 無限ループの温床 +- ❌ **PR ローテーションを忘れる** — 100+ コメントの巨大 PR になる +- ❌ **light モードで Agent (general-purpose) 呼び出しを省略する** — newtext.json が無いと `rotate-pr.sh execute --mode light` はエラーで止まる。prepare → Agent → execute の 3 段は不可分 +- ❌ **light モードで新 PR の title/body に内部用語を漏らす** — 「round N」「rotated」「cross-review」「レビュー指摘で〜」等は禁止 (Agent プロンプトで明示禁止) +- ❌ **newtext.json に旧 PR の title/body をそのままコピーする** — 「現状の差分・実装を反映」が必須。古い説明が残ると後続 PR / 将来のレビュアーが混乱 +- ❌ **`rotate-pr.sh` 内から `claude` CLI を呼んで title/body を生成する** — 環境依存・コスト管理外。Agent tool でメイン側から呼ぶ +- ❌ **CI 失敗を一律で中断** — コード関連/メタチェックを分類(上記参照) +- ❌ **自分の PR に `REQUEST_CHANGES` で投稿** — 必ず 422。事前判定 + COMMENT ダウングレード +- ❌ **`gemini --yolo` だけで起動** — trusted directory で YOLO 無効化。`--skip-trust` 併用 +- ❌ **`pgrep -fa ` で完了判定** — gemini は long prompt が引数に乗り検知失敗。pidfile 必須 +- ❌ **sentinel 単独で完了判定** — codex がクラッシュすると永遠に出ない。`monitor.py` の多軸判定 (pidfile / sentinel / 早期エラー / stall / hard timeout / result.json) を使うこと +- ❌ **タイムアウトなしで wait** — ハング検知不能。`monitor.py` の hard timeout (30 分既定) + stall timeout (10 分既定) を必ず効かせる +- ❌ **EARLY_ERROR の曖昧パターンで kill する** — 行頭の生 `Error:` / `Traceback` は codex がレビュー対象 diff の test コード片を echo するケースで誤検知する。明確な致命 (auth / quota / sandbox / HTTP 401-403-429 / gemini の YOLO 降格) **のみ** kill 対象とし、曖昧パターンは警告ログに留める。誤検知が再発する場合は `--no-early-error` / `MONITOR_NO_EARLY_ERROR=1` で検知自体を無効化する (sentinel / result.json / timeout で十分判定可能) + +## monitor.py が誤って kill する場合の手順 + +`monitor.py` が EARLY_ERROR で codex / gemini を即時 kill してしまい、`result.json` が +生成されないケースは以下で切り分け・回避できる: + +1. **err.log の冒頭を確認**: 検知パターン (`fatal_err` の `early error (fatal) in err.log: ...`) が + 本当に致命なのか、それとも diff body の echo / config validation 警告なのかを判別 + - **v4.11.0 で benign 自動判定を強化**: `_match_is_quoted()` が backtick / 「」 に加え + **ダブル/シングルクォート文字列リテラル** (`"quota exceeded: ..."`) を、`EARLY_ERROR_BENIGN` + が **grep 形式のソース引用行** (`path/to/file.py:22: `) を自動で benign 扱いする。 + codex が tests/*.py 等のテスト用文字列 (`"quota exceeded"`, `"sandbox error"`) を + レビュー中に echo しても誤 kill しなくなった(旧版で PR #23 round 2 に発生した事例) +2. **gemini の `Error in: mcpServers.` 警告**: `.gemini/settings.json` に `disabled: false` + 等の非互換キーがあると毎回出る。`launch-gemini.sh` の sanitize ロジック (v4.7.2+) で + 自動退避するため、最新版にアップデートすれば解消する +3. **誤検知が継続する場合**: `monitor.py --no-early-error` (もしくは `MONITOR_NO_EARLY_ERROR=1` + 環境変数) で EARLY_ERROR 検知自体を無効化し、hard timeout / stall / sentinel / result.json + のみで判定するモードに切り替える +4. **新しい致命パターンを観測した場合**: `EARLY_ERROR_FATAL` に追記する (PR で plugin に反映)。 + 曖昧パターンは `EARLY_ERROR_WARN` 側に置き、kill 対象にはしない +- ❌ **fix サブエージェントが Resolve をスキップ** — reply だけでは未対応扱い。Resolve まで実行 +- ❌ **review body に identifier prefix を付け忘れる** — GitHub UI 上で誰のレビューか不明になる + +## メイン context 節約の工夫 + +1. **大きいファイルはメイン context に載せない**: payload / err.log / diff は + すべて `$TMP_DIR/` (= `state.py _tmp_dir()` の解決先) に置き、メインは + state.json と result.json だけ読む +2. **サブエージェント分離**: 修正は別 context window で実行 +3. **PR ローテーション**: 1 PR あたりの会話履歴を抑える +4. **AI 直接投稿**: 中間ペイロードがメインを通らない +5. **state.json で再開可能**: メインが落ちても次回起動時に続きから + +## 作業完了報告(必須) + +ループ終了後、メインからユーザへの報告: + +- **最終ステータス**: `approved` / `max_rounds` / `oscillation` / `error` +- **総ラウンド数 / PR 数**: 例: `5 rounds / 2 PRs (rotated 1 回)` +- **PR 履歴**: 各 PR 番号 + closed/open 状態 + round 数 +- **各ラウンドのサマリ表**: + + | round | PR | codex | gemini | fix | CI | + |---|---|---|---|---|---| + | 1 | #123 | REQ (5) | REQ (3) | abc123 (5 fixed, 2 deferred) | ✅ | + | 2 | #123 | REQ (2) | APP | def456 (2 fixed) | ✅ | + | 3 | #145 | APP | APP | — | — | + +- **最終スイープ結果** (Step 7.5): `sweep-pr-result.json` の `resolved` / + `fixed_in_sweep` / `remaining_open`。**`remaining_open` は 0 が正常**(残 open + thread あり = 取りこぼし)。0 にできなかった場合は理由を明記 +- **残 deferred nit リスト**(Step 7.5 で Resolve 済み。再対応が要るものがあれば参考列挙) +- **rejected 件数**(bot 誤指摘で却下したもの) +- **最終 PR URL** + +詳細は PR 上のインラインコメントと state.json に残っているため、本報告では +繰り返さない。 + +## 関連 + +- `/ndf:review` — 単発レビュー(AI 直接投稿対応) +- `/ndf:fix` — 修正対応(サブエージェント起動対応) +- `/ndf:codex` — codex CLI 呼び出し手順 +- `/ndf:gemini` — gemini CLI 呼び出し手順 +- `/ndf:resolve-pr-comments` — Resolve Conversation の詳細 +- `general-purpose` エージェント — fix 実行用サブエージェント diff --git a/plugins/ndf-shared/skills/cross-review/docs/01-state-and-review.md b/plugins/ndf-shared/skills/cross-review/docs/01-state-and-review.md new file mode 100644 index 0000000..9f858ca --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/docs/01-state-and-review.md @@ -0,0 +1,254 @@ +# 01: 状態管理 + レビュー実行 (Step 0〜4) + +`SKILL.md` 本体から呼び出される **状態ファイル初期化 / ラウンド開始 / +並列レビュー / 判定 / 振動検知** までの詳細手順。 + +主要処理は `scripts/` 配下のコマンドに切り出し済み: + +| script | 役割 | +|---|---| +| `scripts/state.py init` | Step 0 — state 初期化 / 再開 + プリチェック | +| `scripts/state.py start-round` | Step 1 — round 開始判定 | +| `scripts/launch-codex.sh` / `scripts/launch-gemini.sh` | Step 2 — review launcher | +| `scripts/monitor.py` | Step 2 — codex/gemini プロセス多軸監視 | +| `scripts/wait-review.sh` | Step 2 — `monitor.py` の薄ラッパ(互換用) | +| `scripts/state.py read-result` | Step 2.5 — result.json マージ | +| `scripts/state.py judge` | Step 3 — intent ベース pass 判定 | +| `scripts/state.py check-oscillation` | Step 4 — 振動検知 | + +このドキュメントは **state.json スキーマと AI への入出力契約** を一次資料として残す。 +スクリプト側の挙動はソースを直接参照のこと。 + +## 状態ファイル + +`$TMP_DIR/cross-review-pr<番号>-state.json`: + +```json +{ + "started_at": "2026-05-12T...", + "max_rounds": 12, + "rotate_after": 8, + "only": null, + "current_pr": 123, + "worktree_path": "/tmp/ndf-worktrees/owner--name/pr123", + "repo": "owner/name", + "head_branch": "feature/foo", + "base_branch": "main", + "pr_author": "someone", + "is_own_pr": false, + "event_downgrade": false, + "pr_history": [ + {"pr": 123, "opened_at": "...", "closed_at": null, "rounds": 2} + ], + "rounds": [ + { + "round": 1, + "pr": 123, + "started_at": "...", + "codex": {"intent": "REQUEST_CHANGES", "posted_as": "COMMENT", + "comments": 5, "review_url": "...", + "by_severity": {"critical": 0, "major": 3, "minor": 2, "nit": 0}}, + "gemini": {"intent": "REQUEST_CHANGES", "posted_as": "COMMENT", + "comments": 3, "review_url": "...", + "by_severity": {"critical": 0, "major": 2, "minor": 1, "nit": 0}}, + "fix": {"commit": "abc1234", "fixed": 6, "deferred": 2, "rejected": 0, + "resolved_threads": 4, "ci": "SUCCESS", "ci_note": null}, + "ended_at": "..." + } + ], + "deferred_nits": [ + {"pr": 123, "round": 1, "path": "src/foo.py", "line": 42, "severity": "nit", + "summary": "...", "comment_url": "..."} + ], + "final": null +} +``` + +`final` 値: `approved` / `max_rounds` / `oscillation` / `error` + +### 重要なフィールド + +- `worktree_path` — 並行セッションとの分離。サブエージェントへの cwd 指示にも使う +- `is_own_pr` / `event_downgrade` — 自分の PR の場合 `REQUEST_CHANGES → COMMENT` 強制ダウングレード +- `rounds[].codex.intent` — AI の本来判定。**ループ判定はこれを見る** +- `rounds[].codex.posted_as` — GitHub に実際に送った event。`is_own_pr=true` なら `COMMENT` になる +- `rounds[].fix.resolved_threads` — `resolveReviewThread` で resolve した**件数(int)**。 + ここ (state.json 側) は int だが、fix サブエージェントが返す戻り値ファイル + (`fix-pr-result.json`) 側の `resolved_threads` は **list**(`docs/02` の戻り値スキーマ参照)。 + `state.py merge-fix` が fix結果の list を `len()` して state.json に int で保存する。 + 混同して fix結果側に int を書くと過去 `merge-fix` が落ちていたため、現在は int/list 両受理 +- `rounds[].fix.ci_note` — コード無関係の CI 失敗時に「Assignees 未設定」等の理由を残す + +## Step 0: 準備 + 既存 state 引き継ぎ + +```bash +PLUGIN_ROOT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}" +SCRIPTS="$PLUGIN_ROOT/skills/cross-review/scripts" # or 直接の絶対パス + +# state 初期化 / 再開(プリチェック・worktree 作成・既存コメントスナップショットを内部実行) +eval "$("$SCRIPTS/state.py" init "$STATE_PR" \ + --max-rounds "$MAX_ROUNDS" --rotate-after "$ROTATE_AFTER" \ + ${ONLY:+--only "$ONLY"} \ + ${FOCUS:+--focus "$FOCUS"} \ + ${EXTRA_INSTRUCTIONS_FILE:+--extra-instructions-file "$EXTRA_INSTRUCTIONS_FILE"})" + +# eval で取り込まれる変数: PR, WORKTREE, REPO, HEAD_BRANCH, BASE_BRANCH, +# IS_OWN_PR, EVENT_DOWNGRADE, RESUMED +cd "$WORKTREE" +``` + +`state.py init` が内部で行う処理: + +1. 既存 state.json があり `final == null` なら再開 +2. 自分の PR 判定(`gh api user` と `gh pr view --json author` を比較) +3. worktree 作成(`/--/pr`。`` は `NDF_WORKTREE_BASE` env > `<システム tmpdir>/ndf-worktrees` の優先順で解決。既存パスが現リポジトリの登録済み worktree でなければ `.stale-` に退避して作り直す。実 path は state.json の `worktree_path` を参照) +4. 既存コメントスナップショット (`fix/scripts/fetch-pr-comments.sh` で 3 ソース一括取得) → `$TMP_DIR/cross-review-pr-existing-comments.txt` +5. state.json 書き出し + +**重要**: 以降の全ステップで `cd $WORKTREE` を強制。 +サブエージェント(fix)を起動するときも、prompt 内で worktree path を明示する。 + +## Step 1: Round 開始判定 + +```bash +eval "$("$SCRIPTS/state.py" start-round "$STATE_PR")" +# eval で取り込まれる変数: ROUND, ROUND_IN_PR, PR, MAX_ROUNDS, ROTATE_AFTER +``` + +`state.py start-round` は `max_rounds` 超過なら `final=max_rounds` を書いて exit 1。 +それ以外は新しい round エントリを state.rounds に push して KEY=VALUE を吐く。 + +## Step 2: codex / gemini 並列レビュー(AI 直接投稿) + +**要点**: メインは launcher を **並列バックグラウンド** で起動するだけ。 +各 AI が `gh api` で投稿し `$TMP_DIR/-review-pr-result.json` に +サマリを書く。**ペイロード本体はメイン context に載せない**。 + +### 2.1 launcher 起動 + monitor + +```bash +[ "$ONLY" != "gemini" ] && "$SCRIPTS/launch-codex.sh" "$STATE_PR" "$ROUND" +[ "$ONLY" != "codex" ] && "$SCRIPTS/launch-gemini.sh" "$STATE_PR" "$ROUND" + +# monitor.py が多軸で完了判定。exit code で失敗種別を分岐。 +if ! "$SCRIPTS/monitor.py" "$STATE_PR" "${ONLY:-both}"; then + case $? in + 2) echo "❌ timeout" ;; # hard timeout 超過 + 3) echo "❌ no result" ;; # プロセス終了したが result.json 未生成 + 4) echo "💥 early error" ;; # err.log に致命的パターン + 5) echo "🛑 stalled" ;; # 進捗ログ更新なし + 6) echo "❓ pidfile bad" ;; # 起動失敗 / 不正 + esac + # ラウンドを失敗マークしてリトライ or 中断(state.py side で判断) +fi +``` + +#### `monitor.py` の多軸監視 + +| 軸 | 内容 | +|---|---| +| pidfile + `kill -0` | プロセス生存確認。alive 確認後に `/proc//cmdline` で agent 名一致も検証 (PID 再利用対策)。**プロセスが既に死んでいる場合は result.json の有無のみで OK 判定**する (死亡直後 cmdline 不一致で誤検知しないため) | +| codex sentinel | err.log に `^tokens used$` 出現で正常完了マーク | +| early-error | **行頭限定** で `^Error:` / `^FATAL:` / `^panic:` / `^Traceback ` / `^HTTP/1.1 401\|403\|429` / `^Approval mode overridden to "default"` / `^Authentication failed` / 「quota exceeded」「rate limit exceeded」「API key not found/missing/invalid」「sandbox error」を含む行を検出 (diff/doc 引用文中の同語句は誤検知しないよう anchor + benign フィルタ併用) | +| stall timeout | err.log + stdout.log + progress.log の合計サイズが一定時間変化しなければ STALLED で中断。既定は **agent 別** (codex=**180s** / gemini=**480s**)。gemini は err.log がほぼ無音のため大きめに取る。codex 側既定は不変。上書き方法: CLI `--stall-timeout` (明示優先) > env `MONITOR_STALL_` (per-agent) > env `MONITOR_STALL` (両 agent 共通) > agent 別ビルトイン | +| hard timeout | 既定 **7 分**。`--timeout` or `MONITOR_TIMEOUT` env で上書き | +| progress.log heartbeat | launcher が任意で `-review-pr-progress.log` への短いフェーズマーカー出力を要求し、monitor が最終行を stderr の heartbeat に表示する。内部推論ではなく `scan` / `analyze` / `post` / `done` などの監視用ステータスだけを出す | +| result.json 存在 | プロセス終了後、result.json が無ければ NO_RESULT (exit 3) | +| **result.json + age fallback** | sentinel を持たない agent (gemini) 向け。プロセスが alive のまま result.json の mtime が **30 秒以上前**なら完了とみなし kill → OK。gemini が MCP 切断待ち等でハングするケースに対応 (codex は sentinel チェックが先に発火するため影響なし) | +| **失敗時 kill** | TIMEOUT / STALLED / EARLY_ERROR / PIDFILE_BAD で返るときは対象プロセスに SIGTERM → 3 秒後 SIGKILL。残存プロセスが後から `gh api` 投稿や result.json 書き込みを行うのを防ぐ | + +> ⚠ **罠**: `nohup ... &` でラッパーシェルは即終了し、ハーネスから +> 「タスク完了」通知が飛んでくる。これに惑わされず、`monitor.py` で +> 実プロセスの完了を pidfile / sentinel で確認すること。 +> +> ⚠ **`pgrep -fa ` で完了判定しない**: gemini は long `-p` プロンプトを +> 引数に持つため、`grep` のキーワード選定で誤検知する。**pidfile 必須**。 +> +> ⚠ **sentinel 単独で完了判定しない**: codex がクラッシュすると `tokens used` が +> 永遠に出ない。`monitor.py` は sentinel と pidfile/result.json/err.log を併用する。 +> +> ⚠ **Docker 環境ではゾンビプロセスに注意**: `nohup ... & disown` で起動した +> プロセスは、終了後にゾンビ化する (PID 1 が proper init でない場合)。 +> `monitor.py` は `/proc//status` でゾンビを検出して dead 扱いする。 +> **推奨: Docker 実行時に `--init` フラグを付ける** (tini が PID 1 になりゾンビを reap する)。 + +### 2.2 AI への入出力契約(両 launcher 共通) + +launcher が生成するプロンプトに以下を強制している: + +- **headRefOid (commit_id) を明示**: AI が自前で取得すると baseRefOid を誤って入れる事故が多発 +- **作業 worktree の絶対パス**: 「ファイル読み取りは必ず worktree 配下の絶対パスを使う」(実 path は state.json の `worktree_path` を参照。`` は `NDF_WORKTREE_BASE` env > `<システム tmpdir>/ndf-worktrees` の優先順で解決) +- **event ダウングレード警告**: `event_downgrade=true` のときは payload の `event` を `COMMENT` に +- **既存コメント差分**: `$TMP_DIR/cross-review-pr-existing-comments.txt` を読んで重複指摘禁止 +- **自動レビュー観点**: GitHub API の `pulls//files --paginate` で変更ファイルを全件取得して分類し、`common` / `docs_only` / `code` / `db_migration` / `test` / `dependency` / `config_ci` / `api_contract` / `auth_security` / `frontend` / `performance` / `deletion_rename` / `generated` / `i18n` / `infra` の該当テンプレートを state.json の `auto_review_instructions` に保存する +- **手動追加レビュー観点**: `--focus` / `--extra-instructions-file` が指定されていれば state.json の `manual_extra_review_instructions` に保存し、自動テンプレートの後ろに連結した `review_instructions` を codex / gemini 両 launcher が同じ「追加レビュー観点」セクションとしてプロンプトに差し込む +- **進捗マーカー**: Gemini には `$TMP_DIR/gemini-review-pr-progress.log` へ短いフェーズ名を追記させ、monitor の heartbeat で表示する。内部推論や長文説明は書かせない +- **review body 先頭 prefix**: + ``` + ## 🤖 cross-review | round | | + ``` + `` は **本来の intent**(`posted_as` ではない)。 + 例: 自分PR で REQUEST_CHANGES を COMMENT にダウングロードしても、prefix は `REQUEST_CHANGES` のまま。 +- **出力禁止事項**(SKILL.md「レビュー出力の制約」と一致): + - 「良い点」「Strengths」などの褒めセクションを body に書かない + - 修正アクションを伴わないインラインコメントは作らない(nit はインライン化しない) + - コード引用のみで指摘内容が無いコメント禁止 + - 雑感だけの `event=COMMENT` 投稿禁止(直すべき点が無ければ `APPROVE`) + +### 2.3 AI が書き出すファイル契約 + +各 launcher は AI に以下 2 ファイルの書き出しを指示する: + +| ファイル | 内容 | +|---|---| +| `$TMP_DIR/-review-pr-result.json` | `{event, posted_as, comments_count, review_url, by_severity}` のサマリ | +| `$TMP_DIR/-review-pr-round-payload.json` | `{comments: [{path, line, body, severity}, ...]}` 振動検知用 | + +`/ndf:review` の result.json 出力規約に `posted_as` フィールドを含むこと +(自分PR ダウングレード時に GitHub に実際送った event。デフォルトは `event` と同値)。 + +### 2.4 result.json を state にマージ + +```bash +[ "$ONLY" != "gemini" ] && "$SCRIPTS/state.py" read-result "$STATE_PR" codex +[ "$ONLY" != "codex" ] && "$SCRIPTS/state.py" read-result "$STATE_PR" gemini +``` + +`state.rounds[-1].` に `intent / posted_as / comments / review_url / by_severity` を分離保存する。 + +## Step 3: 判定(intent ベース) + +```bash +if "$SCRIPTS/state.py" judge "$STATE_PR"; then + : # exit 0 = approved。ループ終了。 +elif [ $? -eq 2 ]; then + : # exit 2 = continue → Step 5 (fix) +else + exit 1 +fi +``` + +**判定ロジック**: + +- `APPROVE` / `SKIP` は pass +- `COMMENT` は `by_severity.critical == 0 && major == 0` のみ pass(軽微な指摘のみなら通す) +- `--only` 指定時は反対側を SKIP 扱い +- ループ収束判定は **必ず `intent`** を見る(`posted_as` ではない) + +自分の PR で `REQUEST_CHANGES → COMMENT` にダウングレード投稿していても、 +intent が `REQUEST_CHANGES` なら継続する。 + +## Step 4: 振動検知 + +```bash +if "$SCRIPTS/state.py" check-oscillation "$STATE_PR"; then + : # ここには来ない(成功は exit 2 = continue) +elif [ $? -eq 4 ]; then + exit 4 # final=oscillation で中断 +fi +``` + +各ラウンドの `$TMP_DIR/-review-pr-round-payload.json` から +`path:line` を抽出し、前ラウンドとの重複率を計算。**50% 以上重複で中断**。 + +PR ローテーション直後 (`round_in_pr < 2`) はスキップ。 diff --git a/plugins/ndf-shared/skills/cross-review/docs/02-fix-and-rotation.md b/plugins/ndf-shared/skills/cross-review/docs/02-fix-and-rotation.md new file mode 100644 index 0000000..aaaecc3 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/docs/02-fix-and-rotation.md @@ -0,0 +1,384 @@ +# 02: 修正 (Step 5) + PR ローテーション (Step 6) + 終了処理 (Step 8) + +主要処理は `scripts/` 配下に切り出し済み: + +| script | 役割 | +|---|---| +| (Agent) | Step 5 — 修正サブエージェント起動(メインからの責務) | +| `scripts/state.py merge-fix` | Step 5 後段 — fix 戻り値マージ + CI 分類 | +| `scripts/state.py should-rotate` | Step 6 — rotate 要否判定 | +| `scripts/rotate-pr.sh prepare` | Step 6a — 旧 PR の素材を `rotate-pr-prepare.json` に dump | +| (Agent) | Step 6b — light モードのみ。新 PR の title/body を再生成して `rotate-pr-newtext.json` に書き出し | +| `scripts/rotate-pr.sh execute` | Step 6c — 旧 PR close + 新 PR 作成 (light は同ブランチ / squash は新ブランチ) | +| `scripts/state.py set-current-pr` | Step 6 — rotation 後の state 更新 | +| `scripts/state.py report` | Step 8 — deferred nit + ラウンドサマリ | + +## Step 5: 修正 — **必ずサブエージェント経由** + +**メインセッションでは修正コードを書かない。** `/ndf:fix` を +`general-purpose` サブエージェントで起動する。 + +**サブエージェントの責務(必須 6 点)**: + +1. critical / major / minor の修正コミット +2. 修正テストの追加・実行 +3. 修正対象の thread に **reply 投稿** + **`resolveReviewThread` で Resolve** +4. nit / 判断が割れる minor は **修正せず deferred 記録**(reply は `[deferred / nit]` ラベル付き、Resolve しない) +5. **PR レベルの Summary コメントを `gh pr comment` で投稿**(対応件数 / 重要度別 / deferred 件数 / rejected 件数 / commit SHA を含む) +6. 戻り値ファイル `$TMP_DIR/fix-pr-result.json` を必ず書き出す + (`$TMP_DIR` は env `CROSS_REVIEW_TMP_DIR` > `/.cross_review/` の順で解決。 + 詳細は `scripts/state.py _tmp_dir()` 参照。`/tmp/` 直書きでも `state.py merge-fix` は legacy fallback で拾う) + +> ⚠ inline thread への reply + Resolve **だけでは不十分**。PR ページの +> conversation タブに表示される **PR レベルコメント** がレビュアーへの +> サマリ通知として必須(`/ndf:fix` SKILL.md の手順 7 で規定)。 +> サブエージェント起動プロンプトでも明示的に指示すること。 + +### サブエージェント起動例 + +```python +Agent( + subagent_type="general-purpose", + description=f"Fix PR #{PR} (round {ROUND})", + prompt=f""" +/ndf:fix {PR} --defer-nit を実行してください。 + +**作業ディレクトリ厳守**: cd {WORKTREE_PATH} で作業すること。 +別セッションが /work/ 側で並行作業している可能性があり、 +worktree 外を触ると競合します。 + +## コンテキスト +- リポジトリ: {OWNER_REPO} +- PR: #{PR} (round {ROUND_IN_PR}/{ROTATE_AFTER}) +- worktree: {WORKTREE_PATH} +- ブランチ: {HEAD_BRANCH} +- ベース: {BASE_BRANCH} +- headRefOid: {HEAD_OID} +- 前ラウンドのレビュー結果: + - codex review: {CODEX_REVIEW_URL} + (intent={CODEX_INTENT}, posted_as={CODEX_POSTED_AS}, {CODEX_COMMENT_COUNT}件) + - gemini review: {GEMINI_REVIEW_URL} + (intent={GEMINI_INTENT}, posted_as={GEMINI_POSTED_AS}, {GEMINI_COMMENT_COUNT}件) +- 既存コメントスナップショット: $TMP_DIR/cross-review-pr{PR}-existing-comments.txt + +## ポリシー +- 重要度ラベルは **AI agent の付与を鵜呑みにせず**、コードを読んで独自に再判定する +- critical / major は自動修正 +- minor / nit のうち **パフォーマンス・可読性・重複コード排除** に該当するものは修正 + (特にトータル行数が減る方向は積極実施 / +30 行を超えそうなら deferred + ユーザ問い合わせ) +- それ以外の nit は deferred として記録のみ(修正しない、Resolve しない) +- bot 指摘が誤読していたら修正せず reply で説明(rejected として記録、Resolve しない) +- **重複指摘(codex/gemini が同じ箇所を別 thread で指摘)は全 thread に reply + Resolve** +- PR テスト範囲外の **flaky テストも見つけ次第このループで修正**(放置はリポジトリ品質を劣化させる) + +## 必須実行手順(順序厳守) + +1. PR コメント取得 (3 ソース): `fix/scripts/fetch-pr-comments.sh {OWNER_REPO} {PR}` でインラインコメント / レビュー body / PR レベルコメントを一括取得 +2. 重要度を独自再判定(AI agent のラベルは参考値) +3. CI 状態スナップショット: `gh pr checks {PR} --json name,state` (**完了待ちはしない**、PENDING は無視して FAILURE のみ修正対象に取り込む) +4. critical/major + 該当 minor/nit の修正コミット(worktree 内のみ) +5. `./pint-changed.sh && ./larastan-changed.sh` 等の品質チェック +6. push: `git push origin {HEAD_BRANCH}` (--force / --no-verify 禁止) +7. **CI 再実行は待たない**(push 後の `--watch` 等は行わない、`ci_status` は push 時点での既知失敗のみ反映) +8. **各 thread に reply 投稿**: + - 修正済み: 「対応しました — <ファイル>:<行> で〇〇 (commit )」 + - deferred: 「[deferred / nit] 後続 PR で対応予定」 + - rejected: 「bot 指摘は誤読です — 理由: ...」 +9. **修正済み thread を `resolveReviewThread` で Resolve**: + ```bash + # thread_id は GraphQL で取得 + gh api graphql -f query=' + query {{ repository(owner:"...", name:"...") {{ + pullRequest(number: {PR}) {{ reviewThreads(first:100) {{ + nodes {{ id isResolved path line }} + }} }} + }} }}' + # 修正済みのみ resolve + gh api graphql -f query=' + mutation($id: ID!) {{ + resolveReviewThread(input: {{threadId: $id}}) {{ thread {{ isResolved }} }} + }}' -f id="$THREAD_ID" + ``` + - deferred / rejected の thread は **Resolve しない** +10. **PR レベル Summary コメントを投稿**(必須・inline reply とは別物): + ```bash + gh pr comment {PR} --body "$(cat <<'EOMD' + ## 🔧 /ndf:fix サマリ (round N) + + 対応件数: critical=X / major=Y / minor=Z (合計 N 件) + deferred: D 件 / rejected: R 件 + commit: + CI: SUCCESS | FAILURE | NONE + + ### 詳細 + - 各 thread の対応概要(行リンク付き) + EOMD + )" + ``` + - inline reply + Resolve だけでは「PR ページの Conversation タブ」に + まとめが出ず、レビュアー視点で見落とされる。**必ず投稿する** +11. 戻り値ファイル書き出し(下記フォーマット)。`summary_comment_url` には + 手順 10 の URL を入れる + +## 戻り値ファイル $TMP_DIR/fix-pr{PR}-result.json + +```json +{{ + "pr": {PR}, + "fix_commit": "abc1234", + "ci_status": "SUCCESS" | "FAILURE" | "PENDING", + "ci_failed_checks": [], + "fixed_count": 6, + "by_severity": {{"critical": 0, "major": 4, "minor": 2, "nit": 0}}, + "resolved_threads": [ + {{"thread_id": "PRRT_...", "comment_id": 123, "path": "...", "line": 42}} + ], + "deferred": [ + {{"thread_id": "...", "path": "...", "line": 31, "severity": "nit", + "summary": "...", "comment_url": "..."}} + ], + "rejected": [ + {{"thread_id": "...", "summary": "...", "reason_for_rejection": "..."}} + ] +}} +``` + +> **重要**: `resolved_threads` / `deferred` / `rejected` は必ず **list(配列)** で返すこと。 +> **件数(int) を書かないこと**(例: `"resolved_threads": 3` は誤り。`[]` 形式で返す)。 +> `state.py merge-fix` がこの list を `len()` して state.json には件数(int)で保存する。 +> int を書くと過去 `merge-fix` が `TypeError` で落ちていた(現在は後方互換で int も受理するが、 +> 正は list)。対応した thread が無い場合は空配列 `[]` を返す。 +""", +) +``` + +### Step 5 後段: fix 戻り値マージ + CI 分類 + +```bash +if "$SCRIPTS/state.py" merge-fix "$STATE_PR"; then + : # exit 0 = continue +elif [ $? -eq 3 ]; then + exit 3 # final=error(コード関連 CI 失敗 or fix 戻り値ファイル欠落) +fi +``` + +`state.py merge-fix` が内部で行う処理: + +1. `$TMP_DIR/fix-pr-result.json` を読んで `state.rounds[-1].fix` にマージ +2. `deferred` を `state.deferred_nits` に追記 +3. **CI 失敗の分類**: + - code-fail (`pint` / `larastan` / `phpstan` / `test` / `lint` / `type` / `build` / `ruff` / `eslint` / `tsc` / `mypy`): `final=error` で中断 (exit 3) + - meta-only (`check_pr_requirements` / `assignees` / `reviewers` / `labels` / `meta`): `ci_note` に記録して継続 + - 不明: 保守的に code-fail 扱い + +**例**: `check_pr_requirements`(Assignees 未設定)はループ継続、 +`laravel/pint` や `phpstan` の失敗は即中断してユーザ判断。 + +## Step 6: PR ローテーション (prepare → Agent → execute の 3 段) + +`rotate-pr.sh` は **light モード (default) と squash モード (opt-in)** を持つ。 +両者ともメインからは `prepare → (light のみ Agent) → execute` の 3 段で呼ぶ。 + +```bash +if "$SCRIPTS/state.py" should-rotate "$STATE_PR"; then + # Step 6a: 旧 PR の素材 dump (title / body / isDraft / git log / git diff --stat) + eval "$("$SCRIPTS/rotate-pr.sh" prepare "$STATE_PR")" + + # Step 6b: light モードのみ。Agent(subagent_type="general-purpose") で + # 現状の差分・実装を反映した新 title/body を生成し、 + # $TMP_DIR/rotate-pr-newtext.json に書き出させる。 + # (squash モードでは Step 6b は不要) + + # Step 6c: 実行 (NEW_PR / NEW_PR_URL / NEW_BRANCH を取り込む) + eval "$("$SCRIPTS/rotate-pr.sh" execute "$STATE_PR" --mode "$ROTATE_MODE")" + + "$SCRIPTS/state.py" set-current-pr "$STATE_PR" "$NEW_PR" + # NOTE: STATE_PR は **絶対に変えない**。次ループの scripts も $STATE_PR で呼ぶ。 +fi +``` + +`should-rotate` は `round_in_pr >= rotate_after && total_rounds < max_rounds` で +exit 0 を返す(rotate 要)。それ以外は exit 2(keep)。 + +### Step 6a: `rotate-pr.sh prepare ` + +state.json から旧 PR / worktree を解決し、以下の素材を 1 つの JSON に dump する: + +```json +{ + "state_pr": 217, + "old_pr": 217, + "old_pr_url": "https://github.com/.../pull/217", + "worktree_path": "/tmp/ndf-worktrees/owner--name/pr217", + "head_branch": "feature/...", + "base_branch": "release/...", + "is_draft": true, + "round_in_pr": 5, + "old_title": "...", + "old_body": "...", + "git_log": "abc1234 メッセージ\n...", + "git_diff_stat": " path/to/file | 12 +-\n ..." +} +``` + +ファイル: `$TMP_DIR/rotate-pr-prepare.json`。 +stdout にも `OLD_PR=` / `HEAD_BRANCH=` / `BASE_BRANCH=` / `IS_DRAFT=` / `PREPARE_JSON=` を出すので +`eval` で取り込める。 + +### Step 6b: Agent (general-purpose) で新 title/body を生成 (light モードのみ) + +メインセッションから以下のように Agent を起動する。プロンプトは +**書いて良いこと / 禁止事項** を必ず明示する +(外側の prompt フェンスは内側に ```json を含むため 4 連バッククォートで囲む): + +````python +Agent( + subagent_type="general-purpose", + description=f"Generate light-rotation PR text for PR #{OLD_PR}", + prompt=f""" +PR rotation の light モードで作成する新 PR の title / body を生成してください。 + +## 素材 +- prepare.json: $TMP_DIR/rotate-pr{STATE_PR}-prepare.json + - 元 PR の title / body / git log $BASE..HEAD / git diff --stat +- 必要なら worktree 内のファイルを直接読んで実装内容を確認してよい + (worktree: {WORKTREE_PATH}) + +## 出力ファイル +$TMP_DIR/rotate-pr{STATE_PR}-newtext.json に JSON で書き出してください: + +```json +{{ + "title": "新 PR の title (元 PR の title をそのままコピーしない。現状の実装を反映)", + "body": "新 PR の body (Markdown)。以下のセクションを含む:\\n## 何のために\\n## 何を\\n## Test plan" +}} +``` + +## 書いて **良い** こと +- 何のために (背景・動機) — 元 PR の背景セクションは再利用可 +- 何を (変更内容) — 現在のブランチの実態を git log / git diff から反映 +- Test plan — 元 PR から継承可 + +## 書いて **はいけない** こと (内部用語の漏洩防止) +- 「round N で〜」「cross-review で〜」「レビュー指摘で〜」 +- 「(rotated)」のような automated suffix +- 「fix された問題」の列挙 / レビューサイクルの存在自体への言及 +- 「旧 PR」「巻き直し」等の rotation 内部用語 + +PR を読む人は cross-review の存在を意識しないため、最終 PR を初めて見る読者向けに +書く。元 title / body をそのままコピーするのは **禁止** (現状の実装を反映)。 +""", +) +```` + +> ⚠ `rotate-pr.sh` 内部から `claude` / `codex` / `gemini` CLI を直接呼んで生成 +> させてはならない (環境依存・コスト管理外)。**メイン側の Agent tool で行う**。 + +### Step 6c: `rotate-pr.sh execute --mode light|squash` + +`--mode` で実際の rotation を実行する: + +| mode | 振る舞い | +|---|---| +| `light` (default) | prepare.json と newtext.json を読み、**同ブランチ・同 base** で旧 PR を close → 新 PR を作成。`is_draft=true` なら新 PR も Draft で作る。title/body は newtext から流す | +| `squash` (opt-in) | `-rHHMMSS` の新ブランチを作って `git reset --soft origin/$BASE` で squash 統合 → 旧 PR close → 新 PR (`(rotated)` suffix + automated body) | + +stdout には両モードとも以下を KEY=VALUE で出す: + +- `NEW_PR=` +- `NEW_PR_URL=` +- `NEW_BRANCH=` (light モードでは元ブランチと同じ) + +`state.py set-current-pr` が `state.json` の `current_pr` / `pr_history` を更新する。 +state.json の **キーは元 PR 番号 (STATE_PR) のまま** なので、light/squash どちらでも +後続スクリプトへの第 1 引数は `$STATE_PR` を渡し続ければよい。 + +### 後方互換: 旧 1 引数形式 + +`rotate-pr.sh ` (引数 1 つ) は `execute --mode squash` 相当として動くが、 +stderr に deprecation warning を出す。新規呼び出しは必ず prepare → execute 形式へ移行。 + +> ⚠ **重要**: state.json のファイル名は **最初に init した PR 番号** がキー +> (`$STATE_PR`)。rotation 後も全 scripts の **第 1 引数には常に `$STATE_PR`** を渡す。 +> 内部的に `state.json.current_pr` を読んで「現在の PR」を解決する設計。 +> `PR=$NEW_PR` 等で shell 変数の側を切り替えると、次ループの `state.py start-round` +> が `$TMP_DIR/cross-review-pr-state.json` を探して `state.json not found` で +> 止まる。 + +## Step 7: 次ラウンドへ + +Step 1 に戻る。 + +## Step 7.5: 最終スイープ(必須)— 取りこぼし防止 + +**目的**: ループを抜けた時点で PR 上に **未解決 (open) のレビュースレッドを 1 件も残さない**。 + +### なぜ必要か + +ループ内の修正フェーズ (Step 5) は「一方でも REQUEST_CHANGES」のラウンドでしか走らない。 +そのため以下が取りこぼされる: + +- **最終 APPROVE ラウンドのインラインコメント**: skill のレビュー方針上、`minor` 以下しか + 無ければ `APPROVE` で良い。つまり **APPROVE でも minor/nit のインラインコメントが + 投稿されている**ことがあり、両者 APPROVE でループを抜けるとこれらが未対応のまま残る。 +- **ループ中に deferred 記録した nit**: `state.deferred_nits` に積まれたまま reply のみで + Resolve されていないスレッド。 + +これらを放置すると、PR レビュー画面に「Unresolved」スレッドが残り、人間のレビュアーや +後続作業者が「未対応の指摘がある」と誤認する。 + +### 実行(メインが Agent を駆動) + +ループを抜けたら(`final` がどの値でも)、**メインが `Agent(subagent_type="general-purpose")` +を起動**し、`/ndf:fix ` を再実行させる。bash 単体では Agent を呼べないため、 +while ループ脱出後にメインが以下のプロンプトでサブエージェントを起動する。 + +> **対象 PR**: ``(state.json の `current_pr`。rotation していれば最新 PR) +> **worktree**: state.json の `worktree_path` +> +> PR の **全 open review thread**(インライン / レビュー body / PR レベルコメント)を +> `gh api` で洗い出し、cross-review の codex/gemini が残したものを中心に **すべて解消**せよ: +> 1. 修正可能な `minor`/`nit` → コード修正 + push(同ブランチ、main へは push しない)し、 +> reply + GraphQL `resolveReviewThread` で Resolve。 +> 2. 修正しない(好み・判断保留)`nit` → 「[deferred / nit] 対応見送り: <理由>」を日本語で +> reply した上で **Resolve まで実行**(スレッドを open のまま残さない)。 +> 3. bot 誤指摘 → 却下理由を reply して Resolve。 +> 修正で push した場合は `claude plugin validate` を通すこと。 +> 完了後、`$TMP_DIR/sweep-pr-result.json` に +> `{"resolved": N, "fixed_in_sweep": M, "commit": "", "remaining_open": K, +> "items": ["<1行要約>", ...]}` を書き出し、最終メッセージで内訳を日本語報告せよ。 +> **`remaining_open` は 0 を目標**とし、0 にできない場合は理由を明記すること。 + +> ⚠ 最終スイープは「修正の追加」ではなく **後始末**。新しい設計変更や大きな +> リファクタは行わない(行う必要があれば deferred として report に残す)。 +> push が走った場合でも、それに対する再レビューはループ終了後のため行わない +> (次回 cross-review か通常レビューに委ねる)。 + +### 再開性 + +sweep 中にメインが落ちても、`sweep-pr-result.json` が無ければ Step 7.5 から +再実行すれば良い(Resolve は冪等。既 Resolve スレッドは skip される)。 + +## Step 8: 終了処理 — ラウンドサマリ + 残 deferred の参考列挙 + +最終スイープ (Step 7.5) 完了後、ラウンドサマリを表示: + +```bash +"$SCRIPTS/state.py" report "$STATE_PR" +``` + +`report` は以下を Markdown で吐く: + +- 最終ステータス(`approved` / `max_rounds` / `oscillation` / `error`) +- PR 履歴 +- ラウンドサマリ表 +- 残 deferred nit 一覧 + +メインは `report` の出力に **Step 7.5 の sweep 結果**(`sweep-pr-result.json` の +`resolved` / `fixed_in_sweep` / `remaining_open`)を折り込んで最終報告する。 + +> **方針変更(v4.11.0)**: 従来は deferred nit を「AskUserQuestion で 1 回問い合わせ」て +> いたが、未解決スレッドを残さない方針に変更。**Step 7.5 で nit も含め全 open thread を +> Resolve する**ため、Step 8 のユーザ問い合わせは原則不要。deferred nit は「対応見送りの +> 記録」として report に **参考列挙**するに留める(再対応が要るものがあればユーザが +> その場で指示できる)。`remaining_open > 0` の場合のみ、残った理由を添えて報告する。 diff --git a/plugins/ndf-shared/skills/cross-review/scripts/_tmpdir.sh b/plugins/ndf-shared/skills/cross-review/scripts/_tmpdir.sh new file mode 100755 index 0000000..8df7371 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/scripts/_tmpdir.sh @@ -0,0 +1,23 @@ +# shellcheck shell=bash +# cross-review 共通: tmp ディレクトリ決定ヘルパ。 +# +# Usage: +# . "$(dirname "$0")/_tmpdir.sh" +# TMP_DIR=$(tmpdir) +# +# 優先順位: +# 1. 環境変数 CROSS_REVIEW_TMP_DIR (明示) +# 2. /.cross_review/ (worktree 内。gemini の workspace 制約を根本回避) + +tmpdir() { + if [ -n "${CROSS_REVIEW_TMP_DIR:-}" ]; then + mkdir -p "$CROSS_REVIEW_TMP_DIR" + echo "$CROSS_REVIEW_TMP_DIR" + return + fi + # サブディレクトリから呼ばれた場合でも worktree root を正しく特定する + local root + root="$(git rev-parse --show-toplevel 2>/dev/null)" || root="$PWD" + mkdir -p "$root/.cross_review" + echo "$root/.cross_review" +} diff --git a/plugins/ndf-shared/skills/cross-review/scripts/launch-codex.sh b/plugins/ndf-shared/skills/cross-review/scripts/launch-codex.sh new file mode 100755 index 0000000..2054366 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/scripts/launch-codex.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# cross-review codex launcher. +# +# Usage: launch-codex.sh +# +# 引数 STATE_PR は **state.json の key (= 最初に init した PR 番号)**。 +# rotation 後も state.json の場所は変わらないため、ここに渡すのは常に初期 PR。 +# gh コマンドに使う「現在のレビュー対象 PR」は state.json の `current_pr` を読む。 +# +# tmp ディレクトリは `_tmpdir.sh` の `tmpdir()` 関数で決定: +# CROSS_REVIEW_TMP_DIR env → $PWD/.cross_review/ +# worktree 内に配置することで gemini の workspace 制約を根本回避。 +# codex は --dangerously-bypass-approvals-and-sandbox を維持 +# (-s workspace-write は bwrap 非対応環境で失敗するため)。 +# +# 状態ファイル: $TMP_DIR/codex-review-pr-{result,err,stdout,pid}.json +# (パスは STATE_PR ベースで固定 — monitor.py / state.py と一致させる。) + +set -euo pipefail + +STATE_PR=${1:?STATE_PR required} +ROUND=${2:?ROUND required} + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=_tmpdir.sh +. "$SCRIPT_DIR/_tmpdir.sh" +TMP_DIR=$(tmpdir) + +STATE=$TMP_DIR/cross-review-pr$STATE_PR-state.json +[ -s "$STATE" ] || { echo "state.json not found: $STATE" >&2; exit 1; } + +WORKTREE=$(jq -r '.worktree_path' "$STATE") +REPO=$(jq -r '.repo' "$STATE") +EVENT_DOWNGRADE=$(jq -r '.event_downgrade // false' "$STATE") +EXTRA_REVIEW_INSTRUCTIONS=$(jq -r '.review_instructions // .extra_review_instructions // ""' "$STATE") +# PR (=current_pr) は gh コマンドのレビュー対象 PR 番号として使う。 +# tmp パス側は STATE_PR で固定 (monitor.py / state.py が同じ STATE_PR 起点で +# 読みに来るため、ここを揃えないと PR rotation 後に読み書きパスが食い違う)。 +PR=$(jq -r '.current_pr' "$STATE") +SHA=$(gh pr view "$PR" --json headRefOid -q .headRefOid) + +PROMPT=$TMP_DIR/codex-review-pr$STATE_PR-prompt.md +EXISTING=$TMP_DIR/cross-review-pr$STATE_PR-existing-comments.txt +EXTRA_REVIEW_BLOCK= +if [ -n "$EXTRA_REVIEW_INSTRUCTIONS" ]; then + EXTRA_REVIEW_BLOCK=$(cat < "$PROMPT" <\` には **本来の intent** を書く。 +- 既存コメントスナップショット: $EXISTING (重複指摘禁止) +$EXTRA_REVIEW_BLOCK + +## 出力契約 +- review body の **先頭行** に必ず以下を入れる(fence 不要、Markdown 見出しとして): + \`\`\` + ## 🤖 cross-review | round $ROUND | codex | + \`\`\` + 例: \`## 🤖 cross-review | round $ROUND | codex | REQUEST_CHANGES\` + - \`\` は **本来の intent** (REQUEST_CHANGES / APPROVE / COMMENT) + +### 出力に **含めてはいけないもの**(Resolve 負荷を増やすため) +- ❌ **「良い点」/「Strengths」/「評価できる点」 section** — body にも書かない +- ❌ **対応アクションが無いインラインコメント** — 観察・感想・現状説明だけは禁止 +- ❌ **nit / スタイル指摘のインライン化** — 好みの問題はコメント化しない (無視する) +- ❌ **コード引用 (\`\`\` ... \`\`\`) だけで指摘内容が無いコメント** +- ❌ **\`event=COMMENT\` での雑感投稿** — 直すべき点が無ければ \`APPROVE\` にする + +### インラインコメントの書式 +- \`[重要度 / カテゴリ]\` プレフィックス必須 (例: \`[major / 正確性]\`) +- 重要度は \`critical\` / \`major\` / \`minor\` のみ使う (nit はインライン化しない) +- 本文は **1 コメント = 1 修正アクション** で完結させる。1〜2 文で具体的な修正提案を書く + +### body (総評) の書き方 +- 設計レベル・PR 横断の **修正提案のみ** 書く +- 書くことが無ければ prefix 行 + 1 行サマリだけで良い (褒め言葉や評価文は不要) + +- 投稿後、サマリを **$TMP_DIR/codex-review-pr$STATE_PR-result.json** に書く: + \`\`\`json + { + "event": "REQUEST_CHANGES", + "posted_as": "COMMENT", + "comments_count": 5, + "review_url": "https://github.com/.../pull/$PR#pullrequestreview-...", + "by_severity": {"critical": 0, "major": 3, "minor": 2, "nit": 0} + } + \`\`\` +- payload(全コメント詳細)は **$TMP_DIR/codex-review-pr$STATE_PR-round$ROUND-payload.json** に保存 + (振動検知用、\`{ "comments": [{path, line, body, severity}, ...] }\` 形式) + +## 守るべきこと +- リポジトリ編集は行わない(コード修正は別ステップ) +- worktree 外のパスは触らない +- gh api 失敗時は err.log にエラー詳細を残して即時終了 +EOF + +cd "$WORKTREE" +nohup codex exec --dangerously-bypass-approvals-and-sandbox \ + --config reasoning.effort=medium -C "$WORKTREE" \ + < "$PROMPT" \ + > $TMP_DIR/codex-review-pr$STATE_PR-stdout.log \ + 2> $TMP_DIR/codex-review-pr$STATE_PR-err.log & +echo $! > $TMP_DIR/codex-review-pr$STATE_PR.pid +disown +echo "🚀 codex launched (pid=$(cat $TMP_DIR/codex-review-pr$STATE_PR.pid))" >&2 diff --git a/plugins/ndf-shared/skills/cross-review/scripts/launch-gemini.sh b/plugins/ndf-shared/skills/cross-review/scripts/launch-gemini.sh new file mode 100755 index 0000000..e0b921e --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/scripts/launch-gemini.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# cross-review gemini launcher (trusted directory 対策込み). +# +# Usage: launch-gemini.sh +# +# 引数 STATE_PR は state.json の key (= 最初に init した PR 番号)。 +# レビュー対象の PR は state.json の `current_pr` を読む。 +# +# 注意: +# - worktree のような新規パスは untrusted 判定で --yolo が "default" に降格する。 +# `--skip-trust` と `GEMINI_CLI_TRUST_WORKSPACE=true` を **両方** 必須とする。 +# - 完了判定は monitor.py が pidfile + sentinel + result.json で多軸判定する。 + +set -euo pipefail + +STATE_PR=${1:?STATE_PR required} +ROUND=${2:?ROUND required} + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=_tmpdir.sh +. "$SCRIPT_DIR/_tmpdir.sh" +TMP_DIR=$(tmpdir) + +STATE=$TMP_DIR/cross-review-pr$STATE_PR-state.json +[ -s "$STATE" ] || { echo "state.json not found: $STATE" >&2; exit 1; } + +WORKTREE=$(jq -r '.worktree_path' "$STATE") +REPO=$(jq -r '.repo' "$STATE") +EVENT_DOWNGRADE=$(jq -r '.event_downgrade // false' "$STATE") +EXTRA_REVIEW_INSTRUCTIONS=$(jq -r '.review_instructions // .extra_review_instructions // ""' "$STATE") +# PR (=current_pr) は gh コマンドのレビュー対象 PR 番号として使う。 +# tmp パス側は STATE_PR で固定 (monitor.py / state.py との読み書き整合のため)。 +PR=$(jq -r '.current_pr' "$STATE") +SHA=$(gh pr view "$PR" --json headRefOid -q .headRefOid) + +PROMPT=$TMP_DIR/gemini-review-pr$STATE_PR-prompt.md +# 既存コメントは **プロンプトにインライン埋め込み** する。 +# tmp dir は `/.cross_review/` を使うが、念のため +# プロンプト埋め込み方式も維持 (gemini が read_file を呼ばずに済むので確実)。 +EXISTING_FILE=$TMP_DIR/cross-review-pr$STATE_PR-existing-comments.txt +if [ -s "$EXISTING_FILE" ]; then + EXISTING_INLINE=$(cat "$EXISTING_FILE") +else + EXISTING_INLINE="(なし)" +fi +EXTRA_REVIEW_BLOCK= +if [ -n "$EXTRA_REVIEW_INSTRUCTIONS" ]; then + EXTRA_REVIEW_BLOCK=$(cat < "$PROMPT" <\` は本来の intent を書く。 + +## 既存コメントスナップショット(重複指摘禁止) +workspace 外を読まなくて済むよう、以下にインライン展開する: + +\`\`\` +$EXISTING_INLINE +\`\`\` +$EXTRA_REVIEW_BLOCK + +## 出力契約 +- review body の **先頭行** に必ず以下を入れる: + \`\`\` + ## 🤖 cross-review | round $ROUND | gemini | + \`\`\` + - \`\` は **本来の intent** (REQUEST_CHANGES / APPROVE / COMMENT) + +### 出力に **含めてはいけないもの**(Resolve 負荷を増やすため) +- ❌ **「良い点」/「Strengths」/「評価できる点」 section** — body にも書かない +- ❌ **対応アクションが無いインラインコメント** — 観察・感想・現状説明だけは禁止 +- ❌ **nit / スタイル指摘のインライン化** — 好みの問題はコメント化しない (無視する) +- ❌ **コード引用 (\`\`\` ... \`\`\`) だけで指摘内容が無いコメント** +- ❌ **\`event=COMMENT\` での雑感投稿** — 直すべき点が無ければ \`APPROVE\` にする + +### インラインコメントの書式 +- \`[重要度 / カテゴリ]\` プレフィックス必須 (例: \`[major / 正確性]\`) +- 重要度は \`critical\` / \`major\` / \`minor\` のみ使う (nit はインライン化しない) +- 本文は **1 コメント = 1 修正アクション** で完結させる。1〜2 文で具体的な修正提案を書く + +### body (総評) の書き方 +- 設計レベル・PR 横断の **修正提案のみ** 書く +- 書くことが無ければ prefix 行 + 1 行サマリだけで良い (褒め言葉や評価文は不要) + +### 進捗マーカー(監視用) +- 無言ハングと区別できるよう、作業フェーズが進むたびに + **$TMP_DIR/gemini-review-pr$STATE_PR-progress.log** へ短い 1 行を追記すること +- 内部の推論や長い説明は書かず、以下のようなフェーズ名 + 対象だけを書く: + - \`start: review PR #$PR round $ROUND\` + - \`scan: diff and existing comments\` + - \`analyze: candidate findings\` + - \`post: submit review\` + - \`done: result.json written\` + +- 投稿後、サマリを **$TMP_DIR/gemini-review-pr$STATE_PR-result.json** に + **必ず以下のキーで** 書く: + \`\`\`json + { + "event": "APPROVE", + "posted_as": "COMMENT", + "comments_count": 3, + "review_url": "https://github.com/.../pull/$PR#pullrequestreview-...", + "by_severity": {"critical": 0, "major": 0, "minor": 0, "nit": 0} + } + \`\`\` + - \`intent\` / \`comment_count\` 等の別名は使わないこと + - \`event\` の値は \`APPROVE\` / \`REQUEST_CHANGES\` / \`COMMENT\` のいずれか + - \`event_downgrade=true\` のとき \`posted_as\` は \`COMMENT\` にダウングレード可 +- payload は **$TMP_DIR/gemini-review-pr$STATE_PR-round$ROUND-payload.json** に保存 + (振動検知用、\`{ "comments": [{path, line, body, severity}, ...] }\` 形式) + +## 守るべきこと +- **リポジトリ編集禁止**。gh api での投稿のみ許可 +- worktree 外のパスは触らない +- gh api 失敗時は err.log にエラー詳細を残して即時終了 +EOF + +cd "$WORKTREE" + +# gemini-cli 最新版は mcpServers エントリの `disabled` キーを Unrecognized 扱いし、 +# 起動時に `Error in: mcpServers.` 警告を err.log に出す。文字列としては +# `Error: ...` ではなく `Error in: ...` だが、monitor.py 旧版が誤検知して +# プロセスを kill する原因になっていた (REPORT01 参照)。 +# launcher 側でも sanitize して警告自体を抑制する: `disabled` キーを再帰的に +# 削除した settings.json を起動時のみ差し込み、gemini が読み終わったら復元する。 +SETTINGS=$WORKTREE/.gemini/settings.json +SETTINGS_BACKUP= + +# trap で EXIT / INT / TERM / HUP のいずれでも必ず settings.json を復元する。 +# sleep 2 中や復元前に Ctrl-C / SIGTERM / シェル終了で止まっても、sanitize 済み +# settings.json が worktree に残らないようにするため。冪等に書いてあるので +# 多重実行されても安全。 +restore_settings() { + # SETTINGS_BACKUP が未設定 or バックアップ不在なら何もしない + if [ -n "${SETTINGS_BACKUP:-}" ] && [ -f "$SETTINGS_BACKUP" ]; then + mv -f "$SETTINGS_BACKUP" "$SETTINGS" 2>/dev/null || true + SETTINGS_BACKUP= + fi +} +trap restore_settings EXIT INT TERM HUP + +if [ -f "$SETTINGS" ] && command -v jq >/dev/null 2>&1; then + SETTINGS_BACKUP=$TMP_DIR/gemini-review-pr$STATE_PR-settings-backup.json + cp "$SETTINGS" "$SETTINGS_BACKUP" + SANITIZED=$TMP_DIR/gemini-review-pr$STATE_PR-settings-sanitized.json + if jq 'walk(if type == "object" then del(.disabled) else . end)' "$SETTINGS_BACKUP" > "$SANITIZED" 2>/dev/null; then + cp "$SANITIZED" "$SETTINGS" + else + # jq が失敗したら sanitize を諦め、バックアップも破棄して元のまま起動 + rm -f "$SETTINGS_BACKUP" + SETTINGS_BACKUP= + fi +fi + +# ⚠ --skip-trust と GEMINI_CLI_TRUST_WORKSPACE=true は両方必須 +GEMINI_CLI_TRUST_WORKSPACE=true nohup gemini --yolo --skip-trust --output-format text \ + -p "" \ + > $TMP_DIR/gemini-review-pr$STATE_PR-stdout.log \ + 2> $TMP_DIR/gemini-review-pr$STATE_PR-err.log < "$PROMPT" & +GEMINI_PID=$! +echo $GEMINI_PID > $TMP_DIR/gemini-review-pr$STATE_PR.pid +disown + +# gemini は起動時に 1 度 settings.json を読む。読み込み完了を待ってから元の +# ファイルを復元する (worktree を dirty なままにしないため)。 +# sleep 中に signal が来ても trap restore_settings が必ず復元するため安全。 +if [ -n "$SETTINGS_BACKUP" ] && [ -f "$SETTINGS_BACKUP" ]; then + sleep 2 + restore_settings +fi + +echo "🚀 gemini launched (pid=$GEMINI_PID)" >&2 diff --git a/plugins/ndf-shared/skills/cross-review/scripts/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/monitor.py new file mode 100755 index 0000000..544a912 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/scripts/monitor.py @@ -0,0 +1,806 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""cross-review codex/gemini プロセス監視 CLI。 + +`launch-codex.sh` / `launch-gemini.sh` で起動したバックグラウンドプロセスを +**複数の根拠で多重監視** し、失敗パターン (sentinel 不在 / 早期エラー / ハング / +pidfile stale / result.json 不在) を構造化して扱う。 + +監視軸: + 1. **pidfile** + `kill -0` でプロセス生存確認 + - 可能なら `/proc//cmdline` で codex/gemini であることを再確認 (PID 再利用対策) + 2. **sentinel** (codex のみ): err.log に `^tokens used$` 出現 + 3. **early-error pattern**: err.log に既知の致命的キーワードが出たら即中断 + - **FATAL** (auth/quota/sandbox 等の明確な致命): 検知時に kill + - **WARN** (生の `Error:` / `Traceback` 等の曖昧パターン): 警告ログのみ、kill せず通常判定を継続 + - `--no-early-error` / `MONITOR_NO_EARLY_ERROR=1` で検知自体を無効化可 + 4. **result.json**: プロセス終了後に `/.cross_review/-review-pr-result.json` が + 生成されていなければ失敗扱い + 5. **hard timeout**: 既定 7 分。`--timeout` または `MONITOR_TIMEOUT` で上書き可 + 6. **stall timeout**: err.log + stdout.log の合計サイズが一定時間変化しなければ + STALLED として中断。既定は agent 別 (codex=180s, gemini=480s。gemini は err.log + にほぼ進捗を出さないため大きめ)。`--stall-timeout` で CLI 明示、 + `MONITOR_STALL_` env で per-agent 上書き、`MONITOR_STALL` env で共通上書き可 + 7. **progress.log heartbeat**: agent が任意で書く短いフェーズマーカーを stderr に表示。 + Gemini の stdout/stderr が静かな時間でも、内部推論ではなく監視用の作業段階を確認できる + 8. **result.json + age fallback**: sentinel を持たない agent (gemini) 向け。 + result.json の mtime が 30 秒以上前なら完了とみなし kill → OK + 9. **失敗時 kill**: TIMEOUT / STALLED / EARLY_ERROR (FATAL のみ) / PIDFILE_BAD で + 返るとき、対象プロセスを SIGTERM (3 秒後に SIGKILL) で停止する + +Usage: + monitor.py target ∈ {codex, gemini, both} + monitor.py both --timeout 1200 --stall-timeout 600 + monitor.py both --no-early-error # EARLY_ERROR 検知を完全無効化 + +Exit codes (target=both は最悪値を返す): + 0 OK プロセス正常終了 + result.json 確認 + 1 USAGE / IO error + 2 TIMEOUT hard timeout 超過 + 3 NO_RESULT プロセス終了したが result.json 未生成 + 4 EARLY_ERROR err.log に致命的パターン検出 + 5 STALLED err.log が一定時間進捗なし + 6 PIDFILE_BAD pidfile が無い / 内容が不正 / プロセスが起動していない + +Stdout: 各 agent の最終ステータスを JSON で 1 行ずつ吐く(メインがパース可能)。 +Stderr: 人間向けの進捗ログ(poll ごとに 1 行)。 +""" +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import signal +import sys +import threading +import time +from dataclasses import dataclass +from typing import Optional + + +# ---------- 設定 ---------- + +# 既定値は import 時に **固定数値** で保持する。env (`MONITOR_TIMEOUT` / +# `MONITOR_STALL` / `MONITOR_POLL`) の解釈は **呼び出し時** に try/except +# 付きで行い、非数値 env でも import / 監視プロセスがクラッシュしないようにする。 +# (codex round 5 指摘: import 時の `int(os.environ.get(...))` は +# `MONITOR_STALL=abc` のような誤設定で `_agent_stall_default()` に到達する前に +# ValueError で落ちてしまうため) +DEFAULT_TIMEOUT = 420 # 7 min — `--timeout` / env `MONITOR_TIMEOUT` で上書き可 +# 既定 stall timeout (後方互換のため env MONITOR_STALL は残す)。 +# 両 agent 共通のデフォルトとして引き続き受け付ける (解釈は `_agent_stall_default()` 内)。 +DEFAULT_STALL = 180 # 3 min no progress +# per-agent 上書き: gemini は err.log がほぼ無音なため大きめに取る。 +# 解決順は `_agent_stall_default()` 参照。 +DEFAULT_STALL_AGENT_BUILTIN = { + "codex": 180, # 推論ログを逐次出すので 3 min で十分 + "gemini": 480, # err.log が静かなため 8 min まで許容 +} +DEFAULT_POLL = 15 # 15 sec — env `MONITOR_POLL` で上書き可 +# result.json が書き込まれた後もプロセスがハングするケース (gemini で観測) の +# fallback: mtime から RESULT_AGE_GRACE 秒以上経過していれば完了とみなす。 +RESULT_AGE_GRACE = 30 +# `MONITOR_NO_EARLY_ERROR=1` で EARLY_ERROR 検知を無効化 (escape hatch) +DEFAULT_NO_EARLY_ERROR = os.environ.get("MONITOR_NO_EARLY_ERROR", "").lower() in { + "1", "true", "yes", "on", +} + +# err.log の行頭に近い形で出る **明確な致命** パターン (kill 対象)。 +# auth / quota / sandbox / HTTP 401-403-429 / gemini の YOLO 降格など、 +# プロセスが続行しても result を生成できないと判明しているケースだけを入れる。 +EARLY_ERROR_FATAL = [ + # HTTP エラーステータス行 (`HTTP/1.1 401 Unauthorized` 等) + re.compile(r"^HTTP/\d\S* (?:401|403|429) ", re.MULTILINE), + # gemini 固有: untrusted directory で YOLO が降格される + re.compile(r'^Approval mode overridden to "default"', re.MULTILINE), + # 認証 / 権限系(行頭限定) + re.compile(r"^(?:Authentication failed|Permission denied)", re.MULTILINE), + # quota / rate limit (`m.start()` をキーワード位置に合わせるため `^.*` を付けない。 + # `_match_is_quoted()` が backtick / 「」 引用を判定するために match 開始位置を使うため) + re.compile(r"\b(?:quota exceeded|rate limit exceeded)\b", re.IGNORECASE), + # API key 系 + re.compile(r"\bAPI key (?:not found|missing|invalid)\b", re.IGNORECASE), + # codex 固有: sandbox エラー + re.compile(r"\bsandbox error\b", re.IGNORECASE), +] + +# 行頭の生 `Error:` / `Traceback` 系は **kill しない警告のみ** に降格。 +# - gemini-cli の `Error in: mcpServers.` 警告 (起動時の config 検証) +# - codex がレビュー対象 diff の test コード片を echo して `Traceback` が混入するケース +# など、続行可能な誤検知が頻発するため。プロセスは sentinel / result.json / timeout +# で別途判定する。 +EARLY_ERROR_WARN = [ + re.compile(r"^(?:Error|FATAL|fatal|panic|PANIC|Traceback)[: ]", re.MULTILINE), +] + +# 文脈に含まれていたら benign(doc 引用 / コードレビューコメント等)と見なし誤検知扱い。 +# FATAL / WARN 双方のスキャンに適用される。 +EARLY_ERROR_BENIGN = [ + # gemini-cli の config validation 警告 (`Error in: mcpServers.` / `Error in: ...`): + # 設定の Unrecognized キーを通知するだけで、gemini 本体は起動継続する。 + re.compile(r"^Error in: mcpServers\.", re.MULTILINE), + re.compile(r"^Error in: \S+\s*$", re.MULTILINE), + # diff のコンテキスト行 (` `, `+`, `-` で始まり、その後 markdown 表記が来る) + re.compile(r"^[ +-].*[\|`]", re.MULTILINE), + # markdown のリスト / 引用 + re.compile(r"^\s*[-*>]\s", re.MULTILINE), + # markdown の表セル行 (`| ... | ...` 形式)。SKILL.md / docs/*.md が + # 検知パターンを表で列挙しており、それを codex が echo すると誤検知する。 + re.compile(r"^\|", re.MULTILINE), + # grep / ripgrep 形式のソース引用行 (`path/to/file.ext:42: `)。 + # codex がレビュー対象のテストコード片を grep 形式で echo すると、 + # その文字列リテラル内の FATAL キーワードを誤検知する (PR #23 round 2 で発生)。 + re.compile(r"^\S+\.[A-Za-z0-9]+:\d+:", re.MULTILINE), + # warning は致命ではない + re.compile(r"^warning: ", re.IGNORECASE | re.MULTILINE), +] + + +def _match_is_quoted(line: str, match_start: int, match_end: int) -> bool: + """マッチ位置がドキュメント引用 / コード文字列リテラルに囲まれているか判定。 + + - backtick: マッチ開始までの `` ` `` カウントが奇数 かつ マッチ終了以降に `` ` `` がある + - 日本語クォート: マッチ開始までに直近の `「` が `」` よりも後 かつ マッチ終了以降に `」` がある + - ダブル/シングルクォート文字列リテラル: マッチ開始までの `"` (or `'`) カウントが + 奇数 かつ マッチ終了以降に同じクォートがある (= リテラルの内側) + + Why: SKILL.md / docs/*.md 内で FATAL キーワードを `「quota exceeded」` のように + 引用列挙しており、codex がそれを echo する。さらに tests/*.py の + `"quota exceeded: please upgrade"` のような **テスト用文字列リテラル** を + codex がレビュー中に echo するケース (PR #23 round 2 で実際に発生) もある。 + いずれも引用形であり本物のエラーではないため benign 扱いする。 + """ + before = line[:match_start] + after = line[match_end:] + if before.count("`") % 2 == 1 and "`" in after: + return True + if before.rfind("「") > before.rfind("」") and "」" in after: + return True + # コード文字列リテラル (ダブル / シングルクォート)。 + # エスケープされたクォート (`\"` / `\'`) はリテラルを開閉しないため + # パリティ計算から除外する。これを数えると、文字列内にエスケープ + # クォートを含む行で「引用内/外」の判定がずれ、本物のエラー行を + # 誤って benign 扱い (= FATAL 見逃し) する恐れがある。 + for q in ('"', "'"): + if _unescaped_count(before, q) % 2 == 1 and q in after: + return True + return False + + +def _unescaped_count(text: str, quote: str) -> int: + """`quote` のうちバックスラッシュでエスケープされていない出現数を数える。 + + 直前の連続バックスラッシュ数が奇数なら、そのクォートはエスケープ + されている (リテラルを開閉しない) ものとして除外する。 + """ + count = 0 + for i, ch in enumerate(text): + if ch != quote: + continue + backslashes = 0 + j = i - 1 + while j >= 0 and text[j] == "\\": + backslashes += 1 + j -= 1 + if backslashes % 2 == 0: + count += 1 + return count + +CODEX_SENTINEL = re.compile(r"^tokens used$", re.MULTILINE) + + +def _safe_int_env(name: str, fallback: int) -> int: + """env を safe に int parse する。 + + 非数値時は warn を stderr に出して fallback 値を返す。 + `_agent_stall_default()` と同じく、env 設定ミスでプロセスを落とさないため。 + + Note (codex round 5 指摘): `MONITOR_STALL` 等の env を import 時 / 呼び出し時に + 生の `int(...)` で読むと、非数値設定で監視プロセスが起動できなくなる。 + `DEFAULT_TIMEOUT` / `DEFAULT_POLL` も同じ問題を持つため、共通ヘルパに集約。 + """ + if name not in os.environ: + return fallback + raw = os.environ[name] + try: + return int(raw) + except (ValueError, TypeError): + print( + f"⚠ env {name}={raw!r} が int に変換できません — {fallback} を使用", + file=sys.stderr, flush=True, + ) + return fallback + + +def _agent_stall_default(agent: str) -> int: + """agent ごとの既定 stall timeout を解決する。 + + 解決順: + 1. env `MONITOR_STALL_` (per-agent 明示) + 2. env `MONITOR_STALL` (両 agent 共通) + 3. `DEFAULT_STALL_AGENT_BUILTIN[agent]` (codex=180, gemini=480) + 4. `DEFAULT_STALL` (フォールバック) + + Note (codex round 3 指摘): 2 は **呼び出し時** に `os.environ["MONITOR_STALL"]` + を再評価する。`DEFAULT_STALL` モジュール定数は import 時に env を読むため + プロセス起動後の env 変更に追随できず、テストの monkeypatch も効かない。 + + Note (gemini round 4 指摘): env が非数値だった場合 (`int(...)` で + `ValueError` / `TypeError` が裸で上がる) は warn を出して + `DEFAULT_STALL_AGENT_BUILTIN` / `DEFAULT_STALL` にフォールバックする。 + 監視プロセスを env 設定ミスでクラッシュさせない。 + """ + builtin = DEFAULT_STALL_AGENT_BUILTIN.get(agent, DEFAULT_STALL) + env_key = f"MONITOR_STALL_{agent.upper()}" + if env_key in os.environ: + return _safe_int_env(env_key, builtin) + if "MONITOR_STALL" in os.environ: + return _safe_int_env("MONITOR_STALL", builtin) + return builtin + + +def _tmp_dir() -> pathlib.Path: + """cross-review 用 tmp ディレクトリ。 + + state.py の `_tmp_dir()` と同じロジック。優先: + 1. `CROSS_REVIEW_TMP_DIR` env + 2. `/.cross_review/` (worktree 内。gemini の workspace 制約を根本回避) + + worktree root は `git rev-parse --show-toplevel` で取得する。 + サブディレクトリから起動した場合でも一貫したパスを返す。 + """ + env = os.environ.get("CROSS_REVIEW_TMP_DIR") + if env: + d = pathlib.Path(env).resolve() + d.mkdir(parents=True, exist_ok=True) + return d + # git worktree root を取得。サブディレクトリから起動しても一貫したパスにする。 + import subprocess as _sp + r = _sp.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, + ) + if r.returncode == 0 and r.stdout.strip(): + root = pathlib.Path(r.stdout.strip()).resolve() + else: + root = pathlib.Path.cwd().resolve() + d = root / ".cross_review" + d.mkdir(parents=True, exist_ok=True) + return d + + +# ---------- データ型 ---------- + +@dataclass +class AgentPaths: + agent: str + pr: int + pidfile: pathlib.Path + err_log: pathlib.Path + stdout_log: pathlib.Path + progress_log: pathlib.Path + result: pathlib.Path + + @classmethod + def for_(cls, agent: str, pr: int) -> "AgentPaths": + base = _tmp_dir() / f"{agent}-review-pr{pr}" + return cls( + agent=agent, pr=pr, + pidfile=pathlib.Path(f"{base}.pid"), + err_log=pathlib.Path(f"{base}-err.log"), + stdout_log=pathlib.Path(f"{base}-stdout.log"), + progress_log=pathlib.Path(f"{base}-progress.log"), + result=pathlib.Path(f"{base}-result.json"), + ) + + +@dataclass +class AgentStatus: + agent: str + status: str = "RUNNING" + exit_code: int = 0 + pid: Optional[int] = None + elapsed: float = 0.0 + detail: str = "" + err_log_size: int = 0 + stdout_log_size: int = 0 + progress_log_size: int = 0 + progress_tail: str = "" + idle_seconds: float = 0.0 + result_exists: bool = False + sentinel_seen: bool = False + + +# ---------- 監視ロジック ---------- + +def _read_pidfile(p: pathlib.Path) -> Optional[int]: + try: + s = p.read_text().strip() + return int(s) if s else None + except (FileNotFoundError, ValueError): + return None + + +def _pid_alive(pid: int) -> bool: + """`kill -0` + ゾンビ検出。 + + `os.kill(pid, 0)` はゾンビプロセスに対しても成功する (PID エントリが + 残っているため)。Docker without `--init` 環境では orphan プロセスが + ゾンビ化して永久に残るため、`/proc//status` で State: Z を検出する。 + """ + try: + os.kill(pid, 0) + except (ProcessLookupError, PermissionError): + return False + except OSError: + return False + try: + status_text = pathlib.Path(f"/proc/{pid}/status").read_text() + for line in status_text.splitlines(): + if line.startswith("State:"): + return "Z" not in line + except (FileNotFoundError, PermissionError, OSError): + pass + return True + + +def _is_zombie(pid: int) -> bool: + """PID がゾンビかどうか。_pid_alive() とは独立に呼べるユーティリティ。""" + try: + status_text = pathlib.Path(f"/proc/{pid}/status").read_text() + for line in status_text.splitlines(): + if line.startswith("State:"): + return "Z" in line + except (FileNotFoundError, PermissionError, OSError): + pass + return False + + +def _kill_pid(pid: int, sigterm_grace: float = 3.0) -> None: + """対象プロセスに SIGTERM、`sigterm_grace` 秒後も生きていたら SIGKILL。 + + TIMEOUT / STALLED / EARLY_ERROR で監視を打ち切るとき、対象プロセスが残ったまま + だと後から `gh api` 投稿や result.json 書き込みを実行してメインフローと + 競合する。失敗扱いで返るときは必ず停止させる。 + ゾンビプロセスにはシグナルを送れないためスキップする。 + """ + if pid <= 0: + return + if _is_zombie(pid): + return + try: + os.kill(pid, signal.SIGTERM) + except OSError: + return + deadline = time.monotonic() + sigterm_grace + while time.monotonic() < deadline: + if not _pid_alive(pid): + return + time.sleep(0.5) + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + + +def _pid_cmdline_matches(pid: int, expected: str) -> Optional[bool]: + """`/proc//cmdline` を読んで `expected` を含むか。 + + /proc が読めない環境では None を返す(PID 再利用チェック非対応)。 + """ + try: + cmdline = pathlib.Path(f"/proc/{pid}/cmdline").read_text() + return expected.lower() in cmdline.lower() + except (FileNotFoundError, PermissionError, OSError): + return None + + +def _scan_patterns( + path: pathlib.Path, patterns: list[re.Pattern[str]] +) -> Optional[str]: + """err.log を末尾 200KB だけ読み、`patterns` の最初の **non-benign** ヒット行を返す。 + + BENIGN フィルタは **マッチ行そのもの** に対して適用し、誤検知 (markdown 引用 / + diff body / gemini の config validation 警告) を除外する。 + + 重要 1: `pat.search()` ではなく `pat.finditer()` を使い、benign で除外された場合も + 後続の一致を継続して走査する。これにより benign な先行ヒットの後ろにある本物の + エラーを見逃さない。 + + 重要 2: benign 判定は「マッチ行」だけを対象にする。以前は前後 40 文字の文脈窓を + 使っていたが、それだと benign 行が直前にあるだけで後続の本物エラーを誤って benign + 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で + Traceback が誤抑制された)。 + """ + if not path.exists(): + return None + try: + sz = path.stat().st_size + with path.open("rb") as f: + if sz > 200 * 1024: + f.seek(sz - 200 * 1024) + data = f.read().decode("utf-8", errors="replace") + except OSError: + return None + + for pat in patterns: + for m in pat.finditer(data): + line_start = data.rfind("\n", 0, m.start()) + 1 + line_end = data.find("\n", m.end()) + line_end = line_end if line_end != -1 else len(data) + line = data[line_start:line_end] + # benign パターンはマッチ行そのものに当てる。markdown 引用や + # `Error in: mcpServers.X` のような行単位パターンは「その行」だけを + # 評価すれば判定可能で、文脈窓を広げると誤判定の原因になる。 + if any(b.search(line) for b in EARLY_ERROR_BENIGN): + continue + # マッチ部位が backtick / 日本語「」 で引用されている場合も benign。 + if _match_is_quoted(line, m.start() - line_start, m.end() - line_start): + continue + return line.strip() + return None + + +def _scan_early_fatal(path: pathlib.Path) -> Optional[str]: + return _scan_patterns(path, EARLY_ERROR_FATAL) + + +def _scan_early_warn(path: pathlib.Path) -> Optional[str]: + return _scan_patterns(path, EARLY_ERROR_WARN) + + +def _scan_codex_sentinel(path: pathlib.Path) -> bool: + if not path.exists(): + return False + try: + # 末尾 64KB を読む(sentinel は最後の方に出る) + sz = path.stat().st_size + with path.open("rb") as f: + if sz > 64 * 1024: + f.seek(sz - 64 * 1024) + tail = f.read().decode("utf-8", errors="replace") + except OSError: + return False + return bool(CODEX_SENTINEL.search(tail)) + + +def _safe_size(path: pathlib.Path) -> int: + try: + return path.stat().st_size if path.exists() else 0 + except OSError: + return 0 + + +def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: + """監視用 progress.log の末尾 1 行を安全に読む。 + + Gemini に書かせるのは短いフェーズマーカーだけなので、末尾数 KB で十分。 + 壊れた UTF-8 や読み取り競合があっても monitor 自体は落とさない。 + """ + if not path.exists(): + return "" + try: + sz = path.stat().st_size + with path.open("rb") as f: + if sz > limit: + f.seek(sz - limit) + data = f.read().decode("utf-8", errors="replace") + except OSError: + return "" + if sz > limit and "\n" in data: + data = data.split("\n", 1)[1] + for line in reversed(data.splitlines()): + stripped = line.strip() + if stripped: + return stripped[:200] + return "" + + +def monitor_agent( + agent: str, + pr: int, + timeout: int, + stall_timeout: int, + poll: int, + require_result: bool, + no_early_error: bool = False, + log_prefix: str = "", +) -> AgentStatus: + """1 agent を監視する。 + + `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 + hard timeout / stall / sentinel / result.json のみで判定する。 + """ + paths = AgentPaths.for_(agent, pr) + status = AgentStatus(agent=agent) + started = time.monotonic() + + # 起動チェック: 30 秒待っても pidfile が無ければ起動失敗 + grace_end = started + 30 + while time.monotonic() < grace_end: + if paths.pidfile.exists(): + break + time.sleep(2) + pid = _read_pidfile(paths.pidfile) + if pid is None: + status.status = "PIDFILE_BAD" + status.exit_code = 6 + status.detail = f"pidfile not found: {paths.pidfile}" + _emit_log(log_prefix, agent, status) + return status + + status.pid = pid + # cmdline 検証 (PID 再利用対策) は **プロセスが生きていると確認できたときのみ** 行う。 + # 起動直後に既にプロセスが exit していると /proc/ が消えるか、別プロセスに + # 再利用されている可能性があり、ここで PIDFILE_BAD を返すと「完了している(result.json は出ている)」 + # ケースを誤って失敗にしてしまう。alive=True と確認した瞬間のみ cmdline 一致を検証する。 + + started_wall = time.time() + last_progress_size = ( + _safe_size(paths.err_log) + + _safe_size(paths.stdout_log) + + _safe_size(paths.progress_log) + ) + last_progress = time.monotonic() + cmdline_validated = False + warned_early_error = False + + while True: + elapsed = time.monotonic() - started + status.elapsed = elapsed + + # 1. プロセス生存確認 → 死んでいたら最終判定へ (result.json 存在をチェック) + alive = _pid_alive(pid) + if agent == "codex": + status.sentinel_seen = _scan_codex_sentinel(paths.err_log) + + # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける + # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の + # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が + # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 + if ( + agent == "codex" + and alive + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status + + # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: + # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け + # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば + # 完了とみなし、プロセスを kill → OK。 + # 安全条件: + # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み + # - mtime >= started_wall: 前 round の stale result.json を拾わない + if ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + result_mtime = paths.result.stat().st_mtime + if result_mtime >= started_wall: + result_age = time.time() - result_mtime + if result_age >= RESULT_AGE_GRACE: + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status + + if alive and not cmdline_validated: + # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと + # ファイル不在で None 扱いになり判定不能のため。 + cmdline_ok = _pid_cmdline_matches(pid, agent) + if cmdline_ok is False: + _kill_pid(pid) + status.status = "PIDFILE_BAD" + status.exit_code = 6 + status.detail = f"pid {pid} cmdline does not contain '{agent}' (stale pidfile?)" + _emit_log(log_prefix, agent, status) + return status + if cmdline_ok is True: + cmdline_validated = True + + # 2. hard timeout + if elapsed >= timeout: + if alive: + _kill_pid(pid) + status.status = "TIMEOUT" + status.exit_code = 2 + status.detail = f"hard timeout {timeout}s reached (pid {pid})" + _emit_log(log_prefix, agent, status) + return status + + # 3. early error + # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は + # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を + # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 + if not no_early_error: + fatal_err = _scan_early_fatal(paths.err_log) + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + status.detail = f"early error (fatal) in err.log: {fatal_err[:200]}" + _emit_log(log_prefix, agent, status) + return status + + if not warned_early_error: + warn_err = _scan_early_warn(paths.err_log) + if warn_err: + print( + f"{log_prefix}⚠️ {agent} early-error WARN " + f"(non-fatal, not killing): {warn_err[:200]}", + file=sys.stderr, flush=True, + ) + warned_early_error = True + + if not alive: + # プロセス終了 — result.json を確認 + status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 + if status.result_exists or not require_result: + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"process exited; sentinel={status.sentinel_seen}; " + f"result_exists={status.result_exists}" + ) + else: + status.status = "NO_RESULT" + status.exit_code = 3 + status.detail = f"process exited but result.json missing: {paths.result}" + _emit_log(log_prefix, agent, status) + return status + + # 4. stall detection (err.log / stdout.log / progress.log をモニタ。 + # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には + # launcher が要求した短いフェーズマーカーが出るため、いずれかが + # 更新されれば progress として扱う) + status.err_log_size = _safe_size(paths.err_log) + status.stdout_log_size = _safe_size(paths.stdout_log) + status.progress_log_size = _safe_size(paths.progress_log) + status.progress_tail = _tail_last_nonempty_line(paths.progress_log) + progress_size = ( + status.err_log_size + status.stdout_log_size + status.progress_log_size + ) + if progress_size != last_progress_size: + last_progress_size = progress_size + last_progress = time.monotonic() + status.idle_seconds = time.monotonic() - last_progress + if status.idle_seconds >= stall_timeout: + if alive: + _kill_pid(pid) + status.status = "STALLED" + status.exit_code = 5 + status.detail = ( + f"no log progress for {stall_timeout}s " + f"(pid {pid}, last size {last_progress_size}B)" + ) + _emit_log(log_prefix, agent, status) + return status + + # poll 中の進捗ログ + _emit_progress(log_prefix, agent, status) + time.sleep(poll) + + +def _emit_progress(prefix: str, agent: str, st: AgentStatus) -> None: + progress = f" progress={st.progress_tail!r}" if st.progress_tail else "" + print( + f"{prefix}⏳ {agent} elapsed={st.elapsed:.0f}s pid={st.pid} " + f"idle={st.idle_seconds:.0f}s " + f"err={st.err_log_size}B stdout={st.stdout_log_size}B " + f"progress_log={st.progress_log_size}B " + f"sentinel={'Y' if st.sentinel_seen else '-'}{progress}", + file=sys.stderr, flush=True, + ) + + +def _emit_log(prefix: str, agent: str, st: AgentStatus) -> None: + icon = { + "OK": "✅", "TIMEOUT": "⏰", "NO_RESULT": "❌", + "EARLY_ERROR": "💥", "STALLED": "🛑", "PIDFILE_BAD": "❓", + }.get(st.status, "?") + print( + f"{prefix}{icon} {agent} {st.status} ({st.elapsed:.0f}s) — {st.detail}", + file=sys.stderr, flush=True, + ) + + +# ---------- CLI ---------- + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("pr", type=int) + p.add_argument("target", choices=["codex", "gemini", "both"]) + # env (MONITOR_TIMEOUT / MONITOR_POLL) は呼び出し時に safe parse で読む。 + # 非数値設定でも fixed default (`DEFAULT_TIMEOUT` / `DEFAULT_POLL`) に戻す。 + timeout_default = _safe_int_env("MONITOR_TIMEOUT", DEFAULT_TIMEOUT) + poll_default = _safe_int_env("MONITOR_POLL", DEFAULT_POLL) + p.add_argument("--timeout", type=int, default=timeout_default, + help=f"hard timeout in seconds (default: {timeout_default})") + p.add_argument("--stall-timeout", type=int, default=None, + help="stall timeout (err.log no progress) in seconds. " + "未指定時は agent 別既定 (codex=180, gemini=480) または " + "env MONITOR_STALL_ / MONITOR_STALL を参照") + p.add_argument("--poll", type=int, default=poll_default, + help=f"poll interval in seconds (default: {poll_default})") + p.add_argument("--no-require-result", action="store_true", + help="プロセス終了後に result.json が無くても OK 扱い") + p.add_argument("--no-early-error", action="store_true", + default=DEFAULT_NO_EARLY_ERROR, + help="EARLY_ERROR 検知を無効化 " + "(hard timeout / stall / sentinel / result.json のみで判定) " + f"[env: MONITOR_NO_EARLY_ERROR; default: {DEFAULT_NO_EARLY_ERROR}]") + args = p.parse_args() + + agents = ["codex", "gemini"] if args.target == "both" else [args.target] + require_result = not args.no_require_result + + results: dict[str, AgentStatus] = {} + + def run(agent: str) -> None: + stall = args.stall_timeout if args.stall_timeout is not None \ + else _agent_stall_default(agent) + results[agent] = monitor_agent( + agent=agent, pr=args.pr, + timeout=args.timeout, stall_timeout=stall, + poll=args.poll, require_result=require_result, + no_early_error=args.no_early_error, + log_prefix=f"[{agent}] ", + ) + + threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] + for t in threads: + t.start() + for t in threads: + t.join() + + # 結果出力: 1 行 1 JSON + for agent in agents: + st = results[agent] + print(json.dumps({ + "agent": agent, + "status": st.status, + "exit_code": st.exit_code, + "pid": st.pid, + "elapsed": round(st.elapsed, 1), + "detail": st.detail, + "err_log_size": st.err_log_size, + "stdout_log_size": st.stdout_log_size, + "progress_log_size": st.progress_log_size, + "progress_tail": st.progress_tail, + "idle_seconds": round(st.idle_seconds, 1), + "result_exists": st.result_exists, + "sentinel_seen": st.sentinel_seen, + }, ensure_ascii=False)) + + # exit code: 全エージェントの最大値(OK=0 が最良、それ以外は失敗) + sys.exit(max(results[a].exit_code for a in agents)) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf-shared/skills/cross-review/scripts/rotate-pr.sh b/plugins/ndf-shared/skills/cross-review/scripts/rotate-pr.sh new file mode 100755 index 0000000..9b063f4 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/scripts/rotate-pr.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +# PR rotation — light モード (default) / squash モード +# +# Usage: +# rotate-pr.sh prepare +# 旧 PR の title/body/isDraft + git log $BASE..HEAD + git diff --stat を +# $TMP_DIR/rotate-pr-prepare.json に dump する。 +# light モードでは メインセッションの Agent が prepare.json を読み、 +# 現状の差分・実装を反映した新 title/body を生成して +# $TMP_DIR/rotate-pr-newtext.json に書き出すこと。 +# +# rotate-pr.sh execute [--mode light|squash] (default: light) +# light : 同ブランチで旧 PR を close → 同 head/base で新 PR を作成。 +# title/body は newtext.json から流す。元 PR の isDraft をコピー。 +# PR title に内部用語 (rotated/round/cross-review) は付与しない。 +# squash : squash 統合した新ブランチ (-rHHMMSS suffix) + 新 PR。 +# title 末尾に "(rotated)"、body は automated text。 +# いずれも stdout に NEW_PR= / NEW_PR_URL= / NEW_BRANCH= を出力。 +# +# rotate-pr.sh (deprecated) +# 旧 1 引数形式。`execute --mode squash` 相当として動くが、stderr に +# deprecation warning を出す。新規呼び出しは prepare → execute 形式へ移行。 +# +# 引数 STATE_PR は state.json の key (= 最初に init した PR 番号)。 +# 閉じる「現在の PR」は state.json の `current_pr` を読む。 +# state.json の current_pr / pr_history 更新は `state.py set-current-pr` で別途行う。 + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=_tmpdir.sh +. "$SCRIPT_DIR/_tmpdir.sh" +TMP_DIR=$(tmpdir) + +usage() { + cat >&2 <<'USAGE' +Usage: + rotate-pr.sh prepare + rotate-pr.sh execute [--mode light|squash] (default: light) + rotate-pr.sh (deprecated, = execute --mode squash) +USAGE +} + +# close 後に新 PR create が失敗した場合の rollback hook (light/squash 共通)。 +# OLD_PR はグローバル (load_state で set される) を参照する。 +# 両モードから `trap reopen_old_pr_on_failure ERR` で登録し、create 成功直後に +# `trap - ERR` で解除する (gemini round 6 指摘: 関数定義の重複排除 + EXIT ではなく +# ERR で hook して PR 作成後の処理失敗による誤発火を避ける)。 +reopen_old_pr_on_failure() { + local exit_code=$? + echo "⚠ 新 PR 作成系処理に失敗 (exit=$exit_code) — 旧 PR #${OLD_PR:-?} を reopen します" >&2 + if [ -n "${OLD_PR:-}" ]; then + gh pr reopen "$OLD_PR" >&2 || echo "⚠ 旧 PR #$OLD_PR の reopen にも失敗しました。手動で確認してください。" >&2 + fi +} + +# state.json から共通情報を読み出して shell 変数にセットする。 +# 呼び出し後: STATE_FILE / WORKTREE / OLD_PR / ROUND_IN_PR が使える。 +load_state() { + local state_pr=$1 + STATE_FILE=$TMP_DIR/cross-review-pr$state_pr-state.json + [ -s "$STATE_FILE" ] || { echo "state.json not found: $STATE_FILE" >&2; exit 1; } + WORKTREE=$(jq -r '.worktree_path' "$STATE_FILE") + OLD_PR=$(jq -r '.current_pr' "$STATE_FILE") + ROUND_IN_PR=$(jq --argjson p "$OLD_PR" '[.rounds[] | select(.pr == $p)] | length' "$STATE_FILE") +} + +cmd_prepare() { + local state_pr=${1:?STATE_PR required} + load_state "$state_pr" + + cd "$WORKTREE" + + local pr_json + pr_json=$(gh pr view "$OLD_PR" \ + --json number,url,title,body,headRefName,baseRefName,isDraft) + local head_branch base_branch + head_branch=$(jq -r '.headRefName' <<<"$pr_json") + base_branch=$(jq -r '.baseRefName' <<<"$pr_json") + + # base が origin にあることを保証 (git log/diff のため) + if ! git fetch --quiet origin "$base_branch"; then + echo "⚠ git fetch origin $base_branch に失敗しました。ローカル参照のみで継続します。" >&2 + fi + if ! git rev-parse --verify --quiet "origin/$base_branch" >/dev/null; then + echo "⚠ origin/$base_branch が見つかりません。git_log / git_diff_stat は空になります。" >&2 + fi + + local range="origin/$base_branch..HEAD" + local git_log git_diff_stat + git_log=$(git log --pretty=format:'%h %s' "$range" 2>/dev/null || echo "") + git_diff_stat=$(git diff --stat "$range" 2>/dev/null || echo "") + + local out=$TMP_DIR/rotate-pr$state_pr-prepare.json + jq -n \ + --argjson state_pr "$state_pr" \ + --argjson pr "$pr_json" \ + --argjson round_in_pr "$ROUND_IN_PR" \ + --arg worktree "$WORKTREE" \ + --arg git_log "$git_log" \ + --arg git_diff_stat "$git_diff_stat" \ + '{ + state_pr: $state_pr, + old_pr: $pr.number, + old_pr_url: $pr.url, + worktree_path: $worktree, + head_branch: $pr.headRefName, + base_branch: $pr.baseRefName, + is_draft: $pr.isDraft, + round_in_pr: $round_in_pr, + old_title: $pr.title, + old_body: ($pr.body // ""), + git_log: $git_log, + git_diff_stat: $git_diff_stat + }' > "$out" + + echo "✅ prepare.json 書き出し: $out" >&2 + # 呼び出し側 (SKILL.md / docs) は eval で stdout を取り込む契約。 + # PR の head/base 由来の値は shell メタ文字を含み得るため必ず printf '%q' で + # シェルエスケープしてから出力する (例: ブランチ名に "; rm -rf / 等が来ても安全)。 + printf 'PREPARE_JSON=%q\n' "$out" + printf 'OLD_PR=%q\n' "$OLD_PR" + printf 'HEAD_BRANCH=%q\n' "$head_branch" + printf 'BASE_BRANCH=%q\n' "$base_branch" + printf 'IS_DRAFT=%q\n' "$(jq -r '.isDraft' <<<"$pr_json")" +} + +# light モード本体: 同ブランチで旧 PR を close → 同 head/base で新 PR 作成。 +execute_light() { + local state_pr=$1 + load_state "$state_pr" + + local prep=$TMP_DIR/rotate-pr$state_pr-prepare.json + local newtext=$TMP_DIR/rotate-pr$state_pr-newtext.json + [ -s "$prep" ] || { echo "prepare.json not found: $prep — 先に rotate-pr.sh prepare $state_pr を実行してください" >&2; exit 1; } + [ -s "$newtext" ] || { echo "newtext.json not found: $newtext — Agent (general-purpose) で title/body を生成して書き出してください" >&2; exit 1; } + + local head_branch base_branch is_draft new_title new_body + head_branch=$(jq -r '.head_branch' "$prep") + base_branch=$(jq -r '.base_branch' "$prep") + is_draft=$(jq -r '.is_draft' "$prep") + new_title=$(jq -r '.title' "$newtext") + new_body=$(jq -r '.body' "$newtext") + + [ -n "$new_title" ] && [ "$new_title" != "null" ] || { echo "newtext.json に .title がない" >&2; exit 1; } + # body は空文字列を許容 (GitHub は空 body を許容)。null のみ拒否。 + [ "$new_body" != "null" ] || { echo "newtext.json に .body がない (null)" >&2; exit 1; } + + cd "$WORKTREE" + + echo "🔄 PR #$OLD_PR rotation (light): 同ブランチ $head_branch で巻き直し (base=$base_branch)" >&2 + + # 1. 作業ディレクトリに未 push のコミットがある可能性に備え、close 前に push する。 + # push しないと新 PR に最新コミットが反映されないケースがあるため必須 (gemini 指摘)。 + # state.py init は worktree を detached HEAD で作るため、ブランチ名のみで push すると + # detached HEAD 上の修正コミットが push されない。HEAD: 形式で現在の HEAD を + # 明示する (codex 指摘)。--force / --no-verify は禁止。 + echo "🔼 git push origin HEAD:$head_branch (未 push commit が無ければ no-op)" >&2 + git push origin HEAD:"$head_branch" + + # 2. 旧 PR を close (コメント残し) + gh pr comment "$OLD_PR" --body "ℹ️ レビューコメント履歴整理のため本 PR を一度 close し、同じブランチ \`$head_branch\` で新 PR を作り直します。ブランチの内容・base は変えません。" + gh pr close "$OLD_PR" + + # close 後に create が失敗した場合は旧 PR を reopen して rotation の途中停止を回避する + # (関数定義は file 冒頭で共通化, gemini round 6 指摘) + trap reopen_old_pr_on_failure ERR + + # 3. 新 PR を同 head/base で作成 (Draft 状態は元 PR から継承)。 + # body は --body-file - 経由で stdin から渡し、argv 長制限を回避する (gemini 指摘)。 + local create_args=(--base "$base_branch" --head "$head_branch" --title "$new_title" --body-file -) + if [ "$is_draft" = "true" ]; then + create_args+=(--draft) + fi + local new_pr_url + new_pr_url=$(printf '%s' "$new_body" | gh pr create "${create_args[@]}") + + # gh pr create 成功直後に trap を解除し、後続の URL parse / echo 等が失敗しても + # 新旧 PR が重複して開く事態を避ける (gemini round 6 指摘)。 + trap - ERR + + # PR 番号は create 出力 URL の末尾セグメントから抽出 (gh pr view 追加呼び出しを削減, + # gemini round 6 指摘)。URL 形式: https://github.com///pull/ + local new_pr=${new_pr_url##*/} + + echo "✅ 新 PR #$new_pr: $new_pr_url" >&2 + # eval される契約。head_branch / URL に shell メタ文字が混ざっても安全なよう %q で escape + printf 'NEW_PR=%q\n' "$new_pr" + printf 'NEW_PR_URL=%q\n' "$new_pr_url" + printf 'NEW_BRANCH=%q\n' "$head_branch" +} + +# squash モード本体。 +execute_squash() { + local state_pr=$1 + load_state "$state_pr" + + cd "$WORKTREE" + + local branch base title new_branch pr_meta prep + prep=$TMP_DIR/rotate-pr$state_pr-prepare.json + + # PR メタ情報 (base / title / head) は、まず prepare.json があればそこから読み出し、 + # 無い場合のみ gh pr view にフォールバックする (execute_light と同じ方針で + # 不要な API 呼び出しを排除, gemini round 8 指摘)。 + if [ -s "$prep" ]; then + base=$(jq -r '.base_branch // empty' "$prep") + title=$(jq -r '.old_title // empty' "$prep") + fi + if [ -z "${base:-}" ] || [ -z "${title:-}" ]; then + pr_meta=$(gh pr view "$OLD_PR" --json headRefName,baseRefName,title) + [ -n "${base:-}" ] || base=$(printf '%s' "$pr_meta" | jq -r '.baseRefName') + [ -n "${title:-}" ] || title=$(printf '%s' "$pr_meta" | jq -r '.title') + fi + + # state.py init は worktree を `git worktree add --detach origin/` で作るため、 + # `git branch --show-current` は空文字を返す。空のまま new_branch を生成すると + # `-rHHMMSS` だけのブランチ名になってしまうので、フォールバック順を以下に固定する: + # 1. git branch --show-current (通常 worktree なら使える) + # 2. prepare.json の head_branch (prepare 済みなら最も信頼できる) + # 3. gh pr view --json headRefName (prepare 未実行でも復元可能) + # (codex round 4 指摘) + branch=$(git branch --show-current) + if [ -z "$branch" ] && [ -s "$prep" ]; then + branch=$(jq -r '.head_branch // empty' "$prep") + fi + if [ -z "$branch" ]; then + pr_meta=${pr_meta:-$(gh pr view "$OLD_PR" --json headRefName,baseRefName,title)} + branch=$(printf '%s' "$pr_meta" | jq -r '.headRefName') + fi + [ -n "$branch" ] || { echo "head branch を復元できませんでした (detached worktree かつ prepare.json / gh pr view から取得失敗)" >&2; exit 1; } + new_branch="${branch}-r$(date +%H%M%S)" + + # 既に title 末尾に "(rotated)" / "(rotated2)" 等が付いている場合は除去してから + # "(rotated)" を 1 つだけ付与し、ローテーションのたびに suffix が重複しないようにする + # (gemini round 8 指摘)。 + # 例: + # "Fix foo" → "Fix foo (rotated)" + # "Fix foo (rotated)" → "Fix foo (rotated)" + # "Fix foo (rotated2)" → "Fix foo (rotated)" + # "Fix foo (rotated)(rotated)" → "Fix foo (rotated)" + local title_stripped=$title + while [[ $title_stripped =~ [[:space:]]*\(rotated[0-9]*\)$ ]]; do + title_stripped=${title_stripped%"${BASH_REMATCH[0]}"} + done + local new_title="$title_stripped (rotated)" + + echo "🔄 PR #$OLD_PR rotation (squash): $branch → $new_branch (base=$base)" >&2 + + # 1. 既存ブランチを squash して新ブランチに + git checkout -b "$new_branch" + git reset --soft "origin/$base" + # commit message は -m を複数指定で分割して渡す。$(cat < +EOF +) + local new_pr_url + new_pr_url=$(printf '%s' "$new_body" | gh pr create --base "$base" --title "$new_title" --body-file -) + + # gh pr create 成功直後に trap を解除し、後続の URL parse / echo 等が失敗しても + # 新旧 PR が重複して開く事態を避ける (gemini round 6 指摘)。 + trap - ERR + + # PR 番号は create 出力 URL の末尾セグメントから抽出 (gh pr view 追加呼び出しを削減, + # gemini round 6 指摘)。URL 形式: https://github.com///pull/ + local new_pr=${new_pr_url##*/} + + echo "✅ 新 PR #$new_pr: $new_pr_url" >&2 + # eval される契約。new_branch / URL に shell メタ文字が混ざっても安全なよう %q で escape + printf 'NEW_PR=%q\n' "$new_pr" + printf 'NEW_PR_URL=%q\n' "$new_pr_url" + printf 'NEW_BRANCH=%q\n' "$new_branch" +} + +cmd_execute() { + local state_pr=${1:?STATE_PR required} + shift + # --mode 未指定時は light を default (SKILL.md / 02-fix-and-rotation.md / スクリプト + # 冒頭コメントの「light モード (default)」表記に CLI 契約を揃える, codex round 4 指摘) + local mode="light" + while [ $# -gt 0 ]; do + case $1 in + --mode) + mode=${2:?--mode requires light|squash} + shift 2 + ;; + --mode=*) + mode=${1#--mode=} + shift + ;; + *) + echo "unknown arg: $1" >&2 + usage + exit 2 + ;; + esac + done + case $mode in + light) execute_light "$state_pr" ;; + squash) execute_squash "$state_pr" ;; + *) echo "invalid --mode: $mode (light|squash)" >&2; exit 2 ;; + esac +} + +# ---- entrypoint ---- + +if [ $# -eq 0 ]; then + usage + exit 2 +fi + +case $1 in + prepare) + shift + cmd_prepare "$@" + ;; + execute) + shift + cmd_execute "$@" + ;; + -h|--help) + usage + ;; + *) + # 旧形式: rotate-pr.sh → squash 相当 + if [ $# -eq 1 ] && [[ $1 =~ ^[0-9]+$ ]]; then + echo "⚠ DEPRECATED: rotate-pr.sh 形式は廃止予定です。新形式に移行してください:" >&2 + echo " rotate-pr.sh prepare $1" >&2 + echo " rotate-pr.sh execute $1 --mode light|squash" >&2 + echo " (本実行は --mode squash 相当で継続します)" >&2 + execute_squash "$1" + else + usage + exit 2 + fi + ;; +esac diff --git a/plugins/ndf-shared/skills/cross-review/scripts/state.py b/plugins/ndf-shared/skills/cross-review/scripts/state.py new file mode 100755 index 0000000..c386e70 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/scripts/state.py @@ -0,0 +1,1482 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""cross-review state.json 操作 CLI。 + +`/.cross_review/cross-review-pr-state.json` の初期化 / 読み書きと、 +ループ判定(round 開始 / 収束 / 振動 / PR ローテーション要否 / fix 結果マージ / +deferred nit レポート)を 1 つの CLI に集約する。 + +Subcommands: + init Step 0 state 初期化 or 再開(プリチェック込み) + start-round Step 1 round 開始判定 (ROUND/ROUND_IN_PR/PR を stdout に出す) + read-result Step 2.5 codex/gemini の result.json を state にマージ + judge Step 3 intent ベース pass 判定 (exit 0=approved, 2=continue) + check-oscillation Step 4 path:line 重複率を計算 + merge-fix Step 5 post fix サブエージェント戻り値を state にマージ + CI 分類 + should-rotate Step 6 rotate_after 到達判定 (exit 0=rotate, 2=keep) + set-current-pr PR ローテーション後の current_pr 更新 + report Step 8 deferred nit + ラウンドサマリ表示 + +すべての出力は人間可読 + KEY=VALUE 形式(eval / read で取り回し可能)。 +""" +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import os +import pathlib +import shlex +import subprocess +import sys +import tempfile +import time +from typing import Any + + +# ---------------- helpers ---------------- + +DOC_EXTENSIONS = { + ".md", ".mdx", ".rst", ".txt", ".adoc", ".asciidoc", +} +DOC_FILENAMES = { + "readme", "license", "changelog", "contributing", "codeowners", +} +CODE_EXTENSIONS = { + ".c", ".cc", ".cpp", ".cs", ".css", ".dart", ".ex", ".exs", ".go", ".h", + ".hpp", ".html", ".java", ".js", ".jsx", ".kt", ".kts", ".php", ".py", + ".rb", ".rs", ".scala", ".scss", ".sh", ".sql", ".swift", ".ts", ".tsx", + ".vue", ".yaml", ".yml", +} +MIGRATION_PATH_MARKERS = ( + "/migrations/", "/migration/", "/db/migrate/", "/database/migrations/", + "/alembic/versions/", "/prisma/migrations/", +) +MIGRATION_NAME_MARKERS = ( + "migration", "migrate", "schema.sql", "schema.prisma", +) +TEST_PATH_MARKERS = ( + "/test/", "/tests/", "/spec/", "/specs/", "__tests__/", +) +TEST_NAME_MARKERS = ( + ".test.", ".spec.", "_test.", "_spec.", "test_", "spec_", +) +DEPENDENCY_FILENAMES = { + "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", + "composer.json", "composer.lock", "gemfile", "gemfile.lock", + "go.mod", "go.sum", "requirements.txt", "requirements-dev.txt", + "pyproject.toml", "poetry.lock", "uv.lock", "cargo.toml", "cargo.lock", + "pom.xml", "build.gradle", "build.gradle.kts", +} +CI_CONFIG_MARKERS = ( + "/.github/workflows/", "/.gitlab-ci", "/.circleci/", "/.buildkite/", + "/.kiro/", "/.claude/", "/.codex/", +) +CONFIG_EXTENSIONS = { + ".json", ".toml", ".yaml", ".yml", ".ini", ".env", ".example", +} +API_CONTRACT_MARKERS = ( + "/api/", "/routes/", "/controllers/", "/openapi", "/swagger", + "/proto/", "/graphql/", "/schemas/", +) +AUTH_SECURITY_TOKEN_MARKERS = ( + "auth", "authn", "authz", "permission", "policy", "role", "oauth", "jwt", + "session", "csrf", "cors", "token", +) +AUTH_SECURITY_SUBSTRING_MARKERS = ( + "authentication", "authorization", "authenticat", "authoriz", + "secret", "password", "credential", +) +FRONTEND_EXTENSIONS = { + ".css", ".scss", ".sass", ".less", ".html", ".jsx", ".tsx", ".vue", ".svelte", +} +PERFORMANCE_MARKERS = ( + "cache", "queue", "job", "worker", "concurrent", "parallel", + "batch", "stream", "pagination", "performance", +) +GENERATED_MARKERS = ( + "/dist/", "/build/", "/generated/", "/vendor/", "/node_modules/", +) +I18N_MARKERS = ( + "/locales/", "/locale/", "/i18n/", "/translations/", +) +I18N_EXTENSIONS = {".po", ".pot"} +INFRA_MARKERS = ( + "/terraform/", "/helm/", "/k8s/", "/kubernetes/", "/docker/", + "dockerfile", "docker-compose", +) +INFRA_EXTENSIONS = {".tf", ".tfvars"} + + +COMMON_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 共通 +- PR の目的と変更範囲が一貫しているか。余分な変更、未説明の仕様変更、将来の保守を難しくする設計がないか。 +- 変更が既存仕様・既存コメント・既存コードの前提と矛盾しないか。重複指摘は避け、根拠がある修正アクションだけを指摘する。 +- テスト、検証手順、エラーハンドリング、ロールバック容易性が変更リスクに見合っているか。""" + +DOCS_ONLY_REVIEW_TEMPLATE = """## 自動追加レビュー観点: ドキュメントのみ PR +- ドキュメントの主張・企画・手順が妥当で、読者の意思決定や作業を誤らせないか。 +- コード、設定、コマンド、既存 README / docs / CHANGELOG との整合性が取れているか。古い名称、存在しないパス、実装と違う説明がないか。 +- ドキュメント間で用語、前提、バージョン、責務分担が矛盾していないか。 +- 追加・更新された説明が必要十分で、曖昧な表現や未検証の断定がないか。""" + +CODE_REVIEW_TEMPLATE = """## 自動追加レビュー観点: コード変更 PR +- 設計、正確性、可読性、保守性、単純さを確認する。不要に複雑な分岐、責務の混在、過剰な抽象化がないか。 +- 冗長・重複コード、既存ヘルパや標準 API で置き換えられる処理、言語・フレームワークらしくない実装がないか。 +- 関数・クラス・ファイルのサイズと責務が適切か。長すぎる関数、肥大化したファイル、名前と実態がずれた単位がないか。 +- セキュリティ観点として、入力検証、出力エンコード、認可、秘密情報、ログ、例外、外部コマンド、SQL/HTML/パス操作の扱いを確認する。 +- テストの有無と質、境界値、失敗系、後方互換性、性能・並行性・リソース解放のリスクを確認する。""" + +DB_MIGRATION_REVIEW_TEMPLATE = """## 自動追加レビュー観点: DB migration / schema 変更 +- データ設計としてテーブル、カラム、型、NULL 可否、default、制約、外部キー、unique/index がドメイン要件に合っているか。 +- 型の粒度が妥当か。文字列で持つべきでない値、過剰に広い型、精度不足、timezone、JSON の濫用がないか。 +- 既存データへの影響、backfill、ロック時間、index 作成、ロールバック、アプリケーションの段階的デプロイ順序に問題がないか。 +- migration とモデル、クエリ、ドキュメント、テストデータの整合性が取れているか。""" + +TEST_REVIEW_TEMPLATE = """## 自動追加レビュー観点: テスト変更 +- テストが実装詳細ではなくユーザー影響・仕様・境界値・失敗系を検証しているか。 +- flaky になりやすい時間、乱数、順序、外部サービス、並行実行、共有状態への依存がないか。 +- テスト名、fixture、期待値が読みやすく、失敗時に原因を特定しやすいか。""" + +DEPENDENCY_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 依存関係変更 +- 追加・更新された依存の必要性、ライセンス、メンテナンス状況、既存依存との重複を確認する。 +- lockfile と manifest の整合性、間接依存の大きな変化、ビルド/実行環境への影響を確認する。 +- 依存更新がセキュリティ、互換性、バンドルサイズ、起動時間に与える影響を確認する。""" + +CONFIG_CI_REVIEW_TEMPLATE = """## 自動追加レビュー観点: CI / 設定変更 +- CI 条件、権限、secret 参照、cache key、artifact、並列実行、失敗時の検知性が妥当か。 +- 設定変更がローカル・CI・本番で食い違わないか。環境変数の既定値と `.env.example` 相当の説明が揃っているか。 +- 自動化が過剰な権限や予期せぬ副作用を持たないか。""" + +API_CONTRACT_REVIEW_TEMPLATE = """## 自動追加レビュー観点: API / 契約変更 +- 入出力スキーマ、HTTP status、エラー形式、互換性、バージョニング、既存クライアントへの影響を確認する。 +- バリデーション、認可、ページング、冪等性、レート制限、監査ログが要件に合っているか。 +- API ドキュメント、型定義、テスト、実装の整合性が取れているか。""" + +AUTH_SECURITY_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 認証・認可・機密情報 +- 認証、認可、ロール、所有者チェック、テナント境界が欠落または過剰許可になっていないか。 +- token、password、secret、PII がログ、例外、レスポンス、コミット差分に漏れていないか。 +- CSRF/CORS/session/JWT/OAuth などの設定が安全で、失効・更新・リプレイ対策が妥当か。""" + +FRONTEND_REVIEW_TEMPLATE = """## 自動追加レビュー観点: フロントエンド / UX +- UI 状態、loading/error/empty、キーボード操作、アクセシビリティ、レスポンシブ表示が破綻しないか。 +- コンポーネント責務、重複 UI、状態管理、不要な再レンダリング、バンドルサイズへの影響を確認する。 +- 表示文言、フォーム validation、ユーザー操作後のフィードバックが仕様と一致しているか。""" + +PERFORMANCE_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 性能・並行性 +- N+1、不要な全件取得、過剰な同期 I/O、メモリ保持、ロック、競合、リトライ嵐がないか。 +- cache、batch、pagination、stream、queue/worker の使い方が正しく、失敗時の再実行や重複実行に耐えるか。 +- 計測・ログ・アラートが問題発生時の切り分けに足りるか。""" + +DELETION_RENAME_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 削除・リネーム +- 削除・リネーム対象への参照がコード、設定、CI、ドキュメント、テスト、外部連携に残っていないか。 +- 後方互換性、migration、deprecation、利用者への移行手順が必要ないか。 +- 同名別ファイルや大文字小文字差による環境依存の問題がないか。""" + +GENERATED_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 生成物・ロックファイル +- 生成物が本当にコミット対象か。生成元との差分、再生成手順、不要なノイズが混入していないか。 +- lockfile の差分が意図した依存変更に対応しているか。手編集や不整合がないか。""" + +I18N_REVIEW_TEMPLATE = """## 自動追加レビュー観点: i18n / 文言 +- 翻訳キー、fallback、変数展開、複数形、日付・数値・通貨・タイムゾーン表記が妥当か。 +- 原文と翻訳、UI 表示幅、アクセシビリティラベル、ドキュメント文言の整合性を確認する。""" + +INFRA_REVIEW_TEMPLATE = """## 自動追加レビュー観点: インフラ / デプロイ +- 環境差分、権限、secret、ネットワーク公開範囲、永続化、バックアップ、スケール、ロールバック容易性を確認する。 +- IaC / manifest / Dockerfile の設定が最小権限・再現可能・運用監視しやすい形になっているか。 +- 既存環境への破壊的変更、手動作業、順序依存、ダウンタイムのリスクが説明されているか。""" + +def _default_worktree_base() -> pathlib.Path: + """worktree の親ディレクトリを解決する。 + + 優先順位: + 1. 環境変数 NDF_WORKTREE_BASE (明示オーバーライド) + 2. <システム tmpdir>/ndf-worktrees (非永続領域。コンテナ再作成で自動消滅) + + かつての /work/worktrees ($HOME/work/worktrees) は共有の永続 volume 上にあり、 + 別リポジトリの pr と衝突する・明示削除が必要・volume を消費する問題が + あったため廃止した。 + """ + env = os.environ.get("NDF_WORKTREE_BASE") + if env: + # 相対パスのまま state.json に保存されると後続のパス比較が壊れるため、 + # 常に絶対パスへ解決して返す。 + return pathlib.Path(env).resolve() + return pathlib.Path(tempfile.gettempdir()) / "ndf-worktrees" + + +def _repo_slug(repo: str) -> str: + """`owner/name` を path-safe なディレクトリ名 `owner--name` に変換する。""" + return repo.replace("/", "--") + + +def _is_registered_worktree(path: str) -> bool: + """path が現リポジトリに登録済みの worktree かどうか。 + + パスが存在しても別リポジトリの残骸や git 管理外ディレクトリの可能性があり、 + 流用すると git 操作が壊れるため、流用前に必ずこれで検証する。 + """ + out = _sh(["git", "worktree", "list", "--porcelain"], check=False) + target = str(pathlib.Path(path).resolve()) + return any(line == f"worktree {target}" for line in out.splitlines()) + + +def _create_worktree(worktree: str, pr: int, head_branch: str) -> None: + """origin/ から detached worktree を作成する (フォーク PR はフォールバック)。""" + pathlib.Path(worktree).parent.mkdir(parents=True, exist_ok=True) + # worktree を /tmp 等の非永続領域に置くと、実体だけ消えて親リポジトリの + # 登録 (prunable) が残ることがある。その状態で `git worktree add` すると + # 「パス登録済み」として失敗するため、追加前に prune で掃除しておく。 + subprocess.run( + ["git", "worktree", "prune"], + capture_output=True, text=True, + ) + # フォーク PR の場合 origin に head_branch がないことがある。 + # fetch 失敗時は gh pr checkout --detach でフォールバックする。 + fetch_result = subprocess.run( + ["git", "fetch", "origin", head_branch], + capture_output=True, text=True, + ) + if fetch_result.returncode == 0: + # head branch が既に別の worktree で checkout されている場合を避けるため + # detached で展開する。cross-review はファイル参照しかしないので問題ない。 + _sh(["git", "worktree", "add", "--detach", worktree, f"origin/{head_branch}"]) + info(f"✅ worktree 作成 (detached @ origin/{head_branch}): {worktree}") + else: + info(f"⚠ git fetch origin {head_branch} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") + _sh(["git", "worktree", "add", "--detach", worktree, "HEAD"]) + # worktree 内で gh pr checkout を実行して正しいコミットに切り替え + checkout_result = subprocess.run( + ["gh", "pr", "checkout", str(pr), "--detach"], + capture_output=True, text=True, + cwd=worktree, + ) + if checkout_result.returncode != 0: + # HEAD (親コミット) 指向のまま残すと、次回実行時に + # _is_registered_worktree() を通過して不正流用されるため、 + # die() の前に作成済み worktree をロールバックする。 + subprocess.run( + ["git", "worktree", "remove", "--force", worktree], + capture_output=True, text=True, + ) + die(f"gh pr checkout --detach #{pr} 失敗: {checkout_result.stderr.strip()}") + info(f"✅ worktree 作成 (gh pr checkout --detach #{pr}): {worktree}") + + +def _git_toplevel() -> str | None: + """git worktree root を取得する。失敗時は None を返す。""" + try: + r = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, + ) + if r.returncode == 0 and r.stdout.strip(): + return r.stdout.strip() + except OSError: + pass + return None + + +def _tmp_dir(workspace: str | None = None) -> pathlib.Path: + """cross-review 用 tmp ディレクトリを決定する。 + + 優先順位: + 1. 環境変数 `CROSS_REVIEW_TMP_DIR` (明示) + 2. `/.cross_review/` (worktree 内。gemini の workspace 制約を根本回避) + + `workspace` 未指定なら `git rev-parse --show-toplevel` で worktree root を + 取得する。サブディレクトリから実行してもパス不一致が発生しない。 + git コマンドが失敗した場合のみ `os.getcwd()` にフォールバックする。 + """ + env = os.environ.get("CROSS_REVIEW_TMP_DIR") + if env: + d = pathlib.Path(env).resolve() + d.mkdir(parents=True, exist_ok=True) + return d + ws = pathlib.Path(workspace or _git_toplevel() or os.getcwd()).resolve() + d = ws / ".cross_review" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _resolve_tmp_dir(pr: int | None = None) -> pathlib.Path: + """state.json に保存された tmp_dir を優先し、_tmp_dir() をフォールバックとする。 + + init 時に確定した tmp_dir を再利用することで、CWD や環境変数の変化による + パス不一致リスクを回避する。 + + 注意: この関数は成果物パスの解決にのみ使用する。state ファイル自体のパス解決 + には _tmp_dir() を直接使うこと(循環参照を防ぐため)。 + 副作用: フォールバック時に _tmp_dir() を呼ぶため、ディレクトリ作成 (mkdir) が + 発生する可能性がある。 + """ + if pr is not None: + # state.json から tmp_dir を読み出す (存在する場合) + candidate = _tmp_dir() / f"cross-review-pr{pr}-state.json" + if candidate.exists(): + try: + st = json.loads(candidate.read_text(encoding="utf-8")) + saved = st.get("tmp_dir") + if saved: + p = pathlib.Path(saved) + if p.exists(): + return p + except (OSError, json.JSONDecodeError): + pass + return _tmp_dir() + + +def _state_path(pr: int) -> pathlib.Path: + return _tmp_dir() / f"cross-review-pr{pr}-state.json" + + +def _payload_path(agent: str, pr: int, round_: int) -> pathlib.Path: + return _resolve_tmp_dir(pr) / f"{agent}-review-pr{pr}-round{round_}-payload.json" + + +def _existing_comments_path(pr: int) -> pathlib.Path: + return _resolve_tmp_dir(pr) / f"cross-review-pr{pr}-existing-comments.txt" + + +def _now() -> str: + return _dt.datetime.now(_dt.timezone.utc).astimezone().isoformat(timespec="seconds") + + +def _parse_pr_files_payload(output: str) -> list[dict[str, Any]]: + """`gh pr view --json files` の JSON を分類用の最小構造に正規化する。""" + try: + payload = json.loads(output) + except json.JSONDecodeError: + return [] + files = payload.get("files") if isinstance(payload, dict) else None + if not isinstance(files, list): + return [] + + status_map = { + "ADDED": "A", + "MODIFIED": "M", + "DELETED": "D", + "RENAMED": "R", + "COPIED": "C", + "CHANGED": "M", + } + entries: list[dict[str, Any]] = [] + for f in files: + if not isinstance(f, dict): + continue + path = f.get("path") + if not isinstance(path, str) or not path: + continue + change_type = str(f.get("changeType") or "MODIFIED").upper() + status = status_map.get(change_type, change_type[:1] or "M") + paths = [] + previous = f.get("previousPath") or f.get("previous_filename") + if isinstance(previous, str) and previous and previous != path: + paths.append(previous) + paths.append(path) + entries.append({"status": status, "paths": paths}) + return entries + + +def _parse_pr_files_api_lines(output: str) -> list[dict[str, Any]]: + """GitHub API の PR files を TSV(JSON jq) 出力から分類用構造に変換する。""" + status_map = { + "added": "A", + "modified": "M", + "removed": "D", + "renamed": "R", + "copied": "C", + "changed": "M", + } + entries: list[dict[str, Any]] = [] + for raw in output.splitlines(): + if not raw.strip(): + continue + cols = raw.split("\t") + status_raw = cols[0].strip().lower() if cols else "modified" + path = cols[1].strip() if len(cols) > 1 else "" + previous = cols[2].strip() if len(cols) > 2 else "" + if not path: + continue + paths = [] + if previous and previous != path: + paths.append(previous) + paths.append(path) + entries.append({"status": status_map.get(status_raw, status_raw[:1].upper() or "M"), "paths": paths}) + return entries + + +def _fetch_changed_files(pr: int, repo: str) -> list[dict[str, Any]]: + r = subprocess.run( + [ + "gh", "api", f"repos/{repo}/pulls/{pr}/files", + "--paginate", + "--jq", '.[] | [.status, .filename, (.previous_filename // "")] | @tsv', + ], + capture_output=True, text=True, + ) + if r.returncode == 0: + entries = _parse_pr_files_api_lines(r.stdout) + if entries: + return entries + info("⚠ PR files API の結果が空、または解析できません。gh pr view fallback を試行") + + else: + info(f"⚠ PR files API 取得に失敗。gh pr view fallback を試行: {r.stderr.strip()[:200]}") + + fallback = subprocess.run( + ["gh", "pr", "view", str(pr), "--json", "files"], + capture_output=True, text=True, + ) + if fallback.returncode != 0: + info(f"⚠ PR 変更ファイル一覧の取得に失敗。自動レビュー観点は共通のみ: {fallback.stderr.strip()[:200]}") + return [] + entries = _parse_pr_files_payload(fallback.stdout) + if not entries: + info("⚠ PR 変更ファイル一覧が空、または解析できません。自動レビュー観点は共通のみ") + return entries + + +def _path_info(path: str) -> tuple[str, str, str, str]: + p = pathlib.PurePosixPath(path.replace("\\", "/")) + lower = str(p).lower() + normalized = "/" + lower.lstrip("./") + return lower, normalized, p.name.lower(), pathlib.PurePosixPath(lower).suffix + + +def _contains_any(text: str, needles: tuple[str, ...]) -> bool: + return any(n in text for n in needles) + + +def _path_tokens(path: str) -> set[str]: + _, normalized, _, _ = _path_info(path) + return { + token + for token in normalized.replace(".", "/").replace("-", "/").replace("_", "/").split("/") + if token + } + + +def _is_doc_path(path: str) -> bool: + lower, normalized, name, ext = _path_info(path) + stem = pathlib.PurePosixPath(lower).stem + return ( + ext in DOC_EXTENSIONS + or stem in DOC_FILENAMES + or normalized.startswith("/docs/") + or "/docs/" in normalized + or normalized.startswith("/documentation/") + or "/documentation/" in normalized + ) + + +def _is_code_path(path: str) -> bool: + _, _, _, ext = _path_info(path) + return ext in CODE_EXTENSIONS + + +def _is_migration_path(path: str) -> bool: + lower, normalized, name, ext = _path_info(path) + return ( + ext == ".sql" + or _contains_any(normalized, MIGRATION_PATH_MARKERS) + or any(marker in name or marker in lower for marker in MIGRATION_NAME_MARKERS) + ) + + +def _is_test_path(path: str) -> bool: + lower, normalized, name, _ = _path_info(path) + return _contains_any(normalized, TEST_PATH_MARKERS) or any(m in name or m in lower for m in TEST_NAME_MARKERS) + + +def _is_dependency_path(path: str) -> bool: + _, _, name, _ = _path_info(path) + return name in DEPENDENCY_FILENAMES + + +def _is_config_ci_path(path: str) -> bool: + lower, normalized, name, ext = _path_info(path) + return ( + _contains_any(normalized, CI_CONFIG_MARKERS) + or name.startswith(".env") + or name in {"dockerfile", "makefile", ".editorconfig"} + or lower.startswith(".github/") + or (ext in CONFIG_EXTENSIONS and ("/config/" in normalized or "/configs/" in normalized)) + ) + + +def _is_api_contract_path(path: str) -> bool: + lower, normalized, _, _ = _path_info(path) + return _contains_any(normalized, API_CONTRACT_MARKERS) or "openapi" in lower or "swagger" in lower + + +def _is_auth_security_path(path: str) -> bool: + lower, _, _, _ = _path_info(path) + tokens = _path_tokens(path) + return ( + bool(tokens.intersection(AUTH_SECURITY_TOKEN_MARKERS)) + or any(marker in lower for marker in AUTH_SECURITY_SUBSTRING_MARKERS) + ) + + +def _is_frontend_path(path: str) -> bool: + _, normalized, _, ext = _path_info(path) + return ext in FRONTEND_EXTENSIONS or "/components/" in normalized or "/pages/" in normalized + + +def _is_performance_path(path: str) -> bool: + return bool(_path_tokens(path).intersection(PERFORMANCE_MARKERS)) + + +def _is_generated_path(path: str) -> bool: + _, normalized, name, _ = _path_info(path) + return _contains_any(normalized, GENERATED_MARKERS) or name in { + "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "composer.lock", + "gemfile.lock", "go.sum", "poetry.lock", "uv.lock", "cargo.lock", + } + + +def _is_i18n_path(path: str) -> bool: + _, normalized, _, ext = _path_info(path) + return _contains_any(normalized, I18N_MARKERS) or ext in I18N_EXTENSIONS + + +def _is_infra_path(path: str) -> bool: + lower, normalized, name, ext = _path_info(path) + return ( + _contains_any(normalized, INFRA_MARKERS) + or name in {"dockerfile", "docker-compose.yml", "docker-compose.yaml"} + or ext in INFRA_EXTENSIONS + or lower.endswith(".tfvars.json") + ) + + +PATH_CATEGORY_RULES = ( + ("code", _is_code_path), + ("db_migration", _is_migration_path), + ("test", _is_test_path), + ("dependency", _is_dependency_path), + ("config_ci", _is_config_ci_path), + ("api_contract", _is_api_contract_path), + ("auth_security", _is_auth_security_path), + ("frontend", _is_frontend_path), + ("performance", _is_performance_path), + ("generated", _is_generated_path), + ("i18n", _is_i18n_path), + ("infra", _is_infra_path), +) + + +def _classify_changed_files(entries: list[dict[str, Any]]) -> list[str]: + paths = [p for entry in entries for p in entry.get("paths", []) if isinstance(p, str)] + categories: list[str] = ["common"] + if not paths: + return categories + + if paths and all(_is_doc_path(p) for p in paths): + categories.append("docs_only") + + categories.extend( + category + for category, predicate in PATH_CATEGORY_RULES + if any(predicate(path) for path in paths) + ) + if any(str(entry.get("status", "")).startswith(("D", "R")) for entry in entries): + categories.append("deletion_rename") + + # rename の旧パスだけで検知したカテゴリが混ざるのは有用だが、docs_only は + # 旧パス/新パス両方が docs であるときだけ採用するため上で all(paths) にしている。 + return list(dict.fromkeys(categories)) + + +def _auto_review_instructions(categories: list[str]) -> str: + templates = { + "common": COMMON_REVIEW_TEMPLATE, + "docs_only": DOCS_ONLY_REVIEW_TEMPLATE, + "code": CODE_REVIEW_TEMPLATE, + "db_migration": DB_MIGRATION_REVIEW_TEMPLATE, + "test": TEST_REVIEW_TEMPLATE, + "dependency": DEPENDENCY_REVIEW_TEMPLATE, + "config_ci": CONFIG_CI_REVIEW_TEMPLATE, + "api_contract": API_CONTRACT_REVIEW_TEMPLATE, + "auth_security": AUTH_SECURITY_REVIEW_TEMPLATE, + "frontend": FRONTEND_REVIEW_TEMPLATE, + "performance": PERFORMANCE_REVIEW_TEMPLATE, + "deletion_rename": DELETION_RENAME_REVIEW_TEMPLATE, + "generated": GENERATED_REVIEW_TEMPLATE, + "i18n": I18N_REVIEW_TEMPLATE, + "infra": INFRA_REVIEW_TEMPLATE, + } + return "\n\n".join(templates[c] for c in categories if c in templates) + + +def _combined_review_instructions(auto: str, manual: str) -> str: + return "\n\n".join(part for part in (auto.strip(), manual.strip()) if part) + + +def _extra_review_instructions(args: argparse.Namespace) -> str: + """cross-review launcher に渡す追加レビュー観点を組み立てる。 + + `--focus` は短い観点を直接渡す用途、`--extra-instructions-file` は長めの + チェックリストを渡す用途。両方指定された場合は順に連結する。 + """ + parts: list[str] = [] + focus = getattr(args, "focus", None) + if focus and str(focus).strip(): + parts.append(str(focus).strip()) + + extra_file = getattr(args, "extra_instructions_file", None) + if extra_file: + path = pathlib.Path(extra_file) + try: + text = path.read_text(encoding="utf-8").strip() + except OSError as exc: + die(f"追加レビュー観点ファイルを読めません: {path} ({exc})") + if text: + parts.append(text) + + return "\n\n".join(parts) + + +def _round_started_unixtime(round_entry: dict[str, Any]) -> float | None: + """``round.started_at`` (ISO 8601) を UNIX time (秒) に変換する。 + + fix 戻り値ファイルの mtime と比較して、round 開始前に書かれた古い + ファイルを fallback から除外するために使う。 + パース失敗時は None を返し、呼び出し側で「検証スキップ」を選ばせる。 + """ + started = round_entry.get("started_at") + if not started: + return None + try: + return _dt.datetime.fromisoformat(started).timestamp() + except (TypeError, ValueError): + return None + + +def _is_fresh_fix_result( + path: pathlib.Path, + pr: int, + round_started_ts: float | None, + is_canonical: bool = False, +) -> tuple[bool, dict[str, Any] | None]: + """fallback 候補の fix 戻り値ファイルを採用してよいか判定する。 + + 検証項目: + 1. ファイル mtime が `round_started_ts` 以降であること + (round 開始前に作られた = 古い実行 / 別リポジトリの同番号 PR の残骸) + 2. JSON 内に `pr` フィールドがある場合は対象 PR と一致すること + (`pr` フィールドが無い場合は 1 のみで判定) + + Returns: + ``(is_fresh, parsed_payload)`` のタプル。``is_fresh=True`` の場合のみ + ``parsed_payload`` (dict) が返る。呼び出し側はこれを使って再パースを省略できる。 + + 挙動: + - 古い候補・`pr` 不一致は警告を stderr に出して ``(False, None)`` を返し、 + 呼び出し側で次の候補へ進む。 + - `is_canonical=True` (= ``$TMP_DIR/fix-pr-result.json`` 正規パス) で + **読み取り失敗 (OSError / JSONDecodeError)** が発生した場合のみ + 即時 ``die(code=3)`` する (codex round 2 指摘: 正規パスが壊れているのに + 後続候補へ流れて別 PR の戻り値を誤マージする事故を防ぐ)。 + 正規パスでも `pr` 不一致 / stale mtime は fallback 継続対象とする。 + """ + # 1. mtime チェック + if round_started_ts is not None: + try: + mtime = path.stat().st_mtime + except OSError as exc: + info(f"⚠ fallback 候補 stat 失敗 ({path}): {exc} — skip") + return False, None + if mtime < round_started_ts: + info( + f"⚠ fallback 候補が round 開始前の古いファイル ({path}, " + f"mtime={_dt.datetime.fromtimestamp(mtime).isoformat(timespec='seconds')} " + f"< round_started={_dt.datetime.fromtimestamp(round_started_ts).isoformat(timespec='seconds')}) " + "— skip" + ) + return False, None + + # 2. JSON 内 `pr` フィールドの一致 (任意) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + if is_canonical: + die( + f"正規パスの fix 戻り値ファイルの読み取りに失敗 ({path}): {exc}。" + " 後続 fallback への流れ込みを防ぐため即時中断。", + code=3, + ) + info(f"⚠ fallback 候補 JSON 解析失敗 ({path}): {exc} — skip") + return False, None + # gemini round 3 指摘: `json.loads` は dict 以外 (list 等) も返す。 + # 後続の `payload.get(...)` や cmd_merge_fix 側の `.get()` でクラッシュしないよう、 + # dict でない場合は warn を出して fallback 不採用 ((False, None)) として扱う。 + # + # codex round 4 指摘: ただし `is_canonical=True` (= 正規パス) で非 dict が返った + # 場合は parse 失敗と同じく即時 die(code=3) する。skip で後続 `/tmp/` fallback に + # 流れると、壊れた正規出力を無視して別実行の戻り値を誤マージする経路が残るため。 + if not isinstance(payload, dict): + if is_canonical: + die( + f"正規パスの fix 戻り値ファイルが dict ではない " + f"({path}, type={type(payload).__name__})。" + " 後続 fallback への流れ込みを防ぐため即時中断。", + code=3, + ) + info( + f"⚠ fallback 候補 JSON が dict ではない ({path}, type={type(payload).__name__}) " + "— skip" + ) + return False, None + file_pr = payload.get("pr") + if file_pr is not None: + try: + file_pr_int = int(file_pr) + except (TypeError, ValueError): + info( + f"⚠ fallback 候補の pr フィールドが数値として解釈できない " + f"({path}, file_pr={file_pr!r}) — skip" + ) + return False, None + if file_pr_int != int(pr): + info( + f"⚠ fallback 候補の pr 不一致 ({path}, file_pr={file_pr} != pr={pr}) " + "— 別 PR の戻り値の可能性。skip" + ) + return False, None + return True, payload + + +def _load(pr: int) -> dict[str, Any]: + p = _state_path(pr) + if not p.exists(): + die(f"state.json not found: {p}") + return json.loads(p.read_text(encoding="utf-8")) + + +def _save(pr: int, state: dict[str, Any]) -> None: + p = _state_path(pr) + tmp = p.with_suffix(".json.tmp") + tmp.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") + tmp.replace(p) + + +def _sh(cmd: list[str], check: bool = True) -> str: + r = subprocess.run(cmd, capture_output=True, text=True) + if check and r.returncode != 0: + die(f"command failed ({' '.join(cmd)}): {r.stderr.strip()}") + return r.stdout.strip() + + +def die(msg: str, code: int = 1) -> None: + print(f"❌ {msg}", file=sys.stderr) + sys.exit(code) + + +def info(msg: str) -> None: + print(msg, file=sys.stderr) + + +# ---------------- subcommands ---------------- + +def cmd_init(args: argparse.Namespace) -> None: + """Step 0 — state 初期化 or 既存 state 引き継ぎ + プリチェック。""" + pr = args.pr + manual_extra_review = _extra_review_instructions(args) + # worktree path を先に解決してから tmp_dir を決定する。 + # tmp_dir は /.cross_review/ に配置し、gemini の workspace 制約を根本回避。 + # path には repo slug を含め、他リポジトリの同一 PR 番号と衝突しないようにする。 + repo = _sh(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) + worktree = str(pathlib.Path(args.worktree).resolve()) if args.worktree else str( + _default_worktree_base() / _repo_slug(repo) / f"pr{pr}") + + # worktree 存在チェック用: _tmp_dir() は mkdir するため、先に呼ぶと + # worktree ディレクトリが副作用で作成され exists() が常に true になる。 + # そのため _tmp_dir() 呼び出しは worktree 作成/確認の後に行う。 + + # 再開チェック: CROSS_REVIEW_TMP_DIR が設定されている場合はそちらを優先し、 + # 未設定なら /.cross_review/ を直接パスとして組む。 + # _tmp_dir() は mkdir 副作用があるため使用せず、パス解決のみ行う。 + env_tmp = os.environ.get("CROSS_REVIEW_TMP_DIR") + if env_tmp: + resume_dir = pathlib.Path(env_tmp).resolve() + else: + resume_dir = pathlib.Path(worktree) / ".cross_review" + resume_state_file = resume_dir / f"cross-review-pr{pr}-state.json" + if resume_state_file.exists(): + st = json.loads(resume_state_file.read_text(encoding="utf-8")) + if st.get("final") is None: + state_changed = False + if "auto_review_instructions" not in st: + changed_files = _fetch_changed_files(pr, st.get("repo") or repo) + categories = _classify_changed_files(changed_files) + st["changed_files"] = changed_files + st["auto_review_categories"] = categories + st["auto_review_instructions"] = _auto_review_instructions(categories) + state_changed = True + if manual_extra_review: + st["manual_extra_review_instructions"] = manual_extra_review + # 後方互換: 旧 key も manual 指示として保持する。 + st["extra_review_instructions"] = manual_extra_review + state_changed = True + manual = st.get("manual_extra_review_instructions") or st.get("extra_review_instructions") or "" + combined = _combined_review_instructions( + st.get("auto_review_instructions") or "", + manual, + ) + if st.get("review_instructions") != combined: + st["review_instructions"] = combined + state_changed = True + if state_changed: + resume_state_file.write_text( + json.dumps(st, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + info("↻ 追加レビュー観点を state に反映して再開") + tmp_dir = _tmp_dir(worktree) + wt = st.get("worktree_path") or "" + info(f"↻ 前回中断 state から再開(round={len(st.get('rounds', []))})") + print(f'PR={st["current_pr"]}') + print(f'WORKTREE={shlex.quote(str(wt))}') + print(f'TMP_DIR={shlex.quote(str(tmp_dir))}') + print(f'REPO={shlex.quote(str(st.get("repo") or ""))}') + print(f'HEAD_BRANCH={shlex.quote(str(st.get("head_branch") or ""))}') + print(f'BASE_BRANCH={shlex.quote(str(st.get("base_branch") or ""))}') + print(f"IS_OWN_PR={'1' if st.get('is_own_pr') else '0'}") + print(f"EVENT_DOWNGRADE={'1' if st.get('event_downgrade') else '0'}") + print(f"HAS_EXTRA_REVIEW_INSTRUCTIONS={'1' if st.get('review_instructions') else '0'}") + print(f"RESUMED=1") + return + + # 新規 init: プリチェック + me = _sh(["gh", "api", "user", "--jq", ".login"]) + author = _sh(["gh", "pr", "view", str(pr), "--json", "author", "--jq", ".author.login"]) + is_own = (me == author) + event_downgrade = is_own + if is_own: + info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") + + # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する + head_branch = _sh(["gh", "pr", "view", str(pr), "--json", "headRefName", "--jq", ".headRefName"]) + base_branch = _sh(["gh", "pr", "view", str(pr), "--json", "baseRefName", "--jq", ".baseRefName"]) + changed_files = _fetch_changed_files(pr, repo) + auto_review_categories = _classify_changed_files(changed_files) + auto_review = _auto_review_instructions(auto_review_categories) + review_instructions = _combined_review_instructions(auto_review, manual_extra_review) + if not pathlib.Path(worktree).exists(): + _create_worktree(worktree, pr, head_branch) + elif _is_registered_worktree(worktree): + info(f"↻ 既存 worktree 流用: {worktree}") + else: + # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 + # 流用すると git 操作が壊れるため退避して作り直す。 + stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" + pathlib.Path(worktree).rename(stale) + info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") + _create_worktree(worktree, pr, head_branch) + + # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) + tmp_dir = _tmp_dir(worktree) + state_file = tmp_dir / f"cross-review-pr{pr}-state.json" + + # 既存コメントスナップショット(重複指摘防止)。 + # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を + # fix skill の共有スクリプトで一括取得する。 + fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" + r = subprocess.run( + [str(fetch_script), repo, str(pr)], + capture_output=True, text=True, + ) + existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" + if r.returncode == 0: + existing_path.write_text(r.stdout, encoding="utf-8") + else: + die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") + + state = { + "started_at": _now(), + "max_rounds": args.max_rounds, + "rotate_after": args.rotate_after, + "only": args.only, + "current_pr": pr, + "worktree_path": worktree, + "tmp_dir": str(tmp_dir), + "repo": repo, + "head_branch": head_branch, + "base_branch": base_branch, + "pr_author": author, + "is_own_pr": is_own, + "event_downgrade": event_downgrade, + "changed_files": changed_files, + "auto_review_categories": auto_review_categories, + "auto_review_instructions": auto_review, + "manual_extra_review_instructions": manual_extra_review, + # 後方互換: 旧 key は manual 指示を保持する。 + "extra_review_instructions": manual_extra_review, + "review_instructions": review_instructions, + "pr_history": [{"pr": pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], + "rounds": [], + "deferred_nits": [], + "final": None, + } + state_file.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") + info(f"✅ state 初期化: {state_file}") + print(f"PR={pr}") + print(f'WORKTREE={shlex.quote(str(worktree))}') + print(f'TMP_DIR={shlex.quote(str(tmp_dir))}') + print(f'REPO={shlex.quote(str(repo))}') + print(f'HEAD_BRANCH={shlex.quote(str(head_branch))}') + print(f'BASE_BRANCH={shlex.quote(str(base_branch))}') + print(f"IS_OWN_PR={'1' if is_own else '0'}") + print(f"EVENT_DOWNGRADE={'1' if event_downgrade else '0'}") + print(f"HAS_EXTRA_REVIEW_INSTRUCTIONS={'1' if review_instructions else '0'}") + print("RESUMED=0") + + +def cmd_start_round(args: argparse.Namespace) -> None: + """Step 1 — round 開始判定。""" + st = _load(args.pr) + total = len(st["rounds"]) + max_r = st["max_rounds"] + if total >= max_r: + st["final"] = "max_rounds" + st["ended_at"] = _now() + _save(args.pr, st) + die(f"max_rounds={max_r} 到達。中断。", code=1) + + pr = st["current_pr"] + round_no = total + 1 + round_in_pr = sum(1 for r in st["rounds"] if r["pr"] == pr) + 1 + + # round エントリを開く + st["rounds"].append({ + "round": round_no, + "pr": pr, + "started_at": _now(), + }) + _save(args.pr, st) + + info(f"=== Round {round_no} / {max_r} (PR #{pr}, round_in_pr={round_in_pr}) ===") + print(f"ROUND={round_no}") + print(f"ROUND_IN_PR={round_in_pr}") + print(f"PR={pr}") + print(f"MAX_ROUNDS={max_r}") + print(f"ROTATE_AFTER={st['rotate_after']}") + + +def cmd_read_result(args: argparse.Namespace) -> None: + """Step 2.5 — codex/gemini の result.json を state にマージ。""" + agent = args.agent + pr = args.pr + rfile = pathlib.Path(args.file or _resolve_tmp_dir(pr) / f"{agent}-review-pr{pr}-result.json") + if not rfile.exists() or rfile.stat().st_size == 0: + die(f"{agent}: result 未生成 ({rfile})") + + try: + r = json.loads(rfile.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + die(f"{agent}: result.json の parse に失敗 ({rfile}): {exc}", code=3) + + # gemini round 4 指摘: result.json は本来 dict だが、launcher の出力バグや + # 別実行の残骸で list / str が入り込むと `r.get(...)` で AttributeError になる。 + # 不正な review result はバグなので即時 die(code=3) で停止させる。 + if not isinstance(r, dict): + die( + f"{agent}: result.json が dict ではない " + f"({rfile}, type={type(r).__name__})。review launcher の出力形式不正。", + code=3, + ) + + # 別名フィールドへのフォールバック (gemini が `intent` / `comment_count` を使う変則 JSON を + # 書き出す既知のケースに対応する。仕様としては `event` / `comments_count` が正) + intent = r.get("event") or r.get("intent") + posted_as = r.get("posted_as") or intent + comments = r.get("comments_count") + if comments is None: + comments = r.get("comment_count") + + if intent is None: + die( + f"{agent}: result.json に event / intent フィールドが無い ({rfile})。" + " launcher prompt のスキーマ違反の可能性。" + ) + + st = _load(pr) + if not st.get("rounds"): + die(f"{agent}: state.rounds が空。`state.py start-round` を先に呼んでください") + st["rounds"][-1][agent] = { + "intent": intent, + "posted_as": posted_as, + "comments": comments, + "review_url": r.get("review_url"), + "by_severity": r.get("by_severity", {}), + } + _save(pr, st) + info(f"✅ {agent}: intent={intent} posted_as={posted_as} comments={comments}") + + +def cmd_judge(args: argparse.Namespace) -> None: + """Step 3 — intent ベース pass 判定。 + + Exit code: 0=approved, 2=continue, 1=error + """ + pr = args.pr + st = _load(pr) + if not st.get("rounds"): + die("state.rounds が空。`state.py start-round` を先に呼んでください") + last = st["rounds"][-1] + only = st.get("only") + + def is_pass(intent: str | None, severity: dict[str, int] | None) -> bool: + if intent in ("APPROVE", "SKIP"): + return True + if intent == "COMMENT": + sev = severity or {} + return (sev.get("critical", 0) == 0 and sev.get("major", 0) == 0) + return False + + codex_intent = (last.get("codex") or {}).get("intent", "SKIP") + gemini_intent = (last.get("gemini") or {}).get("intent", "SKIP") + codex_sev = (last.get("codex") or {}).get("by_severity") + gemini_sev = (last.get("gemini") or {}).get("by_severity") + + codex_pass = (only == "gemini") or is_pass(codex_intent, codex_sev) + gemini_pass = (only == "codex") or is_pass(gemini_intent, gemini_sev) + + print(f"CODEX_INTENT={codex_intent}") + print(f"GEMINI_INTENT={gemini_intent}") + + if codex_pass and gemini_pass: + st["final"] = "approved" + st["ended_at"] = _now() + _save(pr, st) + info("✅ 両方 APPROVE。収束。") + sys.exit(0) + + info(f"→ codex={codex_intent} gemini={gemini_intent}。修正へ。") + sys.exit(2) + + +def cmd_check_oscillation(args: argparse.Namespace) -> None: + """Step 4 — path:line 重複率を計算。 + + 前ラウンドと現ラウンドで重複が 50% 以上なら final=oscillation で中断。 + rotation 直後は round_in_pr<2 なのでスキップ。 + """ + pr = args.pr + st = _load(pr) + rounds = st["rounds"] + current_pr = st["current_pr"] + same_pr = [r for r in rounds if r["pr"] == current_pr] + if len(same_pr) < 2: + info("⏭ round_in_pr<2: 振動検知スキップ") + sys.exit(2) # continue + + prev_round_no = same_pr[-2]["round"] + curr_round_no = same_pr[-1]["round"] + + def collect_keys(round_no: int) -> set[str]: + keys: set[str] = set() + for agent in ("codex", "gemini"): + p = _payload_path(agent, pr, round_no) + if not p.exists(): + continue + try: + payload = json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + # gemini round 4 指摘: payload は本来 dict (comments: [...]) だが、 + # launcher のバグで list / str が入り込むと `payload.get(...)` で + # AttributeError になる。不正な review payload はバグなので + # 即時 die(code=3) で停止させる。 + if not isinstance(payload, dict): + die( + f"{agent}: payload.json が dict ではない " + f"({p}, type={type(payload).__name__})。" + " review launcher の出力形式不正。", + code=3, + ) + for c in payload.get("comments", []): + if not isinstance(c, dict): + # comments エントリが dict でない場合も同様に致命扱い + die( + f"{agent}: payload.comments のエントリが dict ではない " + f"({p}, type={type(c).__name__})。", + code=3, + ) + path = c.get("path") + line = c.get("line") or c.get("start_line") + if path and line is not None: + keys.add(f"{path}:{line}") + return keys + + prev = collect_keys(prev_round_no) + curr = collect_keys(curr_round_no) + if not curr: + info("⏭ 現ラウンドの payload なし: 振動検知スキップ") + sys.exit(2) + overlap = prev & curr + ratio = len(overlap) / len(curr) + info(f"振動検知: overlap={len(overlap)}/{len(curr)} ({ratio:.0%})") + + if ratio >= 0.5: + st["final"] = "oscillation" + st["ended_at"] = _now() + _save(pr, st) + die(f"振動検知 — 同一箇所が {ratio:.0%} 重複。中断。", code=4) + sys.exit(2) + + +def _count(v: Any) -> int: + """int(件数) でも list でも None でも件数(int)に正規化する。 + + fix 結果スキーマ上 deferred/rejected/resolved_threads は list が正だが、 + fix サブエージェントが int(件数) を書いてしまうケースがあり、その場合に + len() が `TypeError: object of type 'int' has no len()` で落ちるのを防ぐ。 + """ + if isinstance(v, bool): + # bool は int のサブクラスだが件数として扱わない + return 0 + if isinstance(v, int): + return v + if isinstance(v, dict): + # LLM が単一要素を list ではなく dict 単体で返すケースを 1 件として扱う。 + return 1 + if isinstance(v, (list, tuple)): + return len(v) + if isinstance(v, str) and v.strip().isdigit(): + # LLM が件数を数値文字列 (例: "3" や " 3 ") で返すケースを許容する。 + # strip 後 isdigit() なので前後空白を許し、負号・小数点は引き続き弾く + # (件数は非負整数なので十分)。 + return int(v.strip()) + return 0 + + +def cmd_merge_fix(args: argparse.Namespace) -> None: + """Step 5 後段 — fix サブエージェント戻り値を state にマージ + CI 分類。 + + Exit code: 0=continue, 3=ci-code-fail (final=error) + """ + pr = args.pr + + # state を先に読み、fallback 検証用の round 開始時刻を取得する + # (round 開始前の古いファイルや、別リポジトリの同番号 PR の戻り値を + # 誤マージするのを防ぐ)。 + st = _load(pr) + if not st.get("rounds"): + die("state.rounds が空。`state.py start-round` を先に呼んでください", code=3) + round_started_ts = _round_started_unixtime(st["rounds"][-1]) + + # 戻り値ファイルの探索順: + # 1. --file 明示 (ユーザー指定なので mtime/pr 検証はスキップ) + # 2. $TMP_DIR/fix-pr-result.json (正規; _tmp_dir() 解決先) + # 3. /tmp/fix-pr-result.json (旧プロンプトで /tmp を指定したサブエージェント救済) + # 2, 3 は PR 番号だけで命名されているため、別 round / 別リポジトリの + # 古い結果を拾わないよう mtime と (あれば) JSON 内の `pr` で検証する。 + explicit = pathlib.Path(args.file) if args.file else None + canonical_path = _resolve_tmp_dir(pr) / f"fix-pr{pr}-result.json" + legacy_tmp_path = pathlib.Path(f"/tmp/fix-pr{pr}-result.json") + # (path, is_canonical) のタプル: 正規パス (canonical) の parse 失敗は die(code=3) する + fallback_candidates: list[tuple[pathlib.Path, bool]] = [ + (canonical_path, True), + (legacy_tmp_path, False), + ] + + ffile: pathlib.Path | None = None + fix: dict[str, Any] | None = None + if explicit is not None: + # codex round 4 指摘: `--file` 明示時は fallback 探索に進まず即時失敗させる。 + # ユーザーが特定ファイルを指定しているのに、それが存在しない / 空 / JSON 不正 + # だった場合、無言で fallback に流れて別実行の戻り値を誤マージすると事故になる。 + if not explicit.exists(): + die( + f"--file で指定されたパスが存在しません: {explicit}", + code=3, + ) + if explicit.stat().st_size == 0: + die( + f"--file で指定されたファイルが空です: {explicit}", + code=3, + ) + try: + fix = json.loads(explicit.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + die( + f"--file 指定の fix 戻り値ファイルの読み取り / parse に失敗 " + f"({explicit}): {exc}", + code=3, + ) + # gemini round 3 指摘: `--file` で `list` 等の non-dict JSON が渡されると + # 後続の `fix.get(...)` でクラッシュする。即時 die(code=3) で中断。 + if not isinstance(fix, dict): + die( + f"--file 指定の fix 戻り値ファイルが dict ではない " + f"({explicit}, type={type(fix).__name__})。" + " fix サブエージェント出力の形式不正。", + code=3, + ) + ffile = explicit + # 明示指定は stale 検証スキップ + else: + for c, is_canonical in fallback_candidates: + if not (c.exists() and c.stat().st_size > 0): + continue + is_fresh, parsed = _is_fresh_fix_result(c, pr, round_started_ts, is_canonical=is_canonical) + if not is_fresh: + continue + ffile = c + fix = parsed # 既にパース済みのデータを再利用 (gemini round 2 指摘の性能改善) + break + + if ffile is None or fix is None: + checked = ([str(explicit)] if explicit else []) + [str(c) for c, _ in fallback_candidates] + die( + "fix サブエージェントが戻り値ファイルを生成しなかった " + f"(checked: {checked})", + code=3, + ) + + # key 名 fallback (サブエージェントが別名で書いた場合の救済)。 + # 正規は fix_commit / fixed_count、別名は commit_sha / fixed のみ受理する。 + fix_commit = fix.get("fix_commit") or fix.get("commit_sha") + fixed_count = fix.get("fixed_count") + if fixed_count is None: + fixed_count = fix.get("fixed", 0) + + # `st` は冒頭の fallback 検証で既に load 済み。 + round_no = st["rounds"][-1]["round"] + + # deferred は list が正だが、LLM がスキーマを無視して文字列リスト + # (例: ["nit: ..."]) や単一 dict、int(件数) を返すケースがある。後段の + # deferred_nits 展開ループは dict 以外をスキップするため、まず dict 要素のみへ + # 正規化する (単一 dict は 1 件として包む)。 + _deferred_raw = fix.get("deferred") + if isinstance(_deferred_raw, list): + _deferred_nits = [d for d in _deferred_raw if isinstance(d, dict)] + elif isinstance(_deferred_raw, dict): # 単一 dict フォールバック (gemini #3) + _deferred_nits = [_deferred_raw] + else: + _deferred_nits = [] + + # 保存件数の単一整合ルール: + # - 構造化データ (list / dict) は per-item を保持できるので、展開件数 + # (len(_deferred_nits)) を保存し deferred_nits の件数と一致させる。 + # - int / 数値文字列は per-item データを失った「劣化表現」なので、件数を + # 失わないよう _count() の値を保存する (展開はできないので nits は空)。 + if isinstance(_deferred_raw, (list, dict)): + _deferred_count = len(_deferred_nits) + else: + _deferred_count = _count(_deferred_raw) + + st["rounds"][-1]["fix"] = { + "commit": fix_commit, + "fixed": fixed_count, + # deferred は上記の単一整合ルールで算出した件数を保存する。 + # resolved_threads / rejected は件数しか保存せず後段ループが無いため _count() で可。 + "deferred": _deferred_count, + "rejected": _count(fix.get("rejected")), + "resolved_threads": _count(fix.get("resolved_threads")), + "ci": fix.get("ci_status"), + "ci_failed_checks": fix.get("ci_failed_checks", []) or [], + "ci_note": fix.get("ci_note"), + "by_severity": fix.get("by_severity", {}), + } + st["rounds"][-1]["ended_at"] = _now() + for d in _deferred_nits: + st["deferred_nits"].append({**d, "pr": pr, "round": round_no}) + _save(pr, st) + + # CI 分類 + if (fix.get("ci_status") or "").upper() != "FAILURE": + info(f"✅ fix マージ完了 (commit={fix_commit} fixed={fixed_count})") + return + + code_patterns = ("pint", "larastan", "phpstan", "test", "lint", "type", + "build", "ruff", "eslint", "tsc", "mypy") + meta_patterns = ("check_pr_requirements", "assignees", "reviewers", "labels", "meta") + failed = fix.get("ci_failed_checks") or [] + code_fail = False + meta_fail = False + for name in failed: + low = name.lower() + if any(p in low for p in meta_patterns): + meta_fail = True + elif any(p in low for p in code_patterns): + code_fail = True + else: + code_fail = True # 不明は code-fail(保守的) + + if code_fail: + st["final"] = "error" + st["ended_at"] = _now() + _save(pr, st) + die(f"コード関連 CI 失敗。中断: {failed}", code=3) + + # meta only: 継続 + note = f"メタチェックのみ失敗: {failed} — コードと無関係のため継続" + st["rounds"][-1]["fix"]["ci_note"] = note + _save(pr, st) + info(f"⚠ メタチェックのみ失敗 ({failed}) — 継続") + + +def cmd_should_rotate(args: argparse.Namespace) -> None: + """Step 6 — PR ローテーション要否。Exit 0=rotate, 2=keep. + + 判定は ``round_in_pr >= rotate_after && total < max_rounds`` のみで、 + rotate-pr.sh の ``--mode light|squash`` どちらでも同じ条件を使う。 + state.json の key は ``STATE_PR`` (最初に init した PR 番号) で固定なので、 + light モードで head_branch が変わらない場合でも整合する。 + """ + pr = args.pr + st = _load(pr) + current_pr = st["current_pr"] + round_in_pr = sum(1 for r in st["rounds"] if r["pr"] == current_pr) + total = len(st["rounds"]) + rotate_after = st["rotate_after"] + max_r = st["max_rounds"] + if round_in_pr >= rotate_after and total < max_r: + info(f"🔄 PR #{current_pr} が {round_in_pr} round 経過 — ローテーション必要") + print(f"CURRENT_PR={current_pr}") + print(f"ROUND_IN_PR={round_in_pr}") + sys.exit(0) + sys.exit(2) + + +def cmd_set_current_pr(args: argparse.Namespace) -> None: + """PR ローテーション完了後の state 更新。 + + rotate-pr.sh の light / squash どちらでも、新 PR 番号を受け取って + ``current_pr`` を切り替え、``pr_history`` に新 PR エントリを追加する。 + state.json のファイル名は ``STATE_PR`` (= ``args.pr``) ベースで不変なので、 + light モードで head_branch が変わらないケースでも問題なく追跡できる。 + """ + pr = args.pr # 旧 PR (state file の key) + new_pr = args.new_pr + st = _load(pr) + old_pr = st["current_pr"] + now = _now() + # 旧 PR の history を closed に + for h in st["pr_history"]: + if h["pr"] == old_pr and h["closed_at"] is None: + h["closed_at"] = now + h["rounds"] = sum(1 for r in st["rounds"] if r["pr"] == old_pr) + break + st["pr_history"].append({"pr": new_pr, "opened_at": now, "closed_at": None, "rounds": 0}) + st["current_pr"] = new_pr + _save(pr, st) + info(f"✅ current_pr: {old_pr} → {new_pr}") + + +def cmd_report(args: argparse.Namespace) -> None: + """Step 8 — deferred nit + ラウンドサマリ表示。""" + pr = args.pr + st = _load(pr) + final = st.get("final") or "in_progress" + total = len(st["rounds"]) + prs = [h["pr"] for h in st["pr_history"]] + rotated = max(0, len(prs) - 1) + + print(f"## 最終ステータス: {final}") + print(f"## 総ラウンド数: {total} / PR数: {len(prs)} (rotated {rotated} 回)") + print() + print("## PR 履歴") + for h in st["pr_history"]: + state_str = "closed" if h.get("closed_at") else "open" + print(f"- #{h['pr']} ({state_str}, {h.get('rounds', 0)} rounds)") + print() + print("## ラウンドサマリ") + print("| round | PR | codex | gemini | fix | CI |") + print("|---|---|---|---|---|---|") + for r in st["rounds"]: + codex = r.get("codex") or {} + gemini = r.get("gemini") or {} + fix = r.get("fix") or {} + codex_s = f"{codex.get('intent', '-')} ({codex.get('comments', '-')})" if codex else "-" + gemini_s = f"{gemini.get('intent', '-')} ({gemini.get('comments', '-')})" if gemini else "-" + fix_s = "-" + if fix: + fix_s = f"{(fix.get('commit') or '')[:7]} ({fix.get('fixed', 0)} fixed, {fix.get('deferred', 0)} deferred)" + ci_s = fix.get("ci") or "-" + print(f"| {r['round']} | #{r['pr']} | {codex_s} | {gemini_s} | {fix_s} | {ci_s} |") + print() + + nits = st.get("deferred_nits") or [] + if nits: + print(f"## 残 deferred nit ({len(nits)} 件)") + for n in nits: + print(f"- [{n.get('severity')}] {n.get('path')}:{n.get('line')} — {n.get('summary')}") + print() + print("これらの nit を一括対応する場合は再度 `/ndf:fix ` を起動してください。") + else: + print("## 残 deferred nit: なし") + + +# ---------------- main ---------------- + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + sub = p.add_subparsers(dest="cmd", required=True) + + sp = sub.add_parser("init", help="Step 0 — state 初期化 or 再開") + sp.add_argument("pr", type=int) + sp.add_argument("--max-rounds", type=int, default=12) + sp.add_argument("--rotate-after", type=int, default=8) + sp.add_argument("--only", choices=["codex", "gemini"], default=None) + sp.add_argument("--worktree", default=None) + sp.add_argument( + "--focus", + default=None, + help="追加レビュー観点。例: ドキュメントとコードの整合性を重点的に確認", + ) + sp.add_argument( + "--extra-instructions-file", + default=None, + help="追加レビュー観点を記載した UTF-8 テキストファイル", + ) + sp.set_defaults(func=cmd_init) + + sp = sub.add_parser("start-round", help="Step 1 — round 開始判定") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_start_round) + + sp = sub.add_parser("read-result", help="Step 2.5 — review result を state にマージ") + sp.add_argument("pr", type=int) + sp.add_argument("agent", choices=["codex", "gemini"]) + sp.add_argument("--file", default=None) + sp.set_defaults(func=cmd_read_result) + + sp = sub.add_parser("judge", help="Step 3 — intent ベース pass 判定 (0=approved/2=continue)") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_judge) + + sp = sub.add_parser("check-oscillation", help="Step 4 — path:line 重複率を計算") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_check_oscillation) + + sp = sub.add_parser("merge-fix", help="Step 5 post — fix 戻り値マージ + CI 分類") + sp.add_argument("pr", type=int) + sp.add_argument("--file", default=None) + sp.set_defaults(func=cmd_merge_fix) + + sp = sub.add_parser("should-rotate", help="Step 6 — rotate 要否 (0=rotate/2=keep)") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_should_rotate) + + sp = sub.add_parser("set-current-pr", help="rotation 後の current_pr 更新") + sp.add_argument("pr", type=int, help="state file の元 PR") + sp.add_argument("new_pr", type=int) + sp.set_defaults(func=cmd_set_current_pr) + + sp = sub.add_parser("report", help="Step 8 — deferred nit + サマリ表示") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_report) + + args = p.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf-shared/skills/cross-review/scripts/wait-review.sh b/plugins/ndf-shared/skills/cross-review/scripts/wait-review.sh new file mode 100755 index 0000000..8741d36 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/scripts/wait-review.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Wait for codex / gemini review processes — monitor.py の薄いラッパ。 +# +# Usage: wait-review.sh [codex|gemini|both] [--timeout SEC] [--stall-timeout SEC] +# +# 既定値: +# timeout 1800s (= 30 min) env MONITOR_TIMEOUT で上書き +# stall-timeout 600s (= 10 min) env MONITOR_STALL で上書き +# poll 15s env MONITOR_POLL で上書き +# +# Exit codes は monitor.py に準拠: +# 0 OK +# 1 USAGE / IO error +# 2 TIMEOUT +# 3 NO_RESULT (プロセス終了したが result.json 未生成) +# 4 EARLY_ERROR (err.log に致命的パターン) +# 5 STALLED (err.log 進捗なし) +# 6 PIDFILE_BAD (pidfile 不正 / プロセス未起動) +# +# 旧 wait_codex / wait_gemini (sentinel + pidfile のみ) は信頼性が低かったため +# Python 側に多軸監視を集約した。本ラッパは既存呼び出し互換のために残す。 + +set -euo pipefail + +PR=${1:?PR required} +TARGET=${2:-both} +shift 2 || shift $# + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +exec "$SCRIPT_DIR/monitor.py" "$PR" "$TARGET" "$@" diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/__init__.py b/plugins/ndf-shared/skills/cross-review/tests/__init__.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/__init__.py rename to plugins/ndf-shared/skills/cross-review/tests/__init__.py diff --git a/plugins/ndf-shared/skills/cross-review/tests/conftest.py b/plugins/ndf-shared/skills/cross-review/tests/conftest.py new file mode 100644 index 0000000..a5b257b --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/conftest.py @@ -0,0 +1,47 @@ +"""pytest 共通フィクスチャ。 + +`scripts/state.py` は uv self-contained script として `#!/usr/bin/env -S uv run --script` +で起動される運用だが、テストでは関数を直接 import したい。 +importlib.util で source loader 経由で読み込む。 +""" +from __future__ import annotations + +import importlib.util +import pathlib +import sys +import types + +_HERE = pathlib.Path(__file__).resolve().parent +_SCRIPT = _HERE.parent / "scripts" / "state.py" +_MONITOR = _HERE.parent / "scripts" / "monitor.py" + + +def _load_module(name: str, path: pathlib.Path) -> types.ModuleType: + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"failed to load {path}") + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +def _load_state_module() -> types.ModuleType: + return _load_module("cross_review_state", _SCRIPT) + + +def _load_monitor_module() -> types.ModuleType: + return _load_module("cross_review_monitor", _MONITOR) + + +import pytest + + +@pytest.fixture(scope="session") +def state_mod() -> types.ModuleType: + return _load_state_module() + + +@pytest.fixture(scope="session") +def monitor_mod() -> types.ModuleType: + return _load_monitor_module() diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_default_worktree_base.py b/plugins/ndf-shared/skills/cross-review/tests/test_default_worktree_base.py new file mode 100644 index 0000000..b4d38da --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/test_default_worktree_base.py @@ -0,0 +1,41 @@ +"""state.py `_default_worktree_base()` / `_repo_slug()` の解決テスト。 + +優先順位: + 1. 環境変数 NDF_WORKTREE_BASE + 2. <システム tmpdir>/ndf-worktrees (非永続領域) + +worktree path は `/--/pr` 形式で、他リポジトリの +同一 PR 番号と衝突しない。 +""" +from __future__ import annotations + +import pathlib + + +def test_env_override_takes_precedence(monkeypatch, tmp_path, state_mod): + explicit = tmp_path / "custom-base" + monkeypatch.setenv("NDF_WORKTREE_BASE", str(explicit)) + assert state_mod._default_worktree_base() == explicit + + +def test_default_is_tmpdir_ndf_worktrees(monkeypatch, tmp_path, state_mod): + """既定では永続 volume ではなくシステム tmpdir 配下を使う。""" + monkeypatch.delenv("NDF_WORKTREE_BASE", raising=False) + monkeypatch.setattr(state_mod.tempfile, "gettempdir", lambda: str(tmp_path)) + assert state_mod._default_worktree_base() == tmp_path / "ndf-worktrees" + + +def test_repo_slug_is_path_safe(state_mod): + assert state_mod._repo_slug("devbasex/ai-plugins") == "devbasex--ai-plugins" + + +def test_is_registered_worktree_rejects_foreign_dir(monkeypatch, tmp_path, state_mod): + """`git worktree list` に載っていないパスは流用しない (別リポジトリの残骸対策)。""" + registered = tmp_path / "registered" + foreign = tmp_path / "foreign" + monkeypatch.setattr( + state_mod, "_sh", + lambda cmd, check=True: f"worktree {registered}\nHEAD abc\n", + ) + assert state_mod._is_registered_worktree(str(registered)) is True + assert state_mod._is_registered_worktree(str(foreign)) is False diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_monitor_early_error.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_early_error.py new file mode 100644 index 0000000..a7e3f6b --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_early_error.py @@ -0,0 +1,126 @@ +"""monitor.py `_scan_early_fatal()` の誤検知防止テスト。 + +回帰: SKILL.md / docs/*.md 内の以下のような行は **fatal 検知してはいけない**。 +- Markdown 表セル (`| ... | quota exceeded ... |`) +- backtick で囲まれた quote (`` `quota exceeded` ``) +- 日本語クォートで囲まれた quote (`「quota exceeded」`) +- ダブル/シングルクォートで囲まれた **文字列リテラル** (`"quota exceeded: ..."`) +- grep / ripgrep 形式の **ソース引用行** (`path/to/file.py:22: "quota exceeded..."`) +codex がレビュー対象の doc / テストコードを echo した際に上記が err.log に書かれることで、 +従来の monitor.py は無関係なドキュメント・コード記述を fatal とみなしてプロセスを kill していた。 +""" +from __future__ import annotations + +import pathlib + + +def _write(p: pathlib.Path, content: str) -> pathlib.Path: + p.write_text(content, encoding="utf-8") + return p + + +def test_real_quota_exceeded_is_detected(tmp_path, monitor_mod): + """行頭の本物 `quota exceeded` は依然として fatal 扱い。""" + log = _write(tmp_path / "err.log", "quota exceeded: please upgrade\n") + assert monitor_mod._scan_early_fatal(log) is not None + + +def test_markdown_table_row_is_benign(tmp_path, monitor_mod): + """Markdown 表セル行内のキーワードは doc 引用として無視。""" + log = _write( + tmp_path / "err.log", + "| early-error | `^Error:` / 「quota exceeded」「sandbox error」を含む行 |\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_backtick_quoted_keyword_is_benign(tmp_path, monitor_mod): + """backtick 引用内のキーワードは doc 引用として無視。""" + log = _write( + tmp_path / "err.log", + "explanation of `quota exceeded` pattern handling here\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_japanese_quote_wrapped_keyword_is_benign(tmp_path, monitor_mod): + """日本語「」内のキーワードは doc 引用として無視。""" + log = _write( + tmp_path / "err.log", + "「quota exceeded」を含む行を検出 (diff/doc 引用文中の同語句は誤検知しない)\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_sandbox_error_in_table_is_benign(tmp_path, monitor_mod): + """表セル内の `sandbox error` も無視。""" + log = _write( + tmp_path / "err.log", + "| pattern | codex 固有: 「sandbox error」を含む行を検出 |\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_real_sandbox_error_still_detected(tmp_path, monitor_mod): + """素の `sandbox error` は依然として fatal 扱い。""" + log = _write(tmp_path / "err.log", "Internal sandbox error: cannot start\n") + assert monitor_mod._scan_early_fatal(log) is not None + + +def test_grep_style_source_citation_is_benign(tmp_path, monitor_mod): + """codex がレビュー対象のテストコードを grep 形式 (`path.py:22: code`) で + echo した行は fatal 扱いしない。 + + 回帰: 本 skill 自身の tests/test_monitor_early_error.py を cross-review した際、 + `quota exceeded` を含むテスト用文字列リテラル行が echo され、誤って + EARLY_ERROR で codex が kill された (PR #23 round 2 で実際に発生)。 + """ + log = _write( + tmp_path / "err.log", + '/work/worktrees/pr23/plugins/ndf/skills/cross-review/tests/' + 'test_monitor_early_error.py:22: log = _write(tmp_path / "err.log", ' + '"quota exceeded: please upgrade\\n")\n', + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_double_quoted_string_literal_is_benign(tmp_path, monitor_mod): + """ダブルクォート文字列リテラル内のキーワードは benign(コード片の echo)。""" + log = _write( + tmp_path / "err.log", + ' raise RuntimeError("quota exceeded: please upgrade")\n', + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_single_quoted_string_literal_is_benign(tmp_path, monitor_mod): + """シングルクォート文字列リテラル内のキーワードも benign。""" + log = _write( + tmp_path / "err.log", + " msg = 'sandbox error occurred in test fixture'\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_match_is_quoted_double_quote(monitor_mod): + """`_match_is_quoted()` がダブルクォート文字列リテラルを検出する。""" + line = 'raise RuntimeError("quota exceeded: please upgrade")' + start = line.index("quota") + end = start + len("quota exceeded") + assert monitor_mod._match_is_quoted(line, start, end) + + +def test_match_is_quoted_helper(monitor_mod): + """`_match_is_quoted()` 単体テスト。""" + line_backtick = "see `quota exceeded` doc" + start = line_backtick.index("quota") + end = start + len("quota exceeded") + assert monitor_mod._match_is_quoted(line_backtick, start, end) + + line_jp = "「quota exceeded」と書かれた行" + start = line_jp.index("quota") + end = start + len("quota exceeded") + assert monitor_mod._match_is_quoted(line_jp, start, end) + + line_raw = "quota exceeded happened" + assert not monitor_mod._match_is_quoted(line_raw, 0, len("quota exceeded")) diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_monitor_import_safety.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_import_safety.py new file mode 100644 index 0000000..36efc48 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_import_safety.py @@ -0,0 +1,105 @@ +"""monitor.py の import 時 env 評価 safety テスト (PLAN21 round 6 / 最終). + +codex round 5 指摘: `DEFAULT_TIMEOUT` / `DEFAULT_STALL` / `DEFAULT_POLL` を +`int(os.environ.get(name, ...))` で **import 時** に評価していると、 +`MONITOR_STALL=abc` のような非数値 env が設定されているだけで +`_agent_stall_default()` に到達する前に `int()` の `ValueError` で +monitor.py の import そのものが落ち、監視プロセスが起動できなくなる。 + +修正方針: + - `DEFAULT_TIMEOUT` / `DEFAULT_STALL` / `DEFAULT_POLL` は import 時に + **固定数値** (420 / 180 / 15) で保持する + - env の解釈は `_agent_stall_default()` / `_safe_int_env()` 内で + try/except 付きで行う (既に round 4 で実装済み) + +本テストは fixture 経由ではなく **毎回 fresh に source loader で import** し、 +非数値 env が設定された状態でも import が成功することを確認する。 +""" +from __future__ import annotations + +import importlib.util +import pathlib +import sys +import types + +import pytest + + +_HERE = pathlib.Path(__file__).resolve().parent +_MONITOR = _HERE.parent / "scripts" / "monitor.py" + + +def _fresh_import_monitor(name: str = "cross_review_monitor_fresh") -> types.ModuleType: + """毎回 fresh に monitor.py を import する (sys.modules キャッシュを使わない)。 + + session-scoped fixture (`monitor_mod`) は最初の import 時の env を保持してしまい + 本テストでは「非数値 env がセットされた状態での import が安全か」を検証できない。 + """ + sys.modules.pop(name, None) + spec = importlib.util.spec_from_file_location(name, _MONITOR) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +def test_import_succeeds_with_non_numeric_monitor_stall(monkeypatch): + """MONITOR_STALL=abc でも monitor.py の import が成功すること (codex round 5).""" + monkeypatch.setenv("MONITOR_STALL", "abc") + mod = _fresh_import_monitor("cross_review_monitor_fresh_stall") + # 固定 default が保たれる + assert mod.DEFAULT_STALL == 180 + # 呼び出し時の解決も builtin にフォールバック (既存 round 4 挙動と同じ) + assert mod._agent_stall_default("codex") == 180 + assert mod._agent_stall_default("gemini") == 480 + + +def test_import_succeeds_with_non_numeric_monitor_timeout(monkeypatch): + """MONITOR_TIMEOUT=xxx でも monitor.py の import が成功すること.""" + monkeypatch.setenv("MONITOR_TIMEOUT", "xxx") + mod = _fresh_import_monitor("cross_review_monitor_fresh_timeout") + # 固定 default が保たれる + assert mod.DEFAULT_TIMEOUT == 420 + # safe parse ヘルパも fallback を返す + assert mod._safe_int_env("MONITOR_TIMEOUT", mod.DEFAULT_TIMEOUT) == 420 + + +def test_import_succeeds_with_non_numeric_monitor_poll(monkeypatch): + """MONITOR_POLL=foo でも monitor.py の import が成功すること.""" + monkeypatch.setenv("MONITOR_POLL", "foo") + mod = _fresh_import_monitor("cross_review_monitor_fresh_poll") + assert mod.DEFAULT_POLL == 15 + assert mod._safe_int_env("MONITOR_POLL", mod.DEFAULT_POLL) == 15 + + +def test_import_succeeds_with_all_non_numeric_envs(monkeypatch): + """3 つ全て非数値でも import に失敗しないこと (組み合わせ防御).""" + monkeypatch.setenv("MONITOR_STALL", "abc") + monkeypatch.setenv("MONITOR_TIMEOUT", "xxx") + monkeypatch.setenv("MONITOR_POLL", "???") + mod = _fresh_import_monitor("cross_review_monitor_fresh_all") + assert mod.DEFAULT_STALL == 180 + assert mod.DEFAULT_TIMEOUT == 420 + assert mod.DEFAULT_POLL == 15 + + +def test_safe_int_env_returns_value_when_numeric(monkeypatch, monitor_mod): + """数値が設定されている場合は素直に int を返す.""" + monkeypatch.setenv("SOME_TEST_INT_ENV", "42") + assert monitor_mod._safe_int_env("SOME_TEST_INT_ENV", 1) == 42 + + +def test_safe_int_env_returns_fallback_when_missing(monkeypatch, monitor_mod): + """env が未設定なら fallback を返す.""" + monkeypatch.delenv("SOME_TEST_INT_ENV", raising=False) + assert monitor_mod._safe_int_env("SOME_TEST_INT_ENV", 99) == 99 + + +def test_safe_int_env_warns_on_non_numeric(monkeypatch, monitor_mod, capsys): + """非数値時は stderr に警告を出して fallback を返す.""" + monkeypatch.setenv("SOME_TEST_INT_ENV", "not-a-number") + assert monitor_mod._safe_int_env("SOME_TEST_INT_ENV", 7) == 7 + captured = capsys.readouterr() + assert "SOME_TEST_INT_ENV" in captured.err + assert "int に変換できません" in captured.err diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_monitor_progress_log.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_progress_log.py new file mode 100644 index 0000000..7b98015 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_progress_log.py @@ -0,0 +1,23 @@ +"""monitor.py の progress.log heartbeat 補助関数テスト。""" + + +def test_tail_last_nonempty_line_returns_latest_marker(tmp_path, monitor_mod): + log = tmp_path / "gemini-review-pr1-progress.log" + log.write_text("start: review PR #1\n\nscan: diff\n \npost: submit review\n") + + assert monitor_mod._tail_last_nonempty_line(log) == "post: submit review" + + +def test_tail_last_nonempty_line_handles_missing_file(tmp_path, monitor_mod): + assert monitor_mod._tail_last_nonempty_line(tmp_path / "missing.log") == "" + + +def test_safe_size_handles_missing_file(tmp_path, monitor_mod): + assert monitor_mod._safe_size(tmp_path / "missing.log") == 0 + + +def test_tail_last_nonempty_line_drops_partial_first_line(tmp_path, monitor_mod): + log = tmp_path / "gemini-review-pr1-progress.log" + log.write_text("partial line without beginning\npost: submit review\n", encoding="utf-8") + + assert monitor_mod._tail_last_nonempty_line(log, limit=28) == "post: submit review" diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_monitor_result_age.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_result_age.py new file mode 100644 index 0000000..f3e2a28 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_result_age.py @@ -0,0 +1,330 @@ +"""monitor.py の result.json + age fallback (RESULT_AGE_GRACE) テスト。 + +gemini がレビュー完了後にプロセスがハングするケースで、result.json の +mtime が RESULT_AGE_GRACE 秒以上前なら OK 判定する fallback の検証。 +""" +from __future__ import annotations + +import json +import os +import pathlib +import time +from unittest import mock + + +def test_result_age_grace_constant(monitor_mod): + assert hasattr(monitor_mod, "RESULT_AGE_GRACE") + assert monitor_mod.RESULT_AGE_GRACE == 30 + + +def test_gemini_result_age_fallback_triggers_ok(monitor_mod, tmp_path): + """result.json が RESULT_AGE_GRACE 秒以上前に書かれていれば OK で返る。""" + pr = 999 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12345") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + # time.time() を side_effect で進める: + # call 1 (started_wall): base_time + # call 2+ (result age check): base_time + 40 + # result_mtime = base_time + 5 → started_wall(base_time) 以降かつ age=35s >= 30s + base_time = time.time() + result_mtime = base_time + 5 + os.utime(result, (result_mtime, result_mtime)) + + time_call_count = [0] + def fake_time(): + time_call_count[0] += 1 + if time_call_count[0] == 1: + return base_time + return base_time + 40 + + kill_called = [] + poll_count = [0] + + def fake_pid_alive(pid): + return True + + def fake_kill_pid(pid, sigterm_grace=3.0): + kill_called.append(pid) + + def fake_sleep(s): + poll_count[0] += 1 + if poll_count[0] > 5: + raise RuntimeError("too many polls — fallback should have triggered") + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=fake_kill_pid), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), + mock.patch("time.sleep", side_effect=fake_sleep), + mock.patch("time.time", side_effect=fake_time), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert "result.json exists for" in st.detail + assert 12345 in kill_called + + +def test_gemini_result_age_too_young_continues(monitor_mod, tmp_path): + """result.json が書かれたばかり (age < RESULT_AGE_GRACE) なら fallback しない。""" + pr = 998 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12346") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + # mtime を 5 秒前に設定 (RESULT_AGE_GRACE=30 未満) + recent_mtime = time.time() - 5 + os.utime(result, (recent_mtime, recent_mtime)) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 2: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + # result.json は young なので fallback せず、プロセス終了後の通常 OK になる + assert st.status == "OK" + assert st.exit_code == 0 + assert "process exited" in st.detail + + +def test_fresh_but_young_result_continues_monitoring(monitor_mod, tmp_path): + """started_wall 後に書かれたが age < RESULT_AGE_GRACE なら fallback せず監視継続。""" + pr = 994 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12350") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + + # time.time() side_effect: + # call 1 (started_wall): base_time + # call 2+ (age check): base_time + 10 → age = 5s < 30s + base_time = time.time() + result_mtime = base_time + 5 + os.utime(result, (result_mtime, result_mtime)) + + time_call_count = [0] + def fake_time(): + time_call_count[0] += 1 + if time_call_count[0] == 1: + return base_time + return base_time + 10 # age = 10 - 5 = 5s < 30s + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 2: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), + mock.patch("time.sleep", side_effect=fake_sleep), + mock.patch("time.time", side_effect=fake_time), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + # age < 30s なので fallback せず、プロセス終了後の通常 OK + assert st.status == "OK" + assert "process exited" in st.detail + + +def test_codex_sentinel_takes_priority(monitor_mod, tmp_path): + """codex は sentinel + result.json が先に発火し、age fallback は通らない。""" + pr = 997 + agent = "codex" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12347") + err_log.write_text("some output\ntokens used\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + old_mtime = time.time() - 60 + os.utime(result, (old_mtime, old_mtime)) + + kill_called = [] + + def fake_pid_alive(pid): + return True + + def fake_kill_pid(pid, sigterm_grace=3.0): + kill_called.append(pid) + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=fake_kill_pid), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + # codex sentinel チェックによる OK + assert "codex sentinel" in st.detail + assert 12347 in kill_called + + +def test_stale_result_json_from_previous_round_ignored(monitor_mod, tmp_path): + """前 round の古い result.json (mtime < started_wall) は fallback しない。""" + pr = 996 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12348") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + # mtime を「現在の 120 秒前」に設定 → started_wall より前 → stale + stale_mtime = time.time() - 120 + os.utime(result, (stale_mtime, stale_mtime)) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 2: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + # stale result.json なので fallback せず、プロセス終了後の通常 OK + assert st.status == "OK" + assert "process exited" in st.detail + + +def test_cmdline_not_validated_skips_fallback(monitor_mod, tmp_path): + """cmdline 未検証 (None) だと age fallback は発火しない。""" + pr = 995 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12349") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + base_time = time.time() + result_mtime = base_time + 5 + os.utime(result, (result_mtime, result_mtime)) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 2: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + # cmdline_matches が None → cmdline_validated は False のまま + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + # cmdline 未検証なので fallback せず、プロセス終了後の通常 OK + assert st.status == "OK" + assert "process exited" in st.detail diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_monitor_stall_default.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_stall_default.py new file mode 100644 index 0000000..77a4f6b --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_stall_default.py @@ -0,0 +1,119 @@ +"""monitor.py の per-agent stall timeout 既定値テスト (PLAN21). + +`_agent_stall_default(agent)` の解決優先度: + 1. env `MONITOR_STALL_` (per-agent 明示) + 2. env `MONITOR_STALL` (両 agent 共通) + 3. `DEFAULT_STALL_AGENT_BUILTIN[agent]` (codex=180, gemini=480) + +gemini は err.log にほぼ進捗を出さないため、ビルトイン既定を 480s と大きめに +取って 1 度目の STALLED 誤検知を避ける。codex は従来通り 180s で変更なし。 +""" +from __future__ import annotations + +import pytest + + +def test_builtin_default_codex(monkeypatch, monitor_mod): + """codex のビルトイン既定は 180s。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + monkeypatch.delenv("MONITOR_STALL_GEMINI", raising=False) + assert monitor_mod._agent_stall_default("codex") == 180 + + +def test_builtin_default_gemini(monkeypatch, monitor_mod): + """gemini のビルトイン既定は 480s (codex より大きい)。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + monkeypatch.delenv("MONITOR_STALL_GEMINI", raising=False) + assert monitor_mod._agent_stall_default("gemini") == 480 + + +def test_per_agent_env_overrides_builtin(monkeypatch, monitor_mod): + """env `MONITOR_STALL_GEMINI` 設定で gemini 既定が上書きされる。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.setenv("MONITOR_STALL_GEMINI", "600") + assert monitor_mod._agent_stall_default("gemini") == 600 + # codex は影響を受けない + assert monitor_mod._agent_stall_default("codex") == 180 + + +def test_shared_env_applies_to_both(monkeypatch, monitor_mod): + """env `MONITOR_STALL` 共通指定は両 agent に同じ値が適用される (旧挙動互換)。 + + codex round 3 指摘: `_agent_stall_default()` は呼び出し時に env を再評価する + (import 時固定の `DEFAULT_STALL` ではなく `int(os.environ["MONITOR_STALL"])` を返す)。 + 本テストは monkeypatch で `MONITOR_STALL=240` に書き換え、両 agent が 240 を + 返すことを確認する (= 共通 env が実際に反映されることの検証)。 + """ + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + monkeypatch.delenv("MONITOR_STALL_GEMINI", raising=False) + monkeypatch.setenv("MONITOR_STALL", "240") + # 共通 env が両 agent に効く (per-agent 上書きなしの場合) + assert monitor_mod._agent_stall_default("codex") == 240 + assert monitor_mod._agent_stall_default("gemini") == 240 + + +def test_per_agent_env_takes_precedence_over_shared(monkeypatch, monitor_mod): + """per-agent env > 共通 env の優先順位を確認する。""" + monkeypatch.setenv("MONITOR_STALL", "240") + monkeypatch.setenv("MONITOR_STALL_GEMINI", "777") + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + assert monitor_mod._agent_stall_default("gemini") == 777 + # codex 側は per-agent env が無いので 共通 env (= 240) にフォールバック + assert monitor_mod._agent_stall_default("codex") == 240 + + +def test_unknown_agent_falls_back_to_default_stall(monkeypatch, monitor_mod): + """ビルトインに無い agent 名は `DEFAULT_STALL` にフォールバックする。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.delenv("MONITOR_STALL_UNKNOWN", raising=False) + assert monitor_mod._agent_stall_default("unknown") == monitor_mod.DEFAULT_STALL + + +# ---------------- PLAN21 round 5: env が非数値だった場合のフォールバック ---------------- + + +def test_shared_env_non_numeric_falls_back_to_builtin(monkeypatch, monitor_mod, capsys): + """env `MONITOR_STALL` が非数値なら builtin にフォールバック (ValueError で落ちない)。 + + gemini round 4 指摘: `int(os.environ[...])` は非数値で ValueError を出す。 + 監視プロセスを env 設定ミスでクラッシュさせないため、try/except で builtin に戻す。 + """ + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + monkeypatch.delenv("MONITOR_STALL_GEMINI", raising=False) + monkeypatch.setenv("MONITOR_STALL", "abc") + # codex / gemini とも builtin 既定 (180 / 480) に戻る + assert monitor_mod._agent_stall_default("codex") == 180 + assert monitor_mod._agent_stall_default("gemini") == 480 + captured = capsys.readouterr() + # 警告メッセージが stderr に出る + assert "MONITOR_STALL" in captured.err + assert "int に変換できません" in captured.err + + +def test_per_agent_env_non_numeric_falls_back_to_builtin( + monkeypatch, monitor_mod, capsys +): + """env `MONITOR_STALL_` が非数値なら builtin にフォールバック。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.setenv("MONITOR_STALL_GEMINI", "not-a-number") + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + # gemini は builtin (480) にフォールバック + assert monitor_mod._agent_stall_default("gemini") == 480 + # codex は env 未設定なので builtin (180) + assert monitor_mod._agent_stall_default("codex") == 180 + captured = capsys.readouterr() + assert "MONITOR_STALL_GEMINI" in captured.err + assert "int に変換できません" in captured.err + + +def test_per_agent_env_non_numeric_does_not_affect_other_agent( + monkeypatch, monitor_mod +): + """non-numeric な per-agent env は対象 agent だけに影響する。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.setenv("MONITOR_STALL_GEMINI", "xxx") + monkeypatch.setenv("MONITOR_STALL_CODEX", "200") # codex 側は正常 + assert monitor_mod._agent_stall_default("codex") == 200 + assert monitor_mod._agent_stall_default("gemini") == 480 diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_monitor_zombie.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_zombie.py new file mode 100644 index 0000000..c15d2f4 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_zombie.py @@ -0,0 +1,91 @@ +"""monitor.py のゾンビプロセス検出テスト。 + +Docker without --init 環境では、nohup/disown で起動したプロセスが終了後に +ゾンビ化する。`_pid_alive()` が /proc//status の State: Z を検出して +False を返すことを検証する。 +""" +from __future__ import annotations + +import pathlib +from unittest import mock + + +def test_pid_alive_returns_false_for_zombie(monitor_mod): + """State: Z のプロセスに対して _pid_alive() が False を返す。""" + proc_status = "Name:\tbash\nState:\tZ (zombie)\nTgid:\t12345\nPid:\t12345\n" + + with ( + mock.patch("os.kill", return_value=None), + mock.patch.object( + pathlib.Path, "read_text", return_value=proc_status + ), + ): + assert monitor_mod._pid_alive(12345) is False + + +def test_pid_alive_returns_true_for_running(monitor_mod): + """State: S (sleeping) のプロセスに対して True を返す。""" + proc_status = "Name:\tgemini\nState:\tS (sleeping)\nTgid:\t12345\nPid:\t12345\n" + + with ( + mock.patch("os.kill", return_value=None), + mock.patch.object( + pathlib.Path, "read_text", return_value=proc_status + ), + ): + assert monitor_mod._pid_alive(12345) is True + + +def test_pid_alive_returns_true_for_running_state_r(monitor_mod): + """State: R (running) のプロセスに対して True を返す。""" + proc_status = "Name:\tgemini\nState:\tR (running)\nTgid:\t12345\nPid:\t12345\n" + + with ( + mock.patch("os.kill", return_value=None), + mock.patch.object( + pathlib.Path, "read_text", return_value=proc_status + ), + ): + assert monitor_mod._pid_alive(12345) is True + + +def test_pid_alive_returns_false_for_dead_process(monitor_mod): + """kill -0 が ProcessLookupError を返すプロセスに対して False。""" + with mock.patch("os.kill", side_effect=ProcessLookupError): + assert monitor_mod._pid_alive(99999) is False + + +def test_pid_alive_proc_unreadable_falls_back_to_alive(monitor_mod): + """/proc//status が読めない場合 (non-Linux) は kill -0 の結果のみで True。""" + with ( + mock.patch("os.kill", return_value=None), + mock.patch.object( + pathlib.Path, "read_text", side_effect=FileNotFoundError + ), + ): + assert monitor_mod._pid_alive(12345) is True + + +def test_is_zombie_helper(monitor_mod): + """_is_zombie() ヘルパーの動作確認。""" + zombie_status = "Name:\tbash\nState:\tZ (zombie)\nTgid:\t1\nPid:\t1\n" + alive_status = "Name:\tgemini\nState:\tS (sleeping)\nTgid:\t1\nPid:\t1\n" + + with mock.patch.object(pathlib.Path, "read_text", return_value=zombie_status): + assert monitor_mod._is_zombie(1) is True + + with mock.patch.object(pathlib.Path, "read_text", return_value=alive_status): + assert monitor_mod._is_zombie(1) is False + + with mock.patch.object(pathlib.Path, "read_text", side_effect=FileNotFoundError): + assert monitor_mod._is_zombie(1) is False + + +def test_kill_pid_skips_zombie(monitor_mod): + """ゾンビプロセスに対して _kill_pid() はシグナルを送らない。""" + with ( + mock.patch.object(monitor_mod, "_is_zombie", return_value=True), + mock.patch("os.kill") as mock_kill, + ): + monitor_mod._kill_pid(12345) + mock_kill.assert_not_called() diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_state_auto_review_templates.py b/plugins/ndf-shared/skills/cross-review/tests/test_state_auto_review_templates.py new file mode 100644 index 0000000..c0dbd9e --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/test_state_auto_review_templates.py @@ -0,0 +1,204 @@ +"""PR 変更ファイルに応じた自動レビュー観点の分類テスト。""" + +from __future__ import annotations + + +def test_parse_pr_files_payload_normalizes_gh_files_json(state_mod): + entries = state_mod._parse_pr_files_payload( + """ + { + "files": [ + {"path": "src/app.py", "changeType": "MODIFIED"}, + {"path": "docs/new.md", "previousPath": "docs/old.md", "changeType": "RENAMED"}, + {"path": "src/old.py", "changeType": "DELETED"} + ] + } + """ + ) + + assert entries == [ + {"status": "M", "paths": ["src/app.py"]}, + {"status": "R", "paths": ["docs/old.md", "docs/new.md"]}, + {"status": "D", "paths": ["src/old.py"]}, + ] + + +def test_parse_pr_files_api_lines_normalizes_paginated_api_output(state_mod): + entries = state_mod._parse_pr_files_api_lines( + "modified\tsrc/app.py\t\n" + "renamed\tdocs/new.md\tdocs/old.md\n" + "removed\tsrc/old.py\t\n" + ) + + assert entries == [ + {"status": "M", "paths": ["src/app.py"]}, + {"status": "R", "paths": ["docs/old.md", "docs/new.md"]}, + {"status": "D", "paths": ["src/old.py"]}, + ] + + +def test_docs_only_pr_adds_docs_template(state_mod): + entries = state_mod._parse_pr_files_payload( + '{"files":[{"path":"docs/plan.md","changeType":"MODIFIED"},{"path":"README.md","changeType":"ADDED"}]}' + ) + + categories = state_mod._classify_changed_files(entries) + + assert categories == ["common", "docs_only"] + instructions = state_mod._auto_review_instructions(categories) + assert "ドキュメントのみ PR" in instructions + assert "コード、設定、コマンド" in instructions + + +def test_code_pr_detects_code_test_and_frontend(state_mod): + entries = state_mod._parse_pr_files_payload( + """ + {"files":[ + {"path":"src/components/UserCard.tsx","changeType":"MODIFIED"}, + {"path":"tests/UserCard.test.tsx","changeType":"ADDED"} + ]} + """ + ) + + categories = state_mod._classify_changed_files(entries) + + assert "code" in categories + assert "test" in categories + assert "frontend" in categories + + +def test_db_migration_detects_schema_concerns(state_mod): + entries = state_mod._parse_pr_files_payload( + '{"files":[{"path":"database/migrations/20260101_create_users.sql","changeType":"ADDED"}]}' + ) + + categories = state_mod._classify_changed_files(entries) + + assert "db_migration" in categories + assert "DB migration / schema 変更" in state_mod._auto_review_instructions(categories) + + +def test_dependency_ci_api_security_performance_i18n_infra_are_detected(state_mod): + entries = state_mod._parse_pr_files_payload( + """ + {"files":[ + {"path":"package-lock.json","changeType":"MODIFIED"}, + {"path":".github/workflows/test.yml","changeType":"MODIFIED"}, + {"path":"app/api/auth/token_controller.py","changeType":"MODIFIED"}, + {"path":"src/cache/worker.go","changeType":"MODIFIED"}, + {"path":"locales/ja.json","changeType":"MODIFIED"}, + {"path":"terraform/main.tf","changeType":"MODIFIED"} + ]} + """ + ) + + categories = state_mod._classify_changed_files(entries) + + assert "dependency" in categories + assert "config_ci" in categories + assert "api_contract" in categories + assert "auth_security" in categories + assert "performance" in categories + assert "i18n" in categories + assert "infra" in categories + + +def test_deletion_and_rename_are_detected(state_mod): + entries = state_mod._parse_pr_files_payload( + """ + {"files":[ + {"path":"src/old.py","changeType":"DELETED"}, + {"path":"src/b.py","previousPath":"src/a.py","changeType":"RENAMED"} + ]} + """ + ) + + categories = state_mod._classify_changed_files(entries) + + assert "deletion_rename" in categories + + +def test_index_entrypoint_does_not_trigger_performance(state_mod): + entries = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/index.ts","changeType":"MODIFIED"}]}' + ) + + assert "performance" not in state_mod._classify_changed_files(entries) + + +def test_i18n_extension_detection_does_not_match_po_substrings(state_mod): + false_positive = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/import.polyfill.js","changeType":"MODIFIED"}]}' + ) + real_po = state_mod._parse_pr_files_payload( + '{"files":[{"path":"locales/messages.po","changeType":"MODIFIED"}]}' + ) + + assert "i18n" not in state_mod._classify_changed_files(false_positive) + assert "i18n" in state_mod._classify_changed_files(real_po) + + +def test_env_files_are_config_and_author_is_not_auth_security(state_mod): + env_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":".env.example","changeType":"MODIFIED"}]}' + ) + author_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/components/AuthorCard.tsx","changeType":"MODIFIED"}]}' + ) + auth_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/auth/session.py","changeType":"MODIFIED"}]}' + ) + authz_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/security/authz.py","changeType":"MODIFIED"}]}' + ) + authentication_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/security/authentication.py","changeType":"MODIFIED"}]}' + ) + authorizer_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/security/authorizer.ts","changeType":"MODIFIED"}]}' + ) + + assert "config_ci" in state_mod._classify_changed_files(env_file) + assert "auth_security" not in state_mod._classify_changed_files(author_file) + assert "auth_security" in state_mod._classify_changed_files(auth_file) + assert "auth_security" in state_mod._classify_changed_files(authz_file) + assert "auth_security" in state_mod._classify_changed_files(authentication_file) + assert "auth_security" in state_mod._classify_changed_files(authorizer_file) + + +def test_tokenizer_async_jobcard_and_tflite_do_not_trigger_categories(state_mod): + tokenizer_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/tokenizer.py","changeType":"MODIFIED"}]}' + ) + token_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/auth/token.py","changeType":"MODIFIED"}]}' + ) + async_helper = state_mod._parse_pr_files_payload( + '{"files":[{"path":"utils/async_helpers.ts","changeType":"MODIFIED"}]}' + ) + job_card = state_mod._parse_pr_files_payload( + '{"files":[{"path":"components/JobCard.tsx","changeType":"MODIFIED"}]}' + ) + worker_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/worker/queue.ts","changeType":"MODIFIED"}]}' + ) + tflite_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"models/config.tflite","changeType":"MODIFIED"}]}' + ) + terraform_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"terraform/main.tf","changeType":"MODIFIED"}]}' + ) + + assert "auth_security" not in state_mod._classify_changed_files(tokenizer_file) + assert "auth_security" in state_mod._classify_changed_files(token_file) + assert "performance" not in state_mod._classify_changed_files(async_helper) + assert "performance" not in state_mod._classify_changed_files(job_card) + assert "performance" in state_mod._classify_changed_files(worker_file) + assert "infra" not in state_mod._classify_changed_files(tflite_file) + assert "infra" in state_mod._classify_changed_files(terraform_file) + + +def test_combined_review_instructions_puts_auto_before_manual(state_mod): + assert state_mod._combined_review_instructions("auto", "manual") == "auto\n\nmanual" + assert state_mod._combined_review_instructions("auto", "") == "auto" + assert state_mod._combined_review_instructions("", "manual") == "manual" diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_state_check_oscillation.py b/plugins/ndf-shared/skills/cross-review/tests/test_state_check_oscillation.py new file mode 100644 index 0000000..f00d189 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/test_state_check_oscillation.py @@ -0,0 +1,101 @@ +"""state.py cmd_check_oscillation の non-dict 防御テスト (PLAN21 round 5). + +gemini round 4 指摘: payload.json が dict 以外 (list 等) の場合、 +`payload.get("comments", [])` で AttributeError になる前に明示的に止める。 +""" +from __future__ import annotations + +import argparse +import json +import pathlib + +import pytest + + +PR = 7777 + + +def _seed_state_two_rounds(tmp_dir: pathlib.Path) -> None: + """同一 PR で 2 round 分のエントリを seed する (oscillation 判定の前提)。""" + state = { + "current_pr": PR, + "rounds": [ + {"round": 1, "pr": PR, "started_at": "2026-05-23T00:00:00+00:00"}, + {"round": 2, "pr": PR, "started_at": "2026-05-23T00:10:00+00:00"}, + ], + "deferred_nits": [], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def _make_args() -> argparse.Namespace: + return argparse.Namespace(pr=PR) + + +@pytest.fixture() +def patched_tmp_dir(monkeypatch, tmp_path, state_mod): + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +def _write_payload( + tmp_dir: pathlib.Path, agent: str, round_no: int, data: object +) -> pathlib.Path: + p = tmp_dir / f"{agent}-review-pr{PR}-round{round_no}-payload.json" + p.write_text(json.dumps(data)) + return p + + +def test_non_dict_payload_dies(patched_tmp_dir, state_mod, capsys): + """payload.json が list 等の非 dict なら die(code=3)。 + + `payload.get(...)` を呼ぶ前に明示的に止めることで、launcher 出力バグや + 別実行の残骸を黙って見逃さない。 + """ + tmp_dir = patched_tmp_dir + _seed_state_two_rounds(tmp_dir) + + # round 1 / round 2 の codex payload を dict で置く (正常 = 比較ベース) + _write_payload(tmp_dir, "codex", 1, {"comments": [{"path": "a.py", "line": 1}]}) + _write_payload(tmp_dir, "codex", 2, {"comments": [{"path": "a.py", "line": 1}]}) + # round 2 の gemini payload は不正 (list) + _write_payload(tmp_dir, "gemini", 2, [{"comments": "should_be_ignored"}]) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_check_oscillation(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + + +def test_non_dict_comment_entry_dies(patched_tmp_dir, state_mod, capsys): + """payload.comments のエントリが dict ではない場合も die(code=3)。""" + tmp_dir = patched_tmp_dir + _seed_state_two_rounds(tmp_dir) + + # round 1 / round 2 とも codex の payload は dict だが、 + # round 2 の comments エントリが str (本来 dict) + _write_payload(tmp_dir, "codex", 1, {"comments": [{"path": "a.py", "line": 1}]}) + _write_payload(tmp_dir, "codex", 2, {"comments": ["not-a-dict-entry"]}) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_check_oscillation(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + + +def test_valid_dict_payloads_dont_die(patched_tmp_dir, state_mod): + """正常な dict payload (regression guard): non-dict 検査が誤検知しないこと。""" + tmp_dir = patched_tmp_dir + _seed_state_two_rounds(tmp_dir) + + # 全 agent の payload を正規の dict で置く (overlap < 50% で continue 期待) + _write_payload(tmp_dir, "codex", 1, {"comments": [{"path": "a.py", "line": 1}]}) + _write_payload(tmp_dir, "codex", 2, {"comments": [{"path": "b.py", "line": 2}]}) + + # exit code 2 (continue) で正常終了 + with pytest.raises(SystemExit) as e: + state_mod.cmd_check_oscillation(_make_args()) + assert e.value.code == 2 diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_state_extra_review_instructions.py b/plugins/ndf-shared/skills/cross-review/tests/test_state_extra_review_instructions.py new file mode 100644 index 0000000..b145b1c --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/test_state_extra_review_instructions.py @@ -0,0 +1,35 @@ +"""state.py の追加レビュー観点オプションのテスト。""" + +from __future__ import annotations + +import argparse + + +def test_extra_review_instructions_accepts_focus(state_mod): + args = argparse.Namespace( + focus="ドキュメントとコードの整合性を重点的に確認", + extra_instructions_file=None, + ) + + assert state_mod._extra_review_instructions(args) == "ドキュメントとコードの整合性を重点的に確認" + + +def test_extra_review_instructions_combines_focus_and_file(tmp_path, state_mod): + extra = tmp_path / "focus.md" + extra.write_text("公開 API の説明と実装差分も確認\n", encoding="utf-8") + args = argparse.Namespace( + focus="ドキュメント整合性", + extra_instructions_file=str(extra), + ) + + assert state_mod._extra_review_instructions(args) == ( + "ドキュメント整合性\n\n公開 API の説明と実装差分も確認" + ) + + +def test_extra_review_instructions_ignores_empty_values(tmp_path, state_mod): + extra = tmp_path / "empty.md" + extra.write_text(" \n", encoding="utf-8") + args = argparse.Namespace(focus=" ", extra_instructions_file=str(extra)) + + assert state_mod._extra_review_instructions(args) == "" diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_state_merge_fix.py b/plugins/ndf-shared/skills/cross-review/tests/test_state_merge_fix.py new file mode 100644 index 0000000..63c31ba --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/test_state_merge_fix.py @@ -0,0 +1,776 @@ +"""state.py cmd_merge_fix の堅牢化テスト (PLAN21). + +カバー範囲: + 1. 正規パス + 正規 key (`fix_commit` / `fixed_count`) + 2. 正規パス不在 → `/tmp/` fallback で拾える + 3. 戻り値 key 別名 (`commit_sha` / `fixed`) も受理 + 4. 全候補不在 → exit 3 で die + 探索 path 一覧が stderr に出る + 5. (PLAN21 round 2) fallback の stale 検証 + - mtime が round 開始前 → skip + - JSON 内 `pr` 不一致 → skip + - 明示 `--file` は stale 検証スキップ + 6. (PLAN21 round 3) 正規パス JSON parse 失敗 → 即時 die(code=3) + 7. (PLAN21 round 3) `pr` フィールドが数値として解釈できない → skip + 8. (PLAN21 round 3) `_is_fresh_fix_result` 戻り値のタプル化追従 +""" +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import os +import pathlib + +import pytest + + +PR = 9919 + + +def _seed_state(tmp_dir: pathlib.Path) -> None: + state = { + "current_pr": PR, + "rounds": [ + {"round": 1, "pr": PR, "started_at": "2026-05-23T00:00:00+00:00"} + ], + "deferred_nits": [], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def _make_args(file_path: pathlib.Path | None = None) -> argparse.Namespace: + return argparse.Namespace(pr=PR, file=str(file_path) if file_path else None) + + +def _read_state(tmp_dir: pathlib.Path) -> dict: + return json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) + + +def _canonical_fix() -> dict: + return { + "pr": PR, + "fix_commit": "abc1234", + "ci_status": "SUCCESS", + "ci_failed_checks": [], + "fixed_count": 5, + "by_severity": {"critical": 0, "major": 3, "minor": 2, "nit": 0}, + "resolved_threads": [{"thread_id": "T1"}], + "deferred": [], + "rejected": [], + } + + +@pytest.fixture() +def patched_tmp_dir(monkeypatch, tmp_path, state_mod): + """`CROSS_REVIEW_TMP_DIR` を tmp_path に向けて `_tmp_dir()` を決定的にする。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +def test_canonical_path_and_key(patched_tmp_dir, state_mod): + """正規パス ($TMP_DIR/fix-prN-result.json) + 正規 key で state にマージされる。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + assert merged["commit"] == "abc1234" + assert merged["fixed"] == 5 + assert merged["ci"] == "SUCCESS" + assert merged["by_severity"]["major"] == 3 + + +def test_tmp_fallback_path(patched_tmp_dir, state_mod, tmp_path): + """正規パス不在で /tmp/fix-prN-result.json にある場合は fallback で拾う。 + + 旧プロンプトでサブエージェントが `/tmp/` を指定したケースの救済。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + # 正規パスには書かず、/tmp/ のみに置く + fix = _canonical_fix() + fix["fix_commit"] = "tmp_path_commit" + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.write_text(json.dumps(fix)) + try: + state_mod.cmd_merge_fix(_make_args()) + st = _read_state(tmp_dir) + assert st["rounds"][-1]["fix"]["commit"] == "tmp_path_commit" + finally: + legacy.unlink(missing_ok=True) + + +def test_key_alias_commit_sha_and_fixed(patched_tmp_dir, state_mod): + """サブエージェントが別名 (`commit_sha` / `fixed`) で書いても受理する。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = { + "pr": PR, + "commit_sha": "alias_sha", + "fixed": 3, + "ci_status": "SUCCESS", + "deferred": [], + "rejected": [], + "resolved_threads": [], + } + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + assert merged["commit"] == "alias_sha" + assert merged["fixed"] == 3 + + +def test_missing_all_candidates_dies_with_paths(patched_tmp_dir, state_mod, capsys): + """どの候補にもファイルが無い場合は exit 3 + 探索 path 一覧が stderr に出る。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + # /tmp/ にも置かない (clean up to be safe) + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.unlink(missing_ok=True) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + + captured = capsys.readouterr() + # 探索 path 一覧 (どこを見たか) がメッセージに含まれる + assert str(tmp_dir / f"fix-pr{PR}-result.json") in captured.err + assert f"/tmp/fix-pr{PR}-result.json" in captured.err + + +def test_explicit_file_arg_wins(patched_tmp_dir, state_mod, tmp_path): + """`--file` 明示時はそれを最優先で読む (正規パスや /tmp/ より優先)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + # 正規パスにも置くが、--file で別ファイルを指定する + canonical = _canonical_fix() + canonical["fix_commit"] = "canonical_should_not_be_used" + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(canonical)) + + explicit_fix = _canonical_fix() + explicit_fix["fix_commit"] = "explicit_wins" + explicit_path = tmp_path / "custom-fix.json" + explicit_path.write_text(json.dumps(explicit_fix)) + + state_mod.cmd_merge_fix(_make_args(explicit_path)) + + st = _read_state(tmp_dir) + assert st["rounds"][-1]["fix"]["commit"] == "explicit_wins" + + +# ---------------- PLAN21 round 2: fallback stale 検証 ---------------- + + +def _seed_state_with_started_at(tmp_dir: pathlib.Path, started_at: str) -> None: + """`started_at` を任意のタイムスタンプで seed する。""" + state = { + "current_pr": PR, + "rounds": [{"round": 1, "pr": PR, "started_at": started_at}], + "deferred_nits": [], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def test_fallback_stale_mtime_is_ignored(patched_tmp_dir, state_mod, capsys): + """正規 fallback ファイルの mtime が round 開始前なら skip。 + + 別 round / 別実行の残骸ファイルを誤マージしないことを確認する。 + """ + tmp_dir = patched_tmp_dir + # round 開始時刻 = 現在 (これより古いファイルは skip 対象) + now_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() + _seed_state_with_started_at(tmp_dir, now_dt.isoformat(timespec="seconds")) + + stale = tmp_dir / f"fix-pr{PR}-result.json" + stale.write_text(json.dumps(_canonical_fix())) + # mtime を 1 時間前に巻き戻す (round 開始より明確に古い) + stale_ts = now_dt.timestamp() - 3600 + os.utime(stale, (stale_ts, stale_ts)) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + + captured = capsys.readouterr() + # 古いファイルとして skip された旨が stderr に出る + assert "round 開始前" in captured.err or "古いファイル" in captured.err + + +def test_fallback_tmp_stale_is_ignored(patched_tmp_dir, state_mod, capsys): + """`/tmp/fix-prN-result.json` の古い残骸 (別リポジトリの同番号 PR 想定) は skip。 + + codex review 指摘の本丸: PR 番号だけで命名された共有 namespace の + 古いファイルを無条件に拾わないこと。 + """ + tmp_dir = patched_tmp_dir + now_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() + _seed_state_with_started_at(tmp_dir, now_dt.isoformat(timespec="seconds")) + + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.write_text(json.dumps(_canonical_fix())) + # mtime を 2 時間前に巻き戻す (= 過去の別実行で残った想定) + stale_ts = now_dt.timestamp() - 7200 + os.utime(legacy, (stale_ts, stale_ts)) + try: + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "round 開始前" in captured.err or "古いファイル" in captured.err + finally: + legacy.unlink(missing_ok=True) + + +def test_fallback_pr_mismatch_is_ignored(patched_tmp_dir, state_mod, capsys): + """fallback ファイル内の `pr` が対象 PR と一致しない場合は skip。 + + 別リポジトリの同番号 PR の戻り値が `/tmp` 共有 namespace に + 残っていたケースを想定する。 + """ + tmp_dir = patched_tmp_dir + # round 開始は十分過去にして mtime チェックでは弾かれないようにする + past = _dt.datetime(2000, 1, 1, tzinfo=_dt.timezone.utc) + _seed_state_with_started_at(tmp_dir, past.isoformat(timespec="seconds")) + + fix = _canonical_fix() + fix["pr"] = PR + 1 # 別 PR の戻り値 + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "pr 不一致" in captured.err or "別 PR" in captured.err + + +def test_fallback_fresh_mtime_and_matching_pr_is_accepted(patched_tmp_dir, state_mod): + """mtime が round 開始後 & `pr` 一致なら採用される (regression guard)。""" + tmp_dir = patched_tmp_dir + # round 開始を 1 時間前に置く → 直後に書いたファイルは "fresh" + past_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() - _dt.timedelta(hours=1) + _seed_state_with_started_at(tmp_dir, past_dt.isoformat(timespec="seconds")) + + fix = _canonical_fix() + fix["fix_commit"] = "fresh_ok" + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + assert st["rounds"][-1]["fix"]["commit"] == "fresh_ok" + + +def test_explicit_file_bypasses_stale_check(patched_tmp_dir, state_mod, tmp_path): + """`--file` 明示時は stale 検証をスキップする (ユーザー指定優先)。""" + tmp_dir = patched_tmp_dir + now_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() + _seed_state_with_started_at(tmp_dir, now_dt.isoformat(timespec="seconds")) + + explicit_fix = _canonical_fix() + explicit_fix["fix_commit"] = "explicit_stale_ok" + explicit_path = tmp_path / "explicit.json" + explicit_path.write_text(json.dumps(explicit_fix)) + # mtime を round 開始前に巻き戻しても、--file 指定なら採用される + stale_ts = now_dt.timestamp() - 3600 + os.utime(explicit_path, (stale_ts, stale_ts)) + + state_mod.cmd_merge_fix(_make_args(explicit_path)) + + st = _read_state(tmp_dir) + assert st["rounds"][-1]["fix"]["commit"] == "explicit_stale_ok" + + +# ---------------- PLAN21 round 3: 正規パス parse 失敗 / pr 型不正 / 戻り値タプル化 ---------------- + + +def test_canonical_path_json_parse_failure_dies_immediately( + patched_tmp_dir, state_mod, capsys +): + """正規パス ($TMP_DIR/fix-prN-result.json) の JSON parse 失敗は即時 die(code=3)。 + + codex round 2 指摘: 壊れた正規パスをスキップして /tmp/ fallback に流れると + 別 PR の戻り値を誤マージする事故が起こるため、正規パスの parse 失敗は致命扱い。 + """ + tmp_dir = patched_tmp_dir + # round 開始を十分過去にして mtime チェックでは弾かれないようにする + past = _dt.datetime(2000, 1, 1, tzinfo=_dt.timezone.utc) + _seed_state_with_started_at(tmp_dir, past.isoformat(timespec="seconds")) + + # 正規パスに壊れた JSON を置く (size>0 だが parse 不能) + canonical = tmp_dir / f"fix-pr{PR}-result.json" + canonical.write_text("{ this is not valid json") + + # /tmp/ 側には別 PR を装う偽の正規 JSON を置く (これに流れ込んだら事故) + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.write_text(json.dumps(_canonical_fix())) + try: + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + # 正規パスの読み取り失敗である旨が stderr に出る + assert "正規パス" in captured.err + # /tmp 側の fix がマージされていないこと (= state.rounds[-1].fix が無い) + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + finally: + legacy.unlink(missing_ok=True) + + +def test_fallback_pr_field_non_numeric_is_skipped(patched_tmp_dir, state_mod, capsys): + """fallback ファイルの `pr` フィールドが int 変換不能なら skip (ValueError 防止)。 + + gemini round 2 指摘: `int(file_pr)` で ValueError が裸で上がるとプロセスごと落ちる。 + """ + tmp_dir = patched_tmp_dir + past = _dt.datetime(2000, 1, 1, tzinfo=_dt.timezone.utc) + _seed_state_with_started_at(tmp_dir, past.isoformat(timespec="seconds")) + + # 正規パスに `pr` が int 化できない値の JSON を置く + canonical = tmp_dir / f"fix-pr{PR}-result.json" + bad = _canonical_fix() + bad["pr"] = "not-a-number" + canonical.write_text(json.dumps(bad)) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + # /tmp/ 側にも候補が無いので最終的には "戻り値ファイル無し" で die(3) + assert e.value.code == 3 + captured = capsys.readouterr() + # 数値として解釈できない旨が stderr に出る + assert "数値として解釈できない" in captured.err + # state にマージされていないこと + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +def test_is_fresh_fix_result_returns_tuple_with_parsed_payload( + patched_tmp_dir, state_mod +): + """`_is_fresh_fix_result` は (is_fresh, parsed_payload) を返す。 + + gemini round 2 指摘の性能改善: cmd_merge_fix 側で再パースしないよう、 + fresh な場合は parse 済み dict を返す。 + """ + tmp_dir = patched_tmp_dir + past_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() - _dt.timedelta(hours=1) + past_ts = past_dt.timestamp() + + fix = _canonical_fix() + fix["fix_commit"] = "tuple_return_ok" + p = tmp_dir / f"fix-pr{PR}-result.json" + p.write_text(json.dumps(fix)) + + is_fresh, parsed = state_mod._is_fresh_fix_result(p, PR, past_ts, is_canonical=True) + assert is_fresh is True + assert isinstance(parsed, dict) + assert parsed["fix_commit"] == "tuple_return_ok" + + +def test_is_fresh_fix_result_returns_none_when_stale(patched_tmp_dir, state_mod): + """stale な場合は (False, None) を返す。""" + tmp_dir = patched_tmp_dir + now_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() + + p = tmp_dir / f"fix-pr{PR}-result.json" + p.write_text(json.dumps(_canonical_fix())) + stale_ts = now_dt.timestamp() - 3600 + os.utime(p, (stale_ts, stale_ts)) + + is_fresh, parsed = state_mod._is_fresh_fix_result( + p, PR, now_dt.timestamp(), is_canonical=False + ) + assert is_fresh is False + assert parsed is None + + +# ---------------- PLAN21 round 4: non-dict JSON 防御 ---------------- + + +def test_is_fresh_fix_result_non_dict_json_is_skipped(patched_tmp_dir, state_mod, capsys): + """`_is_fresh_fix_result` は dict 以外 (list 等) の JSON を読んだら (False, None) を返す。 + + gemini round 3 指摘: `json.loads` は list / int / str も返しうるため、 + `payload.get(...)` 呼び出し前に `isinstance(payload, dict)` で防御する必要がある。 + """ + tmp_dir = patched_tmp_dir + # round 開始は十分過去にして mtime チェックを通す + past_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() - _dt.timedelta(hours=1) + past_ts = past_dt.timestamp() + + p = tmp_dir / f"fix-pr{PR}-result.json" + # JSON として valid だが dict ではない (list) + p.write_text(json.dumps([{"fix_commit": "should_be_ignored"}])) + + is_fresh, parsed = state_mod._is_fresh_fix_result( + p, PR, past_ts, is_canonical=False + ) + assert is_fresh is False + assert parsed is None + captured = capsys.readouterr() + # dict ではない旨が stderr に出る + assert "dict ではない" in captured.err + + +def test_is_fresh_fix_result_non_dict_json_canonical_dies_round5( + patched_tmp_dir, state_mod, capsys +): + """PLAN21 round 5 で挙動変更: 正規パス (is_canonical=True) で非 dict なら die(code=3)。 + + 旧 round 4 では skip (False, None) だったが、codex round 4 指摘により + `/tmp/` fallback への流れ込みを防ぐため parse 失敗と同様に即時中断する。 + """ + tmp_dir = patched_tmp_dir + past_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() - _dt.timedelta(hours=1) + past_ts = past_dt.timestamp() + + p = tmp_dir / f"fix-pr{PR}-result.json" + p.write_text(json.dumps(["not", "a", "dict"])) + + with pytest.raises(SystemExit) as e: + state_mod._is_fresh_fix_result(p, PR, past_ts, is_canonical=True) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + assert "正規パス" in captured.err + + +def test_explicit_file_non_dict_json_dies(patched_tmp_dir, state_mod, capsys, tmp_path): + """`--file` 明示時に non-dict JSON が渡されたら die(code=3) で即時中断。 + + gemini round 3 指摘: list 等が渡されると後続 `fix.get(...)` でクラッシュするため、 + 明示指定の場合も dict 検証を行ってから fix に代入する。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + + explicit_path = tmp_path / "bad-explicit.json" + # JSON として valid だが dict ではない (list) + explicit_path.write_text(json.dumps([_canonical_fix()])) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args(explicit_path)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + # state は更新されていない + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +# ---------------- PLAN21 round 5: 正規パス non-dict 即時 die / --file 厳格化 ---------------- + + +def test_canonical_path_non_dict_json_dies_immediately( + patched_tmp_dir, state_mod, capsys +): + """正規パス ($TMP_DIR/fix-prN-result.json) の JSON が dict 以外なら即時 die(code=3)。 + + codex round 4 指摘: parse 失敗と同様、非 dict も「壊れた正規出力」として扱う。 + skip で /tmp/ fallback に流れると別実行の戻り値を誤マージする経路が残るため、 + list / str / int 等が返ったら fallback には進まず即時中断する。 + """ + tmp_dir = patched_tmp_dir + past = _dt.datetime(2000, 1, 1, tzinfo=_dt.timezone.utc) + _seed_state_with_started_at(tmp_dir, past.isoformat(timespec="seconds")) + + # 正規パスに list (非 dict) を置く + canonical = tmp_dir / f"fix-pr{PR}-result.json" + canonical.write_text(json.dumps([{"fix_commit": "should_not_be_read"}])) + + # /tmp/ 側には別 PR を装う正規 JSON を置く (これに流れ込んだら事故) + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.write_text(json.dumps(_canonical_fix())) + try: + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + # 正規パスで非 dict であった旨が stderr に出る + assert "正規パス" in captured.err + assert "dict ではない" in captured.err + # /tmp 側の fix がマージされていないこと + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + finally: + legacy.unlink(missing_ok=True) + + +def test_explicit_file_nonexistent_dies(patched_tmp_dir, state_mod, capsys, tmp_path): + """`--file` 指定パスが存在しなければ fallback に進まず die(code=3)。 + + codex round 4 指摘: ユーザー明示指定なのに無言で fallback に流れて + 別実行の戻り値を誤マージするのを防ぐ。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + # 正規パスにも置いて、fallback に流れたら事故と判別できるようにする + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(_canonical_fix())) + + nonexistent = tmp_path / "does-not-exist.json" + assert not nonexistent.exists() + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args(nonexistent)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "存在しません" in captured.err + # fallback が読まれていない (state にマージされていない) + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +def test_explicit_file_empty_dies(patched_tmp_dir, state_mod, capsys, tmp_path): + """`--file` 指定パスが空ファイルなら fallback に進まず die(code=3)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(_canonical_fix())) + + empty = tmp_path / "empty.json" + empty.write_text("") + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args(empty)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "空です" in captured.err + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +def test_explicit_file_invalid_json_dies(patched_tmp_dir, state_mod, capsys, tmp_path): + """`--file` 指定パスが JSON 不正なら fallback に進まず die(code=3)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(_canonical_fix())) + + bad = tmp_path / "bad.json" + bad.write_text("{ this is not valid json") + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args(bad)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "parse に失敗" in captured.err or "読み取り" in captured.err + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +# ---------------- PLAN25: _count() 正規化 / int 混入時の堅牢化 ---------------- + + +@pytest.mark.parametrize( + "value, expected", + [ + (3, 3), # int(件数) はそのまま件数として扱う + (0, 0), # int の 0 + ([{"a": 1}, {"b": 2}], 2), # list は len() + ([], 0), # 空 list + ((1, 2, 3), 3), # tuple も len() + (None, 0), # None は 0 + (False, 0), # bool は int サブクラスだが件数扱いしない + (True, 0), # bool=True も 0 + ("xyz", 0), # 想定外の型 (str) は 0 + # PLAN25 round2 (gemini 指摘): LLM が件数を数値文字列で返すケースを許容 + ("3", 3), # 数値文字列 → int + ("0", 0), # "0" も 0 + ("12", 12), # 複数桁 + ("abc", 0), # 数値でない文字列は 0 + ("", 0), # 空文字は isdigit()=False → 0 + ("-1", 0), # 負号付きは strip 後も isdigit()=False → 0 (件数は非負) + ("1.5", 0), # 小数点付きは isdigit()=False → 0 + # PLAN25 round4 (gemini 指摘): 単一 dict を 1 件として扱う + ({"thread_id": "x"}, 1), # 単一 dict → 1 + ({}, 1), # 空 dict も 1 件 (構造化データ 1 要素扱い) + # PLAN25 round4 (gemini 指摘): 数値文字列の前後空白を許容 (strip) + (" 3 ", 3), # 前後空白付き数値文字列 → strip して int + (" 12", 12), # 先頭空白のみ + ("7 ", 7), # 末尾空白のみ + (" ", 0), # 空白のみは strip 後空文字 → 0 + ], +) +def test_count_normalizes_int_list_tuple_none_bool(state_mod, value, expected): + """`_count()` が int / dict / list / tuple / None / bool / 数値文字列 / 想定外型を件数(int)に正規化する。 + + codex review 指摘 (PLAN25): `_count()` の挙動がテストで固定されておらず、 + 同じ TypeError 経路 (`len(int)`) が再発しても検知できない。 + PLAN25 round2 (gemini 指摘): 数値文字列 ("3" 等) も件数として許容する。 + PLAN25 round4 (gemini 指摘): 単一 dict は 1 件、数値文字列は前後空白を strip して許容。 + """ + assert state_mod._count(value) == expected + + +def test_merge_fix_int_counts_do_not_crash_and_persist(patched_tmp_dir, state_mod): + """`resolved_threads` / `deferred` / `rejected` が int でも TypeError を出さず件数が保存される。 + + PLAN25 の本丸: fix サブエージェントが list の代わりに int(件数) を書いても + `len(int)` で落ちず完走すること。 + resolved_threads / rejected は _count() で int がそのまま件数として保存される。 + PLAN25 round4 (統一ルール): deferred が int (per-item を失った劣化表現) のときは + 件数を失わないよう _count() の値を保存する。展開はできないので deferred_nits は空。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + # list ではなく int(件数) を書く (再発防止対象のケース) + fix["resolved_threads"] = 4 + fix["deferred"] = 2 + fix["rejected"] = 1 + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + # TypeError を出さずに完走すること + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + assert merged["resolved_threads"] == 4 + # 統一ルール: int は劣化表現なので _count() の件数 (2) を保存する + assert merged["deferred"] == 2 + assert merged["rejected"] == 1 + # int は展開できないため deferred_nits は空のまま (per-item データは復元不能) + assert st["deferred_nits"] == [] + + +def test_merge_fix_deferred_single_dict_is_wrapped(patched_tmp_dir, state_mod): + """`deferred` が単一 dict のとき 1 件として扱い、deferred_nits に 1 件展開される。 + + PLAN25 round4 (gemini #3): LLM が単一要素を list ではなく dict 単体で返した + 場合のフォールバック。保存件数 == 展開件数 == 1 を固定する。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + # list ではなく単一 dict を書く (LLM 出力揺らぎ) + fix["deferred"] = {"comment_id": 7, "summary": "single-nit"} + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + # 構造化データ (dict) なので展開件数 (1) を保存 + assert merged["deferred"] == 1 + assert len(st["deferred_nits"]) == 1 + assert st["deferred_nits"][0]["summary"] == "single-nit" + assert st["deferred_nits"][0]["pr"] == PR + assert st["deferred_nits"][0]["round"] == 1 + + +def test_merge_fix_list_deferred_expands_nits(patched_tmp_dir, state_mod): + """`deferred` が list の場合は従来どおり件数が保存され deferred_nits が展開される。 + + int 対応で list の既存挙動 (件数集計 + deferred_nits 展開) が壊れていないことの regression guard。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + fix["resolved_threads"] = [{"thread_id": "T1"}, {"thread_id": "T2"}] + fix["deferred"] = [ + {"comment_id": 1, "summary": "nit-1"}, + {"comment_id": 2, "summary": "nit-2"}, + ] + fix["rejected"] = [{"comment_id": 3, "summary": "rej-1"}] + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + # list は len() で件数化 + assert merged["resolved_threads"] == 2 + assert merged["deferred"] == 2 + assert merged["rejected"] == 1 + # deferred_nits が展開され、pr / round が付与される + assert len(st["deferred_nits"]) == 2 + summaries = {n["summary"] for n in st["deferred_nits"]} + assert summaries == {"nit-1", "nit-2"} + for n in st["deferred_nits"]: + assert n["pr"] == PR + assert n["round"] == 1 + + +# ---------------- PLAN25 round2 (gemini): deferred 非 dict 要素ガード ---------------- + + +def test_merge_fix_deferred_non_dict_elements_are_skipped(patched_tmp_dir, state_mod): + """`deferred` に dict 以外の要素 (文字列等) が混じってもクラッシュせず、dict のみ展開される。 + + gemini round2 指摘 (major): LLM がスキーマを無視して `deferred` に + 文字列のリスト (例: ["nit: ..."]) を返すと `{**d}` で + `TypeError: 'str' object is not a mapping` が発生する。 + ループ内の `isinstance(d, dict)` ガードで非 dict 要素をスキップすること。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + # dict / 文字列 / None / int が混在した deferred (LLM 出力揺らぎ) + fix["deferred"] = [ + {"comment_id": 1, "summary": "nit-dict"}, + "nit: please fix this string element", + None, + 42, + {"comment_id": 2, "summary": "nit-dict-2"}, + ] + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + # TypeError を出さずに完走すること + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + # codex round3 指摘 (major): 保存件数は「実際に deferred_nits へ展開される + # dict 要素数」と一致させる。非 dict 要素は除外されるので 2 になる。 + assert merged["deferred"] == 2 + # deferred_nits には dict 要素のみが展開される + assert len(st["deferred_nits"]) == 2 + summaries = {n["summary"] for n in st["deferred_nits"]} + assert summaries == {"nit-dict", "nit-dict-2"} + for n in st["deferred_nits"]: + assert n["pr"] == PR + assert n["round"] == 1 + + +# ---------- PLAN25 round3 (codex): 保存件数 == deferred_nits 展開件数 ---------- + + +def test_merge_fix_deferred_count_matches_expanded_nits(patched_tmp_dir, state_mod): + """codex round3 指摘 (major / 正確性): mixed list の `deferred` を渡したとき、 + `rounds[].fix["deferred"]` の保存件数が deferred_nits に実際に展開された + dict 要素数と一致することを検証する回帰テスト。 + + round2 の dict ガードは「展開件数 < 保存件数」という食い違いを生んでいた + (保存は list 全体の長さ、展開は dict のみ)。本テストで両者の一致を固定する。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + # dict 3 件 + 非 dict 4 件 の mixed list + fix["deferred"] = [ + {"comment_id": 1, "summary": "nit-a"}, + "str-element", + None, + {"comment_id": 2, "summary": "nit-b"}, + 123, + ["nested", "list"], + {"comment_id": 3, "summary": "nit-c"}, + ] + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + expanded = [n for n in st["deferred_nits"] if n.get("round") == 1] + # 保存件数 == 実展開件数 == dict 要素数 (3) + assert merged["deferred"] == len(expanded) == 3 + summaries = {n["summary"] for n in expanded} + assert summaries == {"nit-a", "nit-b", "nit-c"} diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_state_read_result.py b/plugins/ndf-shared/skills/cross-review/tests/test_state_read_result.py new file mode 100644 index 0000000..a243fc2 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/test_state_read_result.py @@ -0,0 +1,160 @@ +"""state.py cmd_read_result の result.json スキーマ揺れに対するテスト。 + +カバー範囲: + 1. 正規スキーマ (`event` / `comments_count`) → state にマージされる + 2. 変則スキーマ (`intent` / `comment_count`) → 同等にマージされる (フォールバック) + 3. event / intent いずれも欠落 → die(exit 1) で fail + state 不変 +""" +from __future__ import annotations + +import argparse +import json +import pathlib + +import pytest + + +PR = 4242 +AGENT = "gemini" + + +def _seed_state(tmp_dir: pathlib.Path) -> dict: + state = { + "current_pr": PR, + "rounds": [ + {"round": 1, "pr": PR, "started_at": "2026-05-21T00:00:00+00:00"} + ], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + return state + + +def _make_args(file_path: pathlib.Path) -> argparse.Namespace: + return argparse.Namespace(pr=PR, agent=AGENT, file=str(file_path)) + + +def _read_state(tmp_dir: pathlib.Path) -> dict: + return json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) + + +@pytest.fixture() +def patched_tmp_dir(monkeypatch, tmp_path, state_mod): + """`CROSS_REVIEW_TMP_DIR` を tmp_path に向ける。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +def test_canonical_schema(patched_tmp_dir, state_mod): + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + result = { + "event": "APPROVE", + "posted_as": "APPROVE", + "comments_count": 0, + "review_url": "https://example/pr/1#1", + "by_severity": {"critical": 0, "major": 0, "minor": 0, "nit": 0}, + } + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps(result)) + + state_mod.cmd_read_result(_make_args(rfile)) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1][AGENT] + assert merged["intent"] == "APPROVE" + assert merged["posted_as"] == "APPROVE" + assert merged["comments"] == 0 + assert merged["review_url"] == "https://example/pr/1#1" + assert merged["by_severity"]["critical"] == 0 + + +def test_alias_schema_intent_and_comment_count(patched_tmp_dir, state_mod): + """gemini が `intent` / `comment_count` で書き出すパターンも受理する。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + result = { + "intent": "APPROVE", + "comment_count": 3, + "review_url": "https://example/pr/2#2", + "by_severity": {"critical": 0, "major": 0, "minor": 2, "nit": 1}, + } + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps(result)) + + state_mod.cmd_read_result(_make_args(rfile)) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1][AGENT] + assert merged["intent"] == "APPROVE" + # posted_as は別名 result.json には存在しないので intent と同値にフォールバック + assert merged["posted_as"] == "APPROVE" + assert merged["comments"] == 3 + + +def test_missing_event_and_intent_dies(patched_tmp_dir, state_mod): + """event / intent いずれも無ければ exit 1 で fail し state は不変であること。""" + tmp_dir = patched_tmp_dir + seeded = _seed_state(tmp_dir) + result = {"comments_count": 0} + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps(result)) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_read_result(_make_args(rfile)) + assert e.value.code == 1 + + # state は更新されていない (rounds[-1] に agent エントリが追加されていない) + st = _read_state(tmp_dir) + assert AGENT not in st["rounds"][-1] + assert st["rounds"][-1]["round"] == seeded["rounds"][-1]["round"] + + +def test_empty_result_file_dies(patched_tmp_dir, state_mod): + """空 result.json → die (result 未生成扱い)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + rfile = tmp_dir / "result.json" + rfile.write_text("") + + with pytest.raises(SystemExit) as e: + state_mod.cmd_read_result(_make_args(rfile)) + assert e.value.code == 1 + + +# ---------------- PLAN21 round 5: non-dict / 不正 JSON 防御 ---------------- + + +def test_non_dict_result_json_dies(patched_tmp_dir, state_mod, capsys): + """result.json が list 等の非 dict なら die(code=3)。 + + gemini round 4 指摘: `r.get(...)` で AttributeError になる前に明示的に止める。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + rfile = tmp_dir / "result.json" + # JSON valid だが dict ではない + rfile.write_text(json.dumps([{"event": "APPROVE"}])) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_read_result(_make_args(rfile)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + # state は更新されていない + st = _read_state(tmp_dir) + assert AGENT not in st["rounds"][-1] + + +def test_invalid_json_result_file_dies(patched_tmp_dir, state_mod, capsys): + """JSON parse 不能なら die(code=3)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + rfile = tmp_dir / "result.json" + rfile.write_text("{ this is not valid json") + + with pytest.raises(SystemExit) as e: + state_mod.cmd_read_result(_make_args(rfile)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "parse" in captured.err.lower() or "parse" in captured.err diff --git a/plugins/ndf/skills/data-analyst-export/01-formats.md b/plugins/ndf-shared/skills/data-analyst-export/01-formats.md similarity index 100% rename from plugins/ndf/skills/data-analyst-export/01-formats.md rename to plugins/ndf-shared/skills/data-analyst-export/01-formats.md diff --git a/plugins/ndf/skills/data-analyst-export/02-examples.md b/plugins/ndf-shared/skills/data-analyst-export/02-examples.md similarity index 100% rename from plugins/ndf/skills/data-analyst-export/02-examples.md rename to plugins/ndf-shared/skills/data-analyst-export/02-examples.md diff --git a/plugins/ndf/skills/data-analyst-export/SKILL.md b/plugins/ndf-shared/skills/data-analyst-export/SKILL.md similarity index 100% rename from plugins/ndf/skills/data-analyst-export/SKILL.md rename to plugins/ndf-shared/skills/data-analyst-export/SKILL.md diff --git a/plugins/ndf/skills/data-analyst-export/scripts/export-csv.js b/plugins/ndf-shared/skills/data-analyst-export/scripts/export-csv.js similarity index 100% rename from plugins/ndf/skills/data-analyst-export/scripts/export-csv.js rename to plugins/ndf-shared/skills/data-analyst-export/scripts/export-csv.js diff --git a/plugins/ndf/skills/data-analyst-sql-optimization/01-patterns.md b/plugins/ndf-shared/skills/data-analyst-sql-optimization/01-patterns.md similarity index 100% rename from plugins/ndf/skills/data-analyst-sql-optimization/01-patterns.md rename to plugins/ndf-shared/skills/data-analyst-sql-optimization/01-patterns.md diff --git a/plugins/ndf/skills/data-analyst-sql-optimization/02-examples.md b/plugins/ndf-shared/skills/data-analyst-sql-optimization/02-examples.md similarity index 100% rename from plugins/ndf/skills/data-analyst-sql-optimization/02-examples.md rename to plugins/ndf-shared/skills/data-analyst-sql-optimization/02-examples.md diff --git a/plugins/ndf/skills/data-analyst-sql-optimization/SKILL.md b/plugins/ndf-shared/skills/data-analyst-sql-optimization/SKILL.md similarity index 100% rename from plugins/ndf/skills/data-analyst-sql-optimization/SKILL.md rename to plugins/ndf-shared/skills/data-analyst-sql-optimization/SKILL.md diff --git a/plugins/ndf/skills/deepwiki-transfer/SKILL.md b/plugins/ndf-shared/skills/deepwiki-transfer/SKILL.md similarity index 100% rename from plugins/ndf/skills/deepwiki-transfer/SKILL.md rename to plugins/ndf-shared/skills/deepwiki-transfer/SKILL.md diff --git a/plugins/ndf/skills/deepwiki-transfer/scripts/fetch_wiki.py b/plugins/ndf-shared/skills/deepwiki-transfer/scripts/fetch_wiki.py similarity index 100% rename from plugins/ndf/skills/deepwiki-transfer/scripts/fetch_wiki.py rename to plugins/ndf-shared/skills/deepwiki-transfer/scripts/fetch_wiki.py diff --git a/plugins/ndf/skills/deepwiki-transfer/scripts/pyproject.toml b/plugins/ndf-shared/skills/deepwiki-transfer/scripts/pyproject.toml similarity index 100% rename from plugins/ndf/skills/deepwiki-transfer/scripts/pyproject.toml rename to plugins/ndf-shared/skills/deepwiki-transfer/scripts/pyproject.toml diff --git a/plugins/ndf/skills/deepwiki-transfer/scripts/split_pages.py b/plugins/ndf-shared/skills/deepwiki-transfer/scripts/split_pages.py similarity index 100% rename from plugins/ndf/skills/deepwiki-transfer/scripts/split_pages.py rename to plugins/ndf-shared/skills/deepwiki-transfer/scripts/split_pages.py diff --git a/plugins/ndf/skills/deepwiki-transfer/scripts/validate_gfm.py b/plugins/ndf-shared/skills/deepwiki-transfer/scripts/validate_gfm.py similarity index 100% rename from plugins/ndf/skills/deepwiki-transfer/scripts/validate_gfm.py rename to plugins/ndf-shared/skills/deepwiki-transfer/scripts/validate_gfm.py diff --git a/plugins/ndf-shared/skills/deploy/SKILL.md b/plugins/ndf-shared/skills/deploy/SKILL.md new file mode 100644 index 0000000..769919d --- /dev/null +++ b/plugins/ndf-shared/skills/deploy/SKILL.md @@ -0,0 +1,114 @@ +--- +name: deploy +description: "Create deploy PRs from feature to environment branches." +argument-hint: " (例: qa/staging, release/v2)" +disable-model-invocation: true +allowed-tools: + - Bash + - Read +--- + +# 環境デプロイPR作成コマンド + +現在のfeatureブランチを指定した環境ブランチへデプロイするためのPRを作成する。`{feature}_to_{env}` という命名のdeployブランチを作成し、最新 origin/main を取り込んでから環境ブランチへPRを出す。 + +## 使用方法 + +``` +/ndf:deploy qa/staging +/ndf:deploy release/v2 +``` + +## cherry-pick-pr との使い分け + +| 観点 | cherry-pick-pr | deploy | +|---|---|---| +| 適用範囲 | featureブランチの**一部コミット**を選択 | featureブランチ**全体**を適用 | +| ブランチ戦略 | 環境ブランチから短命ブランチ派生 | featureブランチから deploy ブランチ派生 | +| main取り込み | 必須 | 必須 | +| 用途 | 特定修正のみ検証環境に届けたい | feature機能全体を環境で検証したい | + +## 処理フロー + +### 1. バリデーション + +```bash +CURRENT_BRANCH=$(git branch --show-current) +[[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]] && \ + echo "❌ Error: デフォルトブランチからデプロイできません" && exit 1 +``` + +### 2. deployブランチ名の導出 + +```bash +FEATURE_BRANCH=$(git branch --show-current) +# 環境名を抽出: "qa/staging" → "staging", "release/v2" → "v2" +ENV_SUFFIX=$(echo "$ARGUMENTS" | sed 's|.*/||') +DEPLOY_BRANCH="${FEATURE_BRANCH}_to_${ENV_SUFFIX}" +``` + +### 3. 既存PRチェック + +```bash +EXISTING_PR=$(gh pr list --head "$DEPLOY_BRANCH" --base "$ARGUMENTS" \ + --json number,url --jq '.[0].url // empty') +if [[ -n "$EXISTING_PR" ]]; then + echo "✅ PR already exists: $EXISTING_PR" + exit 0 +fi +``` + +既存PRがあれば更新は「deployブランチにpushする」だけで済むため、再作成しない。 + +### 4. deployブランチ作成 + main取り込み + +```bash +git fetch origin main +git checkout -b "$DEPLOY_BRANCH" +git merge origin/main --no-edit || { + echo "❌ main とのmerge conflict。手動解決が必要です" + git merge --abort + git checkout "$FEATURE_BRANCH" + git branch -D "$DEPLOY_BRANCH" + exit 1 +} +``` + +### 5. push + PR作成 + +```bash +git push -u origin "$DEPLOY_BRANCH" +gh pr create --base "$ARGUMENTS" --head "$DEPLOY_BRANCH" \ + --title "$DEPLOY_BRANCH → $ARGUMENTS" \ + --body "$(cat <<'EOF' +## Summary +- 環境デプロイ用PR +- 元ブランチ: $FEATURE_BRANCH +- main取り込み済み + +## Test plan +- [ ] $ARGUMENTS 環境で動作確認 + + +EOF +)" +``` + +### 6. 元ブランチに復帰 + +```bash +git checkout "$FEATURE_BRANCH" +``` + +## 注意事項 + +- デフォルトブランチからの実行は禁止 +- main取り込みで conflict が出た場合、deployブランチを削除して戻る(featureブランチ側を先に同期すべき) +- deployブランチは PR マージ後に削除してよい +- 環境ブランチへの再デプロイは「同じ deployブランチに push」でPRが更新される + +## 関連 + +- `/ndf:cherry-pick-pr` — 一部コミットだけを環境に届ける場合 +- `/ndf:branch-fix-strategy` — ブランチ運用戦略の原則 +- `/ndf:sync-main` — featureブランチに main を取り込む diff --git a/plugins/ndf-shared/skills/docker-container-access/01-environment-detection.md b/plugins/ndf-shared/skills/docker-container-access/01-environment-detection.md new file mode 100644 index 0000000..51941bc --- /dev/null +++ b/plugins/ndf-shared/skills/docker-container-access/01-environment-detection.md @@ -0,0 +1,84 @@ +# 環境判定ガイド + +## Step 1: 自身の環境を確認 + +```bash +# 自分がコンテナ内で動作しているか確認 +cat /proc/1/cgroup 2>/dev/null | grep -q docker && echo "コンテナ内" || echo "ホスト環境" + +# または +[ -f /.dockerenv ] && echo "コンテナ内" || echo "ホスト環境" +``` + +## Step 2: Docker環境の種類を判定 + +自身がコンテナ内の場合、以下のいずれかの環境です: + +| 環境 | 説明 | 判定方法 | +|-----|------|---------| +| **DinD** (Docker in Docker) | コンテナ内に独立したDockerデーモン | `docker info`でDocker rootが`/var/lib/docker` | +| **DooD** (Docker outside of Docker) | ホストのDockerソケットを共有 | `/var/run/docker.sock`がマウントされている | + +```bash +# DooD判定: docker.sockがマウントされているか +ls -la /var/run/docker.sock 2>/dev/null && echo "DooD環境の可能性" || echo "DinDまたはホスト環境" + +# Docker rootディレクトリの確認 +docker info 2>/dev/null | grep "Docker Root Dir" +``` + +## DinD環境でのアクセス + +DinD環境では、**localhost**で他のコンテナにアクセスできます。 + +### 特徴 +- コンテナ内に独立したDockerデーモンが動作 +- ネットワークは通常のDocker環境と同じ +- `localhost:ポート`でアクセス可能 + +### アクセス例 + +```bash +# Webサーバーへのアクセス +curl http://localhost:8080 + +# データベースへの接続 +mysql -h localhost -P 3306 -u user -p + +# Playwright MCPでのアクセス +# URL: http://localhost:3000 +``` + +## 環境判定スクリプト + +```bash +#!/bin/bash +# Docker環境判定スクリプト + +echo "=== Docker環境判定 ===" + +# 自分がコンテナ内かチェック +if [ -f /.dockerenv ] || grep -q docker /proc/1/cgroup 2>/dev/null; then + echo "実行環境: Dockerコンテナ内" + + # DinD/DooD判定 + if [ -S /var/run/docker.sock ]; then + echo "Docker形式: DooD (Docker outside of Docker)" + echo "" + echo "→ 他のコンテナへのアクセスにはコンテナ名を使用してください" + echo "→ bind mountはホストのパスを参照するため注意が必要です" + else + echo "Docker形式: DinD (Docker in Docker)" + echo "" + echo "→ localhostで他のコンテナにアクセス可能です" + fi +else + echo "実行環境: ホストマシン" + echo "" + echo "→ 通常のDocker操作が可能です" +fi + +echo "" +echo "=== 利用可能なコンテナ ===" +docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo "Dockerが利用できません" +``` diff --git a/plugins/ndf-shared/skills/docker-container-access/02-dood-access.md b/plugins/ndf-shared/skills/docker-container-access/02-dood-access.md new file mode 100644 index 0000000..d19fc96 --- /dev/null +++ b/plugins/ndf-shared/skills/docker-container-access/02-dood-access.md @@ -0,0 +1,134 @@ +# DooD環境でのアクセスガイド + +## 特徴 + +- ホストのDockerデーモンを共有 +- localhostはホストマシンを指す(他のコンテナではない) +- コンテナ間通信はDockerネットワーク経由 + +## アクセス方法 + +### 1. コンテナ名でアクセス(同一ネットワーク内) + +```bash +# コンテナ名を確認 +docker ps --format "table {{.Names}}\t{{.Ports}}" + +# コンテナ名でアクセス +curl http://my-web-container:8080 + +# docker-composeの場合、サービス名でアクセス +curl http://web:8080 +``` + +### 2. Dockerネットワーク経由 + +```bash +# ネットワーク一覧を確認 +docker network ls + +# 特定ネットワークのコンテナを確認 +docker network inspect bridge --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}' + +# IPアドレスでアクセス +curl http://172.17.0.2:8080 +``` + +### 3. 同一ネットワークへの参加 + +```bash +# 自分のコンテナを対象コンテナと同じネットワークに接続 +docker network connect my-network $(hostname) + +# その後、コンテナ名でアクセス可能 +curl http://target-container:8080 +``` + +## curlでのアクセス例 + +```bash +# NG: localhostは使えない +curl http://localhost:8080 # → Connection refused + +# OK: コンテナ名を使用 +curl http://my-app-container:8080 + +# OK: docker-composeのサービス名 +curl http://api:3000 + +# OK: コンテナのIPアドレス +CONTAINER_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' my-container) +curl http://${CONTAINER_IP}:8080 +``` + +## Playwright MCP / Chrome DevTools MCP + +```bash +# DooD環境では、コンテナ名またはIPを使用 +# URL: http://web-container:3000 (コンテナ名) +# URL: http://172.17.0.3:3000 (IP) +``` + +--- + +## bind mountの注意点 + +### 問題 + +DooD環境では、`docker run -v`や`docker-compose`のbind mountは**ホストマシンのパス**を参照します。開発コンテナ内のパスではありません。 + +```yaml +# NG: DooD環境では期待通りに動作しない +volumes: + - ./local-dir:/app/data # ホストの./local-dirを参照してしまう +``` + +### 解決策 + +#### 1. Dockerfileでコピー(推奨) + +```dockerfile +FROM node:18 +WORKDIR /app +COPY . . +RUN npm install +CMD ["npm", "start"] +``` + +#### 2. 名前付きボリュームを使用 + +```bash +# ボリュームを作成 +docker volume create my-data + +# ファイルをボリュームにコピー +docker run --rm -v my-data:/data -v $(pwd):/src alpine cp -r /src/. /data/ + +# ボリュームをマウントしてコンテナ起動 +docker run -v my-data:/app/data my-image +``` + +#### 3. docker cpを使用 + +```bash +# コンテナにファイルをコピー +docker cp ./local-file.txt my-container:/app/ + +# コンテナからファイルを取得 +docker cp my-container:/app/output.txt ./ +``` + +### docker-compose.yml での対応 + +```yaml +# DooD環境対応版 +version: '3.8' +services: + app: + build: . # Dockerfileでファイルをコピー + volumes: + - app-data:/app/data # 名前付きボリューム使用 + +volumes: + app-data: +``` diff --git a/plugins/ndf-shared/skills/docker-container-access/03-troubleshooting.md b/plugins/ndf-shared/skills/docker-container-access/03-troubleshooting.md new file mode 100644 index 0000000..0acac32 --- /dev/null +++ b/plugins/ndf-shared/skills/docker-container-access/03-troubleshooting.md @@ -0,0 +1,63 @@ +# トラブルシューティング + +## Q: `curl: (7) Failed to connect to localhost port 8080` + +**原因**: DooD環境でlocalhostを使用している + +**解決策**: +```bash +# コンテナ名またはIPを使用 +docker ps # コンテナ名を確認 +curl http://container-name:8080 +``` + +## Q: bind mountしたファイルが見えない + +**原因**: DooD環境ではホストのパスを参照している + +**解決策**: +```bash +# docker cpでコピー +docker cp ./file.txt container:/app/ + +# または名前付きボリュームを使用 +``` + +## Q: コンテナ間で通信できない + +**原因**: 異なるDockerネットワークに所属している + +**解決策**: +```bash +# 同じネットワークに接続 +docker network connect my-network container-a +docker network connect my-network container-b +``` + +## Q: docker.sockへのアクセス権限がない + +**解決策**: +```bash +# docker グループに追加(要再ログイン) +sudo usermod -aG docker $USER + +# または一時的に権限付与 +sudo chmod 666 /var/run/docker.sock +``` + +--- + +# ベストプラクティス + +## DO(推奨) + +- **コンテナアクセス前に環境を判定する** +- **DooD環境ではコンテナ名/サービス名を使用する** +- **ファイル共有はDockerfileのCOPYまたは名前付きボリュームを使用** +- **docker-composeではサービス名でアクセス** + +## DON'T(非推奨) + +- **環境を確認せずにlocalhostを使用する** +- **DooD環境でbind mountに依存する** +- **IPアドレスをハードコードする(変わる可能性がある)** diff --git a/plugins/ndf-shared/skills/docker-container-access/SKILL.md b/plugins/ndf-shared/skills/docker-container-access/SKILL.md new file mode 100644 index 0000000..eadd1ff --- /dev/null +++ b/plugins/ndf-shared/skills/docker-container-access/SKILL.md @@ -0,0 +1,76 @@ +--- +name: docker-container-access +description: "Diagnose Docker container access and localhost routing." +when_to_use: "Docker / コンテナへのアクセス・localhost 接続不可・DinD/DooD 環境判定が必要なとき。Triggers: 'docker access', 'container connect', 'localhost not working', 'DinD', 'DooD', 'Docker接続', 'コンテナアクセス', 'curl container'" +allowed-tools: + - Read + - Bash + - Glob +--- + +# Docker Container Access Skill + +## 概要 + +ローカル開発環境がDocker開発コンテナ上で動作している場合、他のDockerコンテナへのアクセス方法が通常と異なります。このスキルでは、環境を判定し、適切なアクセス方法を選択するためのガイドラインを提供します。 + +## クイックリファレンス + +``` +環境判定 → アクセス方法 +──────────────────────────────── +ホスト環境 → localhost:port +DinD環境 → localhost:port +DooD環境 → container-name:port または IP:port + +ファイル共有(DooD環境) +──────────────────────────────── +Dockerfile COPY → 推奨(ビルド時にコピー) +名前付きボリューム → 推奨(永続化が必要な場合) +docker cp → OK(一時的なコピー) +bind mount → NG(ホストのパスを参照) +``` + +## 環境判定(最初に実行) + +```bash +# 自分がコンテナ内か確認 +[ -f /.dockerenv ] && echo "コンテナ内" || echo "ホスト環境" + +# DooD判定 +ls -la /var/run/docker.sock 2>/dev/null && echo "DooD環境" || echo "DinDまたはホスト" +``` + +| 環境 | 説明 | コンテナへのアクセス | +|-----|------|-------------------| +| **ホスト** | 通常のDocker環境 | `localhost:port` | +| **DinD** | コンテナ内に独立したDockerデーモン | `localhost:port` | +| **DooD** | ホストのDockerソケットを共有 | `container-name:port` | + +## 詳細ガイド + +詳細は以下のファイルを参照してください: + +| ファイル | 内容 | +|---------|------| +| `01-environment-detection.md` | 環境判定の詳細、判定スクリプト | +| `02-dood-access.md` | DooD環境でのアクセス方法、bind mount注意点 | +| `03-troubleshooting.md` | トラブルシューティング、ベストプラクティス | + +## よくある問題(簡易版) + +| 症状 | 原因 | 解決策 | +|-----|------|--------| +| `localhost`で接続できない | DooD環境 | コンテナ名を使用 | +| bind mountしたファイルが見えない | DooD環境 | `docker cp`または名前付きボリューム | +| コンテナ間で通信できない | 別ネットワーク | 同じネットワークに接続 | + +## 関連Skill + +- **python-execution**: Python実行環境の判定 +- **corder-code-templates**: Dockerfileテンプレート + +## 関連リソース + +- [Docker Networking](https://docs.docker.com/network/) +- [Docker in Docker](https://hub.docker.com/_/docker) diff --git a/plugins/ndf-shared/skills/fix/SKILL.md b/plugins/ndf-shared/skills/fix/SKILL.md new file mode 100644 index 0000000..92471dd --- /dev/null +++ b/plugins/ndf-shared/skills/fix/SKILL.md @@ -0,0 +1,303 @@ +--- +name: fix +description: "Fix actionable PR review comments." +when_to_use: "PRレビューコメント (codex/gemini/人間) の指摘を実際にコード修正で対応したいとき。review-pr-comments で分類した後の修正フェーズに使う。Triggers: 'PRコメント対応', 'PRレビュー修正', 'PR fix', 'review feedback fix', 'コメントに対応して修正'" +argument-hint: "[PR番号] [--defer-nit] [--severity-min critical|major|minor]" +allowed-tools: + - Bash + - Read + - Edit + - Write + - Glob + - Grep +--- + +# PR修正コマンド + +直前PR、または引数で指定されたPRのreview comment確認・修正対応実行。 + +## 起動モード + +このスキルは **メインセッション直接実行** と **サブエージェント (`general-purpose`) 起動** の両方に対応する。 +長丁場のクロスレビューループ(`/ndf:cross-review`)からは **必ずサブエージェント経由で起動** されることを想定: + +```python +# メインからの起動例(cross-review が内部でこれを行う) +Agent( + subagent_type="general-purpose", + description="Fix PR review comments (sub-agent)", + prompt=""" +/ndf:fix --defer-nit を実行してください。 + +PR: +リポジトリ: +重要度ポリシー: critical/major/minor は修正、nit は deferred として残す +完了後の戻り値: 件数サマリ + 修正コミット SHA + 残 nit リスト +""" +) +``` + +サブエージェント側ではこの SKILL.md を読み込んで、自己完結で +**修正 → コミット → push → reply → Resolve Conversation** まで実行する。 +メインへの戻り値は最小限のサマリのみ。 + +## 引数 + +| 引数 | 意味 | 既定 | +|---|---|---| +| `[PR番号]` | 対象 PR | 直前 PR | +| `--defer-nit` | nit 指摘は修正せず deferred としてリスト出力 | OFF | +| `--severity-min LEVEL` | 指定重要度未満は無視(`critical` / `major` / `minor`) | `minor` (= minor 以上を修正) | + +## 重要度ベースの自動修正ポリシー + +`[重要度 / カテゴリ]` プレフィックス(`/ndf:review` 出力規約)で分類。 +**ただし重要度ラベルを鵜呑みにしない** — 各指摘ごとにコード/仕様を独自に調査し、 +本来の重要度を判定し直してから下表の動作を適用する(bot のラベリングは参考値に過ぎない)。 + +| 重要度 | 動作 | ユーザ問い合わせ | +|---|---|---| +| `critical` | **必ず自動修正** | なし | +| `major` | **必ず自動修正** | なし | +| `minor` / `nit` (パフォーマンス・可読性・重複コード排除) | **このPRで修正対応**。特にトータル行数が減る方向の修正は積極的に実施 | なし | +| `minor` / `nit` (上記カテゴリ、修正範囲が +30 行を超えそう) | ユーザ問い合わせ | あり | +| `minor` (その他) | 自動修正(明らかな改善のみ)。判断が割れるなら `nit` として deferred 扱い | なし | +| `nit` (その他) | `--defer-nit` 指定時は **修正せず deferred リスト** に追加。最後にまとめてユーザ問い合わせ | あり(最後に1回) | + +**重要度の独自判定**: +- AI agent (CodeRabbit / Copilot 等) が `nit` と付けていても、実体がパフォーマンス改善や重複排除なら **minor/nit カテゴリ修正対象** として扱う +- 逆に AI agent が `critical` と付けていても、実害がないスタイル指摘なら `nit` 相当に格下げして deferred 化してよい +- 重要度はカテゴリ(performance/readability/duplication/security/style/etc)と合わせて、コード本体を読んだ上で判定する + +**指摘の正否判断**: +- ロジック・仕様逸脱・セキュリティ: コード/仕様を確認してから修正可否判断 +- bot 指摘で **明らかに誤読** している場合(例: 意図的な変数展開を「クオート不足」と指摘する等): 修正しない、reply で理由説明 +- 仕様判断が必要な指摘(API 変更、互換性破壊など): ユーザ問い合わせ対象(critical でもエスカレーション) + +**自動判断できない場合の取り扱い** (context 節約のため安易に user に投げない): +- 仕様文書(docs/, README)を読んで判断する +- 既存テストを読んで挙動を確認する +- 関連する他コードの慣例を確認する +- それでも不明なら deferred リストに「要ユーザ判断」として記録、最後にまとめて問い合わせ + +## 手順 + +1. review comment取得 + 重要度を**独自に再判定**(AI agent のラベルは参考値) +2. **CIエラー確認**(`gh pr checks ` で **現時点の** 失敗ジョブを検出) + - **完了待ちはしない**。実行中(PENDING/IN_PROGRESS)のチェックは無視して次ステップへ進む + - 直近で失敗(FAILURE)状態のジョブのみを修正対象に取り込む +3. 修正対象を確定: + - `critical` / `major` → 全件修正対象 + - `minor` / `nit` (パフォーマンス・可読性・重複排除) → 修正対象。+30行超なら **deferred + ユーザ問い合わせ** + - `minor` (その他) → 修正対象(明らかでないものは `deferred[]` へ) + - `nit` (その他、`--defer-nit` 時) → `deferred[]` のみ、修正しない + - CIエラー → 全件修正対象(PRテスト範囲外の **flaky テストも見つけ次第修正**) +4. 問題点修正 + - **コード行数が減る方向の修正は積極的に実施**(重複排除、不要分岐除去 等) +5. **コミット前の再確認**(修正作業中に状況が変わっている可能性への対応) + - **review comment再取得**: 作業中に新しいコメントが追加されていないか確認 + - **CI状態再確認**: 現時点の状態だけ確認(完了待ちはしない)。新しい失敗が出ていれば対象に取り込む + - 新しい指摘/失敗があれば手順3に戻る +6. コミット・プッシュ +7. PRにSummaryコメントを追加(対応した件数 + deferred 件数を明記) +8. 対応したインラインコメントに個別に返信 +9. **deferred スレッドには `[deferred / nit]` のラベル付き返信** を投稿(resolve はしない) +10. reviewerに再レビューを依頼 +11. 対応完了したインラインコメントを「Resolve Conversation」にする(`resolveReviewThread` mutation) + - resolve した thread_id / comment_id / path / line を `resolved_threads[]` に記録 + - `deferred` / `rejected` の thread は Resolve しない(次ラウンドで再評価するため) +12. **戻り値ファイルを書き出す**: `/tmp/fix-pr<番号>-result.json` (後述「戻り値フォーマット」参照) + - `ci_failed_checks` には `gh pr checks --json name,state` から `state=FAILURE` の name を抽出して列挙 + - push 直後の CI 再実行結果は**待たない**ため、戻り値の `ci_status` は push 時点での既知失敗のみを反映する + +- 4〜6はgit、1〜2/5と7以降はgithub mcpまたはghを利用 + +**flakyテストの扱い**: PR の変更範囲外で発生している flaky テストも、見つけ次第このPRで修正する。 +flaky を放置するとリポジトリ全体のコード品質が下がり、後続 PR の CI 信頼性も損なわれるため。 + +## CIエラーチェック + +### 失敗ジョブの検出 + +```bash +# PRの全チェック状態を確認(FAIL/PASS/PENDING) +gh pr checks + +# JSON形式で詳細取得 +gh pr checks --json name,state,link,completedAt + +# 失敗ジョブのみ抽出 +gh pr checks --json name,state | \ + python3 -c "import json,sys; [print(c['name']) for c in json.load(sys.stdin) if c['state']=='FAILURE']" + +# 実行中ジョブのみ抽出(状態スナップショット用。完了は待たない) +gh pr checks --json name,state | \ + python3 -c "import json,sys; [print(c['name']) for c in json.load(sys.stdin) if c['state'] in ('PENDING','IN_PROGRESS','QUEUED')]" +``` + +### CI完了待ちはしない + +このスキルでは **CI 完了待ちは行わない**(`gh pr checks --watch` 等は使わない)。 +- 各チェックポイントでは「現時点で FAILURE のジョブ」のみを取り込んで修正する +- push 後の CI 再実行結果も待たない(待機中に context を消費しないため) +- ただし `gh pr checks --json name,state` での **状態スナップショット取得は実施** + し、戻り値の `ci_status` / `ci_failed_checks` に反映する + +### 失敗ログの取得 + +```bash +# ワークフロー実行ID取得 +RUN_ID=$(gh run list --branch --limit 1 --json databaseId --jq '.[0].databaseId // empty') +[ -z "$RUN_ID" ] && { echo "No CI run found for this branch"; exit 0; } + +# 失敗ステップのログだけ表示(効率的) +gh run view $RUN_ID --log-failed + +# 特定ジョブのログ +gh run view $RUN_ID --job --log +``` + +### CIエラーの分類と対応方針 + +| エラー種別 | 対応方針 | +|---|---| +| **lint/format** | 自動修正ツール実行(`ruff`, `prettier`, `eslint --fix` 等)→ コミット | +| **型チェック** | 型定義・アノテーションを修正。無視コメントは原則禁止(根本対応) | +| **テスト失敗** | 失敗テストを読み、実装/テストどちらが正しいか判断してから修正。テスト側の問題なら仕様確認 | +| **ビルドエラー** | 依存関係・構文・設定ファイルを確認 | +| **依存脆弱性** | 可能ならバージョン更新、無理なら除外ルール追加(理由明記) | +| **タイムアウト/flaky** | retry設定、テスト分割、リトライ追加。**PR範囲外の flaky も見つけ次第修正**(放置でリポジトリ全体の品質劣化を招くため) | +| **インフラ一時障害** | 再実行で解消することがあるため `gh run rerun $RUN_ID` を先に試す | + +### review指摘との統合 + +review指摘とCIエラーは**同じPRで一緒に修正**する: +- 同じファイル・機能に関する指摘とCIエラーは1コミットにまとめる +- 独立しているなら別コミットに分離(git log で追いやすい) + +## ghコマンド例 + +### PR コメント一括取得 (3 ソース) + +```bash +# インラインコメント / レビュー body / PR レベルコメントを一括取得 +FETCH_SCRIPT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/skills/fix/scripts/fetch-pr-comments.sh" +"$FETCH_SCRIPT" +``` + +### コメントへの返信 + +```bash +# PRのレビューコメント一覧を取得 (インラインコメントのみ) +gh api repos/{owner}/{repo}/pulls/{pr_number}/comments + +# 特定のコメントに返信(in_reply_to にコメントIDを指定) +gh api repos/{owner}/{repo}/pulls/{pr_number}/comments \ + -f body="修正しました。" \ + -F in_reply_to={comment_id} +``` + +### Resolve Conversation + +```bash +# GraphQL APIでスレッドをresolveする +gh api graphql -f query=' + mutation { + resolveReviewThread(input: {threadId: "{thread_node_id}"}) { + thread { isResolved } + } + } +' +``` + +### thread_node_idの取得方法 + +```bash +# PRのレビュースレッド一覧を取得(node_id含む) +gh api graphql -f query=' + query { + repository(owner: "{owner}", name: "{repo}") { + pullRequest(number: {pr_number}) { + reviewThreads(first: 100) { + nodes { + id + isResolved + comments(first: 1) { + nodes { body } + } + } + } + } + } + } +' +``` + +**方針**: +- 品質・可読性・セキュリティ向上、既存機能影響なし +- 指摘がすべて正しいとは限らない。修正前に仕様を調査し、実施の可否を判断すること +- 未対応の場合はその理由をコメントに書き込む + +## 戻り値フォーマット(必須) + +サブエージェント呼び出し時の context 節約のため、**実行結果は `/tmp/fix-pr<番号>-result.json` に書き出す**: + +```json +{ + "pr": 67, + "fix_commit": "abc1234", + "ci_status": "SUCCESS" | "FAILURE" | "PENDING" | "NONE", + "ci_failed_checks": [], + "ci_note": null, + "fixed_count": 5, + "by_severity": {"critical": 1, "major": 2, "minor": 2, "nit": 0}, + "resolved_threads": [ + { + "thread_id": "PRRT_...", + "comment_id": 3222849090, + "path": "src/foo.py", + "line": 42 + } + ], + "deferred": [ + { + "comment_id": 3222849090, + "thread_id": "PRRT_...", + "path": "src/foo.py", + "line": 42, + "severity": "nit", + "category": "style", + "summary": "末尾セミコロンの有無", + "reason_for_deferral": "好みの範囲。プロジェクト規約と齟齬なし" + } + ], + "rejected": [ + { + "comment_id": 3222849090, + "summary": "heredoc を <<'JSON' にせよ", + "reason_for_rejection": "$SHA を意図的に展開する必要があり、クオート化すると逆に壊れる" + } + ], + "summary_comment_url": "https://github.com/.../pull/67#issuecomment-..." +} +``` + +**フィールド説明**: + +- `ci_failed_checks` — `ci_status = FAILURE` のとき、失敗した check 名の配列。`/ndf:cross-review` 側で code-related (`pint/larastan/test/build/lint/type`) と meta-only (`check_pr_requirements/assignees/reviewers/labels`) を分類し、メタチェックのみ失敗ならループ継続する +- `ci_note` — code-related ではない CI 失敗の補足。例: `"メタチェックのみ失敗: check_pr_requirements — Assignees 未設定"` +- `resolved_threads` — 手順 11 で `resolveReviewThread` mutation を実行したスレッド一覧。`deferred` / `rejected` の thread は **Resolve しない**(再評価のため) + +サブエージェントとして起動された場合は、この JSON をメインに返すサマリの基礎とする。 + +## 作業完了報告(必須) + +メイン or PR への報告内容(戻り値ファイルから抽出): +- 対応した指摘の件数(重要度別: critical/major/minor/nit) +- **deferred 件数**(主に nit、最後にユーザ問い合わせ予定) +- **rejected 件数**(bot 指摘が不適切で修正しなかった件、各々理由付き) +- **対応したCIエラーの一覧**(ジョブ名、エラー内容、修正方法) +- **対応した flaky テストの一覧**(PR範囲外も含む) +- 修正コミット SHA / 修正ファイル一覧 +- 戻り値ファイルパス: `/tmp/fix-pr<番号>-result.json` +- **PR URL を最後に必ず記載**(例: `https://github.com///pull/<番号>`) diff --git a/plugins/ndf-shared/skills/fix/scripts/fetch-pr-comments.sh b/plugins/ndf-shared/skills/fix/scripts/fetch-pr-comments.sh new file mode 100755 index 0000000..aa7f97f --- /dev/null +++ b/plugins/ndf-shared/skills/fix/scripts/fetch-pr-comments.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Usage: fetch-pr-comments.sh +# 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を一括取得し、 +# タグ付き行単位で stdout に出力する。 +# 全ソース取得失敗時は非 0 で終了する(0件取得と取得失敗を区別)。 +set -uo pipefail + +if [[ $# -lt 2 ]] || [[ -z "${1:-}" ]] || [[ -z "${2:-}" ]]; then + echo "ERROR: 引数が不足しています。Usage: $0 " >&2 + exit 1 +fi + +REPO="$1" +PR="$2" + +FAIL_COUNT=0 + +# 1. インラインコメント (diff の特定行に紐づく) +# 本文全体を保持する。改行は \n エスケープして 1 行に収める。 +# gh api --jq は内部で jq -r 相当だが、環境差を吸収するため明示的に jq -r へパイプする。 +if ! gh api "repos/${REPO}/pulls/${PR}/comments" --paginate \ + | jq -r '.[] | "\(.path // "?"):\(.line // .original_line // "?") [\(.user.login)] \(.body // "" | gsub("\n"; "\\n") | gsub("```"; "` ` `"))"'; then + echo "WARNING: インラインコメントの取得に失敗しました (repos/${REPO}/pulls/${PR}/comments)" >&2 + (( FAIL_COUNT += 1 )) || true +fi + +# 2. レビュー body (CHANGES_REQUESTED / COMMENTED 等の総評) +# 本文全体を保持する。改行は \n エスケープして 1 行に収める。 +if ! gh api "repos/${REPO}/pulls/${PR}/reviews" --paginate \ + | jq -r '.[] | select(.body != null and .body != "") | "[REVIEW-BODY] [\(.user.login)] state=\(.state) \(.body | gsub("\n"; "\\n") | gsub("```"; "` ` `"))"'; then + echo "WARNING: レビュー body の取得に失敗しました (repos/${REPO}/pulls/${PR}/reviews)" >&2 + (( FAIL_COUNT += 1 )) || true +fi + +# 3. PR レベルコメント (Conversation タブの通常コメント) +# 本文全体を保持する。改行は \n エスケープして 1 行に収める。 +if ! gh api "repos/${REPO}/issues/${PR}/comments" --paginate \ + | jq -r '.[] | "[PR-COMMENT] [\(.user.login)] \(.body // "" | gsub("\n"; "\\n") | gsub("```"; "` ` `"))"'; then + echo "WARNING: PR レベルコメントの取得に失敗しました (repos/${REPO}/issues/${PR}/comments)" >&2 + (( FAIL_COUNT += 1 )) || true +fi + +# 全ソース失敗時のみ非 0 で終了(認証切れ等の検出) +if (( FAIL_COUNT >= 3 )); then + echo "ERROR: 全 3 ソースの取得に失敗しました" >&2 + exit 1 +fi diff --git a/plugins/ndf/skills/gemini/SKILL.md b/plugins/ndf-shared/skills/gemini/SKILL.md similarity index 100% rename from plugins/ndf/skills/gemini/SKILL.md rename to plugins/ndf-shared/skills/gemini/SKILL.md diff --git a/plugins/ndf-shared/skills/git-gh-operations/01-common-errors.md b/plugins/ndf-shared/skills/git-gh-operations/01-common-errors.md new file mode 100644 index 0000000..a7afa44 --- /dev/null +++ b/plugins/ndf-shared/skills/git-gh-operations/01-common-errors.md @@ -0,0 +1,145 @@ +# Git / gh 共通エラー事例集 + +## 1. git add pathspec エラー + +### 事象 +``` +fatal: pathspec 'lambda-batch/CarImageProcessingPipeline/src/foo.py' did not match any files +``` + +### 原因 +CWD が `/work/repo/lambda-batch/CarImageProcessingPipeline/` なのに、 +リポジトリルートからの相対パスで `git add` した。 + +`git status` はリポジトリルートからの相対パスで表示するが、 +`git add` は CWD からの相対パスで解決する。 + +### 予防策 +```bash +# Step 1: CWD確認 +pwd +# => /work/repo/lambda-batch/CarImageProcessingPipeline/ + +# Step 2: git status の出力を確認 +git status +# modified: lambda-batch/CarImageProcessingPipeline/src/foo.py +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# これはリポジトリルートからの相対パス + +# Step 3: CWD からの相対パスに変換 +git add src/foo.py +# または +git add . # CWD以下のすべての変更 +``` + +## 2. gh api 404 エラー + +### 事象 +``` +gh api repos/owner/repo/pulls/comments/123/replies -f body='message' +# => 404 Not Found +``` + +### 原因 +POST メソッドが必要な API エンドポイントに GET でアクセスした。 +`gh api` はデフォルトで GET を使用する。 + +### 修正 +```bash +gh api -X POST repos/owner/repo/pulls/comments/123/replies -f body='message' +``` + +## 3. GitHub 自己 Approve エラー + +### 事象 +``` +Could not approve for pull request review. Can not approve your own pull request +``` + +### 原因 +GitHub はセキュリティ上、自分で作成した PR を APPROVE できない。 + +### 対策 +```bash +# pending review を削除してから COMMENT として再送信 +# method: "delete_pending" → method: "create" + event: "COMMENT" +``` + +## 4. AWS CLI [$LATEST] パースエラー + +### 事象 +``` +Unknown options: , , , +``` + +### 原因 +CloudWatch ログストリーム名に含まれる `[$LATEST]` が +`--query` JMESPath パーサーや shell の glob として解釈される。 + +### 対策 +```bash +# シングルクォートで囲んでも --query との組み合わせで問題が出る +# --output json + python パースが最も安全 +aws logs get-log-events \ + --log-group-name "/aws/lambda/func-name" \ + --log-stream-name '2026/02/18/[$LATEST]abc123' \ + --output json | python3 -c " +import sys, json +data = json.loads(sys.stdin.read()) +for e in data['events']: + print(e['message'].strip()) +" +``` + +## 5. git commit メッセージの特殊文字 + +### 事象 +コミットメッセージに日本語や改行が含まれるとエスケープ問題が発生。 + +### 対策 +常に HEREDOC 形式を使用: +```bash +git commit -m "$(cat <<'EOF' +日本語メッセージ + +詳細説明 + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +注意: `<<'EOF'` (シングルクォート付き)で変数展開を抑制する。 + +## 6. gh pr checks が exit code 1 で止まる + +### 事象 +``` +gh pr checks 11765 2>&1 +# => チェック結果は表示されるが、1つでもfailがあると exit code 1 で終了 +# => Claude Code が「コマンド失敗」と判定して処理を中断 +``` + +### 原因 +`gh pr checks` は CI チェックに失敗があると非0の exit code を返す仕様。 +Claude Code の Bash ツールはコマンドの exit code が 0 以外だとエラーとして扱う。 + +### 対策 +常に `|| true` を付けて exit code を 0 にする: +```bash +# チェック一覧を取得(failがあっても止まらない) +gh pr checks 11765 2>&1 || true + +# --watch で完了待ちする場合も同様 +gh pr checks 11765 --watch 2>&1 || true + +# 失敗のみフィルタする場合 +gh pr checks 11765 2>&1 | grep -i fail || true +``` + +### 補足 +同様の問題が発生する gh コマンド: +- `gh run view RUN_ID --log-failed` (失敗ログ取得時) +- `gh pr diff` (差分が大きい場合にパイプ破損) + +いずれも `2>&1 || true` を付けることで安全に実行できる。 diff --git a/plugins/ndf-shared/skills/git-gh-operations/SKILL.md b/plugins/ndf-shared/skills/git-gh-operations/SKILL.md new file mode 100644 index 0000000..68b8a7a --- /dev/null +++ b/plugins/ndf-shared/skills/git-gh-operations/SKILL.md @@ -0,0 +1,228 @@ +--- +name: git-gh-operations +description: "Resolve git and GitHub CLI operation errors." +when_to_use: "git / gh コマンドでエラーが出た or 操作方法に迷うとき。Triggers: 'git add', 'git commit', 'git push', 'gh pr', 'gh api', 'GitHub操作', 'gitエラー', 'fatal:', 'pathspec'" +allowed-tools: + - Bash + - Read +--- + +# Git / gh 操作スキル + +## 最重要ルール: CWD とパスの整合性 + +git コマンドはすべて **CWD からの相対パス** で解決される。 +操作前に必ず `pwd` で CWD を確認すること。 + +### パターン1: CWDがサブディレクトリの場合 + +``` +# CWD: /work/repo/lambda-batch/MyProject/ +# リポジトリルート: /work/repo/ + +# NG: リポジトリルートからのパスを指定 +git add lambda-batch/MyProject/src/foo.py +# => fatal: pathspec did not match any files + +# OK: CWDからの相対パスを指定 +git add src/foo.py + +# OK: 絶対パスを指定 +git add /work/repo/lambda-batch/MyProject/src/foo.py +``` + +### パターン2: 安全な方法 + +```bash +# 方法A: git -C でリポジトリルートを指定 +git -C /work/repo add lambda-batch/MyProject/src/foo.py + +# 方法B: CWD を変更せずに絶対パスを使用 +git add "$(git rev-parse --show-toplevel)/lambda-batch/MyProject/src/foo.py" + +# 方法C(推奨): CWDからの相対パスを使用 +# まず pwd で確認してからパスを組み立てる +``` + +## git 操作チェックリスト + +### git add の前に + +1. `pwd` で CWD を確認 +2. `git status` で変更ファイルのパスを確認(表示されるパスはリポジトリルートからの相対パス) +3. `git status` の出力パスと CWD の関係を計算してから `git add` する + +### git commit の前に + +1. `git diff --cached` でステージング内容を確認 +2. HEREDOC形式でメッセージを渡す(改行・特殊文字の問題回避) + +```bash +git commit -m "$(cat <<'EOF' +コミットメッセージ + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +## gh CLI / GitHub API の注意点 + +### パラメータ: `-f` vs `-F` + +```bash +# -f: 文字列パラメータ +gh api repos/OWNER/REPO/pulls/PR/comments -f body="テキスト" + +# -F: 非文字列パラメータ(数値、boolean、null、ファイル) +gh api repos/OWNER/REPO/pulls/PR/comments -F in_reply_to=2826074026 + +# 混在OK +gh api repos/OWNER/REPO/pulls/PR/comments -f body="返信テキスト" -F in_reply_to=2826074026 +``` + +### PRレビューコメントの取得 + +```bash +# コメント一覧を取得(id, path, body の先頭を表示) +gh api repos/OWNER/REPO/pulls/PR/comments \ + --jq '.[] | {id: .id, path: .path, body: (.body | split("\n")[0][:80])}' +``` + +### PRレビューコメントへの返信 + +```bash +# NG: /replies エンドポイントは存在しない(404になる) +gh api repos/OWNER/REPO/pulls/comments/{id}/replies -f body='...' +# => 404 Not Found + +# NG: -X POST を付けても同じ(エンドポイント自体が存在しない) +gh api -X POST repos/OWNER/REPO/pulls/comments/{id}/replies -f body='...' +# => 404 Not Found + +# OK: in_reply_to パラメータを使って新規コメントとして投稿 +gh api repos/OWNER/REPO/pulls/PR/comments \ + -f body="返信テキスト" \ + -F in_reply_to=COMMENT_ID +``` + +### レビュースレッドの Resolve(GraphQL) + +```bash +# 1. 未解決スレッドのID一覧を取得 +gh api graphql -f query=' +query { + repository(owner: "OWNER", name: "REPO") { + pullRequest(number: PR) { + reviewThreads(first: 50) { + nodes { + id + isResolved + comments(first: 1) { + nodes { path body } + } + } + } + } + } +}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {id, path: .comments.nodes[0].path}' + +# 2. スレッドを Resolve +gh api graphql -f query=' +mutation { + resolveReviewThread(input: {threadId: "PRRT_xxx"}) { + thread { isResolved } + } +}' +``` + +### PR の CI チェック結果 + +`gh pr checks` は1つでもfailがあると **exit code 1** で終了する。 +Claude Codeではコマンド失敗と判定されて処理が止まるため、必ず `|| true` を付ける。 + +```bash +# NG: failがあるとexit code 1で止まる +gh pr checks PR --repo OWNER/REPO + +# OK: exit codeを常に0にして出力を取得 +gh pr checks PR --repo OWNER/REPO 2>&1 || true + +# OK: 失敗のみフィルタ +gh pr checks PR --repo OWNER/REPO 2>&1 | grep -i fail || true +``` + +#### 重要: CIの完了を待ってはいけない + +- `--watch` や完了までのポーリングは **禁止**。現在のステータスを一度スナップショットするだけでよい。 +- チェックが `in_progress` / `queued` / `pending` の場合は **完了を待たず次のステップへ進む**。 +- 対応対象は **コード修正で直せるfailのみ**。以下のような「ステータス確認系」チェックは無視する: + - `check_pr_requirements` 等、PR要件・メタ情報のみ検証するもの + - Lint/テストに非依存なラベル/タイトル/説明チェック + - 外部サービス起因で自己修復するトランジェントなfail(再実行で直るもの) +- 対応する: ビルド失敗・テスト失敗・型エラー・lint違反など、**リポジトリ内コードの修正で解消可能なもの**。 + +```bash +# 失敗ジョブのログ(エラー行のみ抽出) +gh run view RUN_ID --repo OWNER/REPO --log-failed 2>&1 \ + | grep -E '(FAIL|Error|Tests:)' | head -20 || true +``` + +### 自分のPRは Approve できない + +``` +# GitHub の制約: 自分で作成した PR に APPROVE レビューは不可 +# => "Can not approve your own pull request" +# 対策: event を "COMMENT" に変更して送信 +``` + +### PR作成時の body は HEREDOC + +```bash +# NG: \n がリテラルで混入する可能性 +gh pr create --title "タイトル" --body "行1\n行2" + +# OK: HEREDOC形式 +gh pr create --title "タイトル" --body "$(cat <<'EOF' +## Summary +- 変更内容 + +## Test plan +- [ ] テスト項目 +EOF +)" +``` + +## AWS CLI の注意点 + +### CloudWatch ログストリーム名の [$LATEST] + +```bash +# NG: --query で [$LATEST] を含む文字列がパースエラー +aws logs get-log-events --query 'events[*].message' --output text + +# OK: --output json にして python でパース +aws logs get-log-events --output json | python3 -c " +import sys,json +data = json.loads(sys.stdin.read()) +for e in data['events']: + print(e['message'].strip()) +" +``` + +## エラー事例集 + +| エラーメッセージ | 原因 | 対策 | +|----------------|------|------| +| `fatal: pathspec '...' did not match any files` | CWD とパスの不一致 | `pwd` 確認後、CWD相対パスで指定 | +| `404 Not Found` (gh api replies) | `/comments/{id}/replies` は存在しない | `in_reply_to` パラメータで投稿 | +| `422 Unprocessable` (gh api) | `-f` で数値を渡した | 数値は `-F` を使う | +| `Can not approve your own pull request` | 自己 Approve 不可 | `COMMENT` イベントに変更 | +| `gh pr checks` が exit code 1 | 1つでもfailがあると非0終了 | `gh pr checks ... 2>&1 \|\| true` | +| `Unknown options: , , ,` (aws cli) | `[$LATEST]` のシェルエスケープ | `--output json` + python パース | + +## 詳細ガイド + +| ファイル | 内容 | 参照タイミング | +|---------|------|--------------| +| `01-common-errors.md` | 詳細なエラー事例と再現手順 | エラー発生時 | diff --git a/plugins/ndf/skills/google-auth/SKILL.md b/plugins/ndf-shared/skills/google-auth/SKILL.md similarity index 100% rename from plugins/ndf/skills/google-auth/SKILL.md rename to plugins/ndf-shared/skills/google-auth/SKILL.md diff --git a/plugins/ndf/skills/google-auth/pyproject.toml b/plugins/ndf-shared/skills/google-auth/pyproject.toml similarity index 100% rename from plugins/ndf/skills/google-auth/pyproject.toml rename to plugins/ndf-shared/skills/google-auth/pyproject.toml diff --git a/plugins/ndf/skills/google-auth/scripts/google_auth.py b/plugins/ndf-shared/skills/google-auth/scripts/google_auth.py similarity index 100% rename from plugins/ndf/skills/google-auth/scripts/google_auth.py rename to plugins/ndf-shared/skills/google-auth/scripts/google_auth.py diff --git a/plugins/ndf/skills/google-auth/uv.lock b/plugins/ndf-shared/skills/google-auth/uv.lock similarity index 100% rename from plugins/ndf/skills/google-auth/uv.lock rename to plugins/ndf-shared/skills/google-auth/uv.lock diff --git a/plugins/ndf/skills/google-chat/SKILL.md b/plugins/ndf-shared/skills/google-chat/SKILL.md similarity index 100% rename from plugins/ndf/skills/google-chat/SKILL.md rename to plugins/ndf-shared/skills/google-chat/SKILL.md diff --git a/plugins/ndf/skills/google-chat/pyproject.toml b/plugins/ndf-shared/skills/google-chat/pyproject.toml similarity index 100% rename from plugins/ndf/skills/google-chat/pyproject.toml rename to plugins/ndf-shared/skills/google-chat/pyproject.toml diff --git a/plugins/ndf/skills/google-chat/scripts/gchat_read.py b/plugins/ndf-shared/skills/google-chat/scripts/gchat_read.py similarity index 100% rename from plugins/ndf/skills/google-chat/scripts/gchat_read.py rename to plugins/ndf-shared/skills/google-chat/scripts/gchat_read.py diff --git a/plugins/ndf/skills/google-chat/uv.lock b/plugins/ndf-shared/skills/google-chat/uv.lock similarity index 100% rename from plugins/ndf/skills/google-chat/uv.lock rename to plugins/ndf-shared/skills/google-chat/uv.lock diff --git a/plugins/ndf/skills/google-drive/SKILL.md b/plugins/ndf-shared/skills/google-drive/SKILL.md similarity index 100% rename from plugins/ndf/skills/google-drive/SKILL.md rename to plugins/ndf-shared/skills/google-drive/SKILL.md diff --git a/plugins/ndf/skills/google-drive/pyproject.toml b/plugins/ndf-shared/skills/google-drive/pyproject.toml similarity index 100% rename from plugins/ndf/skills/google-drive/pyproject.toml rename to plugins/ndf-shared/skills/google-drive/pyproject.toml diff --git a/plugins/ndf/skills/google-drive/scripts/gdrive_fetch.py b/plugins/ndf-shared/skills/google-drive/scripts/gdrive_fetch.py similarity index 100% rename from plugins/ndf/skills/google-drive/scripts/gdrive_fetch.py rename to plugins/ndf-shared/skills/google-drive/scripts/gdrive_fetch.py diff --git a/plugins/ndf/skills/google-drive/uv.lock b/plugins/ndf-shared/skills/google-drive/uv.lock similarity index 100% rename from plugins/ndf/skills/google-drive/uv.lock rename to plugins/ndf-shared/skills/google-drive/uv.lock diff --git a/plugins/ndf-shared/skills/implementation-plan/SKILL.md b/plugins/ndf-shared/skills/implementation-plan/SKILL.md new file mode 100644 index 0000000..0e0a130 --- /dev/null +++ b/plugins/ndf-shared/skills/implementation-plan/SKILL.md @@ -0,0 +1,98 @@ +--- +name: implementation-plan +description: "Create or update implementation plan files." +when_to_use: "実装開始時 / PR作成時に実装プランの作成・更新が必要なとき。複数ファイル変更・新機能追加・DBマイグレーションを含む変更で自動参照。Triggers: '実装プラン', '実装を開始', 'PR作成', 'implementation plan', 'plan first', '設計書を作成', 'issues/に追加'" +--- + +# 実装プランガイド + +## 基本方針 + +実装の開始時およびPR作成時に、`issues/` 配下に実装プランファイルが存在するか確認し、なければ作成する。プランを残すことで後任エンジニアや将来の自分が変更意図を追跡できる。 + +## 実装プランが必要なケース + +以下のいずれかに該当する場合は作成する: + +- 複数ファイルにまたがる変更 +- 新規機能の追加 +- 既存ロジックの大幅な変更 +- DBマイグレーションを伴う変更 +- 複数のタスクに分解できる作業 + +## 実装プランが不要なケース + +以下のような軽微な変更では不要: + +- typo修正、文言変更 +- 設定値の変更のみ +- 1ファイルで完結する軽微な修正 +- フォーマッター適用のみ +- ドキュメントのみの更新 + +判断に迷う場合はユーザーに確認する。 + +## ファイル配置・命名 + +- パス: `issues/` +- ファイル名に日本語は含めないこと(Git/CI/検索ツール互換性のため) +- タスクIDがある場合: `issues/TASK-1234_concise-description.md` +- タスクIDがない場合: `issues/{feature-name}.md` + +## PR作成時のプランファイル生成 + +PR作成時に `issues/` にプランファイルが存在しない場合、以下の情報源からプランファイルを生成する: + +1. **会話履歴** - それまでのやりとりから要件・背景・方針を抽出 +2. **git log** - コミット履歴からタスクの流れと変更概要を把握 +3. **git diff** - 実際の変更内容から修正対象ファイルと変更内容を特定 + +これらを組み合わせて、下記フォーマットに沿ったプランファイルを作成してからPRを作成する。 + +## プランのフォーマット + +```markdown +# {タスクID}: {機能名/修正内容} + +## 関連リンク +(Issue/チケット/設計ドキュメントがあれば記載) + +## 概要 +- 何を実装・修正するのか + +## 問題・背景 +- なぜこの変更が必要なのか(該当する場合) + +## 修正対象 +- 変更対象のファイルパス一覧 + +## タスク分解 + +### Task 1: {タスク名} +- **対象ファイル:** 変更対象のファイルパス +- **変更内容:** 具体的な変更内容 + +### Task 2: {タスク名} +- **対象ファイル:** 変更対象のファイルパス +- **変更内容:** 具体的な変更内容 + +## 影響範囲 +- 変更による影響を受ける機能やファイル + +## テスト計画 +- [ ] {実装した機能が正しく動作することの確認} +- [ ] {既存機能にリグレッションがないことの確認} +``` + +## ワークフロー + +1. 実装の依頼を受けたら、まずプランが必要か判断する +2. 必要な場合は `issues/` にプランファイルを作成してから実装を開始する +3. PR作成時にプランファイルが存在しない場合、必要であれば会話履歴・git log・git diffからプランファイルを生成してからPRを作成する + +## プランと PR Body の関係 + +- プランファイル = 「なぜ」「どう分解するか」を残す永続的な記録 +- PR body = 「何をやったか」「どうテストするか」のレビュー用サマリ + +同じ内容をコピーせず、PR bodyでは「詳細は `issues/xxx.md` 参照」と誘導してもよい。 diff --git a/plugins/ndf-shared/skills/investigation-rules/SKILL.md b/plugins/ndf-shared/skills/investigation-rules/SKILL.md new file mode 100644 index 0000000..a4757a8 --- /dev/null +++ b/plugins/ndf-shared/skills/investigation-rules/SKILL.md @@ -0,0 +1,105 @@ +--- +name: investigation-rules +description: "Write evidence-backed investigation and debug reports." +when_to_use: "調査・デバッグ・不具合レポートを作成するとき。「ない」「該当なし」等の否定的結論を出すときは必ず参照。Triggers: '調査', 'デバッグ', '不具合レポート', '原因調査', 'investigation', 'root cause', 'カラムがない', '該当コードがない', 'データがない'" +--- + +# 調査レポート作成ルール + +不具合調査・データ調査・仕様調査でレポートを作成する際のルール。コード読解だけに頼らず、必ず実行結果・出力・実データで裏取りする。 + +## 否定的結論にはエビデンス必須 + +「カラムがない」「データがない」「関数が呼ばれていない」「該当コードがない」等の **否定的な結論** を書く場合、**必ず実行結果をエビデンスとして添付すること**。 + +### なぜこのルールが必要か + +AIは「もっともらしいが間違った推論」をしがちで、コード読解だけで「ない」と断定して誤判断を招きやすい。事例として、外部テーブルの一部カラムだけを見て「該当カラムなし」と結論づけたが、実際には別名のカラムにデータが存在していた、という判断ミスが典型。 + +### 具体的な裏取り方法 + +| 主張の種類 | 必須エビデンス | +|-----------|--------------| +| DB: カラムが存在しない | `SHOW COLUMNS FROM table_name` / `DESCRIBE` の結果 | +| DB: データが存在しない | `SELECT COUNT(*) FROM table WHERE ...` の結果 | +| DB: テーブルが存在しない | `SHOW TABLES LIKE '%keyword%'` の結果 | +| コード: 関数/シンボルが存在しない | `grep -rn 'name' .` / LSP検索 / Serena `find_symbol` の結果 | +| コード: 呼び出し箇所がない | `find_referencing_symbols` / `grep` の結果 | +| 設定: 値が存在しない | 設定ファイルのdiff / `env` / `config` コマンド出力 | +| ログ: エラーが出ていない | `grep` / 検索ツールのクエリと結果期間 | + +### レポートへの記載例 + +```markdown +### 残課題 + +| 課題 | 概要 | エビデンス | 優先度 | +|------|------|-----------|--------| +| 外部API の retry 未実装 | Xクライアントで retry ハンドリングが無い | `grep -rn "retry\|Retry" src/client/x/` → 0件 | 中 | +| status=deleted の件数 | 論理削除レコードが残存 | `SELECT COUNT(*) FROM ... WHERE status='deleted'` → 2,341件 | 低 | +``` + +### やってはいけないこと + +- コードを読んだだけで「このカラムは存在しない」と断定する +- 1つのテーブル/ファイルだけ見て「データに問題はない」と結論づける +- 外部テーブルの一部のカラムだけ見て「他にはない」と判断する(全カラムを確認する) +- エビデンスなしで残課題の優先度を「低」にする(誤判断の典型) + +## 外部データ調査の原則 + +外部API/外部テーブル/サードパーティデータソースを調査する際は、**全体構造を必ず確認する**。 + +```sql +-- まず全体像を把握する +SHOW COLUMNS FROM external_source_table; + +-- 次に対象カラムのデータ分布を確認する +SELECT column_name, COUNT(*) FROM table GROUP BY column_name; +``` + +外部データは外部システム由来でカラム名・値域が予測しづらいため、コードから逆引きするだけでは見落とす。 + +## ハルシネーション防止チェックリスト + +推論で埋めず、必ず以下を実行して裏取りする: + +| チェック項目 | 方法 | +|------------|------| +| カラム/フィールドが存在するか | `SHOW COLUMNS` / スキーマ定義ファイルを開く | +| データが存在するか | `SELECT COUNT(*) WHERE ...` / サンプルレコード取得 | +| 型が一致するか | DB定義とアプリコード両方を確認(Eloquent `$casts`、dataclass型等) | +| FK/制約が存在するか | マイグレーション履歴を追跡(追加→削除→再追加の変遷を確認) | +| 論理削除ポリシーは何か | `SoftDeletes` / `deleted_at` の有無を確認 | +| 環境差異がないか | dev/staging/prod で同じクエリを実行して比較 | + +## 調査結果の書き方テンプレート + +```markdown +## 症状 +何がどう間違っているか(定量的に、エビデンス付きで) + +## 調査経緯 +1. 仮説1: xxx → クエリ/コマンドで確認 → 否定/肯定 +2. 仮説2: yyy → ... + +## 根本原因 +コードレベルでどこが問題か(ファイル名:行番号で特定) + +## エビデンス +``` +SQL/コマンド実行結果をそのまま貼る +``` + +## 修正方針 +どのフェーズで何を直すか(多層防御の観点) + +## 検証手順 +修正後にどう確認するか(回帰テスト含む) +``` + +SQLクエリ結果・コマンド出力をそのまま貼り、「コードを読んだ推測」と「実行して確認した事実」を明確に区別する。 + +## 関連スキル + +- `/ndf:problem-solving` — 根本原因分析と多層防御の原則 diff --git a/plugins/ndf-shared/skills/issue-plan-strategy/SKILL.md b/plugins/ndf-shared/skills/issue-plan-strategy/SKILL.md new file mode 100644 index 0000000..4275a37 --- /dev/null +++ b/plugins/ndf-shared/skills/issue-plan-strategy/SKILL.md @@ -0,0 +1,335 @@ +--- +name: issue-plan-strategy +description: "Turn issues into plans and implementation workflows." +when_to_use: "issue → plan 作成 / 既存 plan の実装 (実行) を依頼されたとき。複数 PR に分割される設計や、release branch + 個別 PR + worktree 運用が必要なときに参照する。Triggers: 'issueのplanを作って', 'PLANxxの設計', '設計書を起こして', 'このplanを実装して', 'PLANxxを実装', 'planを実行', 'release branch 作って実装開始', 'multi-PR で進めて'" +argument-hint: "[issue-path-or-url] (例: issues/i16.md, https://github.com/org/repo/issues/123)" +allowed-tools: + - Bash + - Read + - Write + - Edit + - Glob + - Grep +--- + +# issue → plan → multi-PR ワークフロー + +1 つの issue から plan を作る際、推奨される PR が複数に分かれることは日常的に発生する。本 skill はその際の **release ブランチ + 個別 PR ブランチ + Draft PR 先行作成 + git worktree 並行開発 + レビュー運用** の標準フローを規定する。 + +本 skill は **plan の作成フェーズと plan の実行(実装)フェーズの両方** をカバーする。同じワークフローが「設計を起こす段階」と「設計に従って実装する段階」を貫通することで、作成者と実装者(あるいは将来の自分)が同じ手順を共有できる。 + +## 発動条件 + +| トリガ | 例 | 入る Step | +|---|---|---| +| スラッシュコマンド (引数あり) | `/ndf:issue-plan-strategy issues/foo.md`、`/ndf:issue-plan-strategy https://github.com/org/repo/issues/123` | Step 0 から | +| スラッシュコマンド (引数なし) | `/ndf:issue-plan-strategy` (現在ブランチで作業中の issue/plan を解析) | Step 0 から | +| 自動発動 (作成系) | 「この issue の plan を作って」「設計書を起こして」「PLAN42 の設計を起こして」 | Step 1〜2 | +| 自動発動 (実行系) | 「この plan を実装して」「PLAN42 を実行して」「multi-PR で進めて」「release branch を切って実装開始」 | Step 0 → 既存 plan を読み → Step 3 以降 | + +引数で渡された issue / plan は **ファイルパス / URL / 番号** いずれでも受け付ける: + +- ファイルパス (`issues/PLANxx_*.md`): 直接 Read +- GitHub Issue URL / `#番号`: `gh issue view --json title,body,labels` で取得 +- それ以外の文字列: そのまま issue 本文として扱う + +## Step 0: 作成フェーズか実行フェーズか判定 + +最初に **既に plan ファイルが存在するか** で判定する。skill 内で `Glob` を使うのが第一選択 (例: `Glob('issues/*PLAN42*')`)。shell で確認する場合は: + +```bash +# issues/ 配下に該当 plan があるか (PLAN42 / feature-name 部分は実値に置換) +find issues/ -maxdepth 1 -iname '*PLAN42*' -o -iname '*feature-name*' +``` + +| 状況 | 進むフェーズ | +|---|---| +| plan ファイルがない / issue しかない | **作成フェーズ** (Step 1〜2 へ) | +| plan ファイルがあり、release branch がない | **実行フェーズ・初期化** (Step 3 へ) | +| release branch も Draft PR も既にある | **実行フェーズ・継続** (Step 5 以降。worktree / 並行開発 / レビュー / merge を進める) | + +実行フェーズで入った場合、既存 plan の **「PR 分割計画」セクション**を必ず Read してから Step 3 以降の自動化判断に使う。 + +## 全体フロー + +``` + ┌─ 作成フェーズ ──────────────────────────────────────┐ +issue 取得 ─┤ │ + │ plan 作成 (必要なら plan モード) ─ 単一PR? ─ YES ─▶ implementation-plan + /ndf:pr で完了 + │ │ + │ NO + └──────────────────────────────────────────┼──────────┘ + ▼ + ┌─ 実行フェーズ ──────────────────────────────────────┐ +既存 plan ─▶│ Step 3: release branch 作成 + Draft release PR │ + │ Step 4: 個別 PR ブランチ作成 + 各 Draft PR (release base) + │ Step 5: git worktree で並行開発 (依存関係を考慮) │ + │ Step 6: 個別 PR ごとに /ndf:review or /ndf:cross-review + │ → /ndf:fix → merge into release │ + │ Step 7: release ブランチで結合テスト相当のレビュー │ + │ Step 8: release PR body 最終化 → Ready & merge │ + └─────────────────────────────────────────────────────┘ +``` + +QA / staging 等の検証環境向けには、個別 PR or release PR 単位で `/ndf:cherry-pick-pr` を別途実行する (Step 9)。 + +実行フェーズに途中から入った場合は、対応する Step の途中再開で構わない。各 Step の冒頭で **既に存在するブランチ / PR / worktree を `git branch -a` / `gh pr list` / `git worktree list` で確認**してから作業に入る。 + +## Step 1: issue 取得と plan 作成 (作成フェーズ専用) + +> 実行フェーズで入った場合はこの Step をスキップし、既存 plan を Read して Step 3 へ進む。 + +1. 引数を解釈して issue 本文を取得する +2. `issues/` 配下に plan ファイルが既に存在するか `Glob` で確認する +3. なければ `/ndf:implementation-plan` の **プランフォーマット**に従って plan ファイルを作成する + - ファイル名は英数 (例: `issues/PLAN42_multi-pr-refactor.md`) + - 内容に「複数 PR に分割する根拠」「PR 単位と依存関係」を必ず含める +4. 設計判断が重い場合は **Claude Code の plan モード** (ExitPlanMode を用いる読み取り専用フェーズ) に切り替えて十分検討してから実装へ進む + +plan の構造は `/ndf:implementation-plan` を参照。本 skill では multi-PR を前提に **以下のセクションを追加**する: + +```markdown +## PR 分割計画 + +| PR # | branch 名 | 概要 | 依存 | 並行可否 | +|---|---|---|---|---| +| 1 | feature/PLAN42-schema | スキーマ追加 | なし | ○ | +| 2 | feature/PLAN42-api | API 実装 | PR1 | × (PR1 merge 後) | +| 3 | feature/PLAN42-ui | UI 実装 | PR1 | ○ (mock で開始可) | + +release branch: `release/PLAN42` +base branch: `main` +``` + +## Step 2: 単一 PR で足りるか判定 + +plan を書いた結果が以下のいずれかなら **release ブランチを作らず**、`/ndf:implementation-plan` + `/ndf:pr` の通常フローに切り替える: + +- 変更ファイルが 1〜2 個で結合度が低い +- 1 PR で安全に review 可能 (差分 ~500 行以内が目安) +- 依存関係のある複数タスクが存在しない + +複数 PR が妥当な場合 (スキーマ + API + UI、機能追加 + マイグレーション、複数モジュール横断 等) のみ Step 3 に進む。 + +## Step 3: release ブランチ + Draft PR 先行作成 (実行フェーズの開始点) + +> 実行フェーズで自動発動した場合の最初の自動化対象。既に `release/` ブランチや Draft PR が存在する場合は作成をスキップし、Step 4 へ進む。 + +### release ブランチ作成 + +```bash +git fetch origin +git checkout -b release/ origin/ +git push -u origin release/ +``` + +### レビュアー視点の原則 (release PR body の大前提) + +個別 PR はセルフレビュー (`/ndf:cross-review` 等) で merge される。**人間のレビュアーが見るのは release PR だけ**であり、個別 PR の存在をレビュアーに意識させてはならない。したがって: + +- release PR の body は **self-contained 必須**: 「何のために」(背景・解決したい課題) と「何を」(release ブランチ全体としての変更内容) を、**個別 PR を一切参照せずに**理解できる粒度で書く +- 個別 PR リンクの列挙を body の本文にしない。開発中の進捗管理に使う場合は `
` 折りたたみ内の補足情報に格下げする +- `/ndf:cross-review` の light rotation と同じ原則を適用する: 現状の差分・実装を反映し、内部用語 (PLAN-ID 運用、round、rotated 等) をレビュアー向け本文に漏らさない + +### release → default の Draft PR を先行作成 + +```bash +gh pr create \ + --base \ + --head release/ \ + --draft \ + --title "release: <概要>" \ + --body "$(cat <<'EOF' +## Summary +- (背景) なぜこの変更が必要か / 解決したい課題 +- (変更内容) release ブランチ全体として何をするか +- plan: issues/_xxx.md + +## Test plan (結合観点のみ) +- [ ] 個別 PR では検出できない結合テスト項目 + +
+開発用: 個別 PR 進捗 (レビュー対象外) + +- [ ] # PR1: ... +- [ ] # PR2: ... +- [ ] # PR3: ... + +
+ + +EOF +)" +``` + +Draft 作成時点では実装が進んでいないため body は plan ベースの暫定でよいが、Ready for review 前に **実装の最終形を反映した body へ最終化**する (Step 8 参照)。 + +release PR を **先に作る理由**: PR 番号が確定し、個別 PR の説明から参照できるため。 + +## Step 4: 個別 PR ブランチ + Draft PR 先行作成 + +> 既存ブランチは `git branch -a | grep "feature/-"` で確認し、未作成のものだけ作る。Draft PR の存在は `gh pr list --base release/ --state all` で確認。 + +各 PR について **同じパターンで先に Draft PR まで作る**: + +```bash +# release ブランチを base に個別ブランチを切る +git fetch origin release/ +git checkout -b feature/- origin/release/ + +# 空コミットで push して Draft PR を作る (base=release と HEAD が同一だと +# gh pr create が "No commits between ..." で失敗するため、差分ゼロのまま PR +# 作成のトリガにする目的で `--allow-empty` を使う) +git commit --allow-empty -m "chore: - Draft PR 作成" +git push -u origin feature/- + +gh pr create \ + --base release/ \ + --head feature/- \ + --draft \ + --title "feat: - <概要>" \ + --body "$(cat <<'EOF' +## Summary +- plan: issues/_xxx.md +- release PR: # +- 担当範囲: + +## Test plan +- [ ] ... + + +EOF +)" +``` + +完了後 release PR の本文を `gh pr edit` で更新し、`
` 内の開発用チェックリストに個別 PR 番号を埋める (body 本文には書かない)。 + +## Step 5: git worktree で並行開発 + +並行可能 (依存なし or mock で先行可) な PR は **git worktree** で同時に開く: + +```bash +# repo ルート (default branch のまま) で +git worktree add ../--schema feature/-schema +git worktree add ../--ui feature/-ui + +# それぞれの worktree で別ターミナル / 別エージェントを起動 +``` + +ガイドライン: + +- **依存のある PR は順次着手**する (PR1 merge → PR2 開始) +- 並行 PR 間で同じファイルを触る場合は事前にレビュー観点で分担を明確化する +- 終わった worktree は `git worktree remove ` で片付ける +- Claude Code から並行開発を指示する場合、Agent tool の `isolation: "worktree"` も検討する + +## Step 6: 個別 PR のレビュー + +**レビューは原則個別 PR 単位**で行う: + +| 用途 | コマンド | +|---|---| +| PR 作成前のセルフレビュー | `/ndf:review-branch` | +| GitHub 上の単体レビュー | `/ndf:review ` | +| codex + gemini 両方の収束ループ | `/ndf:cross-review ` | +| 指摘の修正 | `/ndf:fix ` | + +個別 PR が APPROVE → Draft 解除 → release ブランチへ merge (squash 推奨)。 + +## Step 7: release ブランチのレビュー (結合テスト相当のみ) + +release ブランチへの merge が一通り進んだ段階で: + +- **個別 PR で見た観点を再レビューしない** +- **結合テスト相当**の観点のみレビューする: + - PR 間の API / 型 / スキーマ整合 + - 設定値の重複・矛盾 + - migration の順序依存 + - E2E シナリオ (`/ndf:playwright-scenario-test` の活用) +- ここで個別 PR 範囲のバグが見つかった場合は、**release PR にコメントせず**、該当の個別 PR (既に merge 済みなら新しい修正 PR を release 配下に作成) 側に指摘を書き込み、修正ループを回す +- release PR には integration 観点の指摘のみ残す + +## Step 8: release PR body の最終化と release → default の merge + +### body の最終化 (Ready for review の前に必須) + +個別 PR が全て merge されたら、**Draft 解除の前に** release PR の body を実装の最終形を反映した self-contained な内容へ更新する: + +```bash +# release ブランチ全体の差分を確認して body を書き直す +git fetch origin +git diff origin/...origin/release/ --stat +gh pr edit --title "..." --body "..." +``` + +最終化のチェック観点 (Step 3 のレビュアー視点の原則を満たすこと): + +- [ ] 「何のために」「何を」が個別 PR や plan ファイルを辿らずに理解できる +- [ ] 実装中の方針変更・スコープ増減が body に反映されている +- [ ] 個別 PR への参照が本文に残っていない (`
` 内の開発用情報は残してよい) +- [ ] 内部用語 (round、rotated 等) が漏れていない + +### Draft 解除と merge + +release PR が APPROVE されたら: + +```bash +# Draft 解除 +gh pr ready +# merge: 個別 PR が既に squash 済みで release ブランチに並んでいるため、 +# main 側でも個別 PR 単位の commit を追跡できる `--merge` (merge commit 保持) +# が既定として推奨。プロジェクト規約で線形履歴必須なら `--rebase`、 +# それ以外で commit 数を 1 本にしたい場合のみ `--squash`。 +gh pr merge --merge --delete-branch +``` + +merge 後は plan ファイル末尾に「完了サマリ」(マージ済み PR 番号 / 検証結果) を追記してクローズ化する。 + +## Step 9: 検証環境 (qa/staging 等) への適用 + +QA / staging 検証は **個別 PR 単位** or **release ブランチ単位** のどちらでも OK。 +`/ndf:cherry-pick-pr` は Claude Code 内の slash command なので、shell ではなく +Claude Code セッション上で実行する点に注意。 + +個別 PR 単位で qa に反映する場合: + +```text +# (Claude Code 内で実行する slash command) +/ndf:cherry-pick-pr qa/staging +``` + +release ブランチごと qa に反映する場合 (まとまった検証が必要な場合): + +```bash +# 1. shell で release ブランチに切り替え +git checkout release/ +``` + +```text +# 2. (Claude Code 内で実行する slash command) +/ndf:cherry-pick-pr qa/staging +``` + +詳細は `/ndf:cherry-pick-pr` と `/ndf:branch-fix-strategy` を参照。`feature → main` 系 PR を汚染しないため、検証ブランチ向けは必ず短命ブランチ経由で扱う。 + +## アンチパターン + +| ❌ やってはいけないこと | 理由 | +|---|---| +| release ブランチを作らず巨大な 1 PR で出す | レビュー困難・revert 困難・並行開発不可 | +| 個別 PR の base を default にする | release で統合する意味が失われ、partial merge が default を汚染する | +| 個別 PR Draft 作成を実装後に回す | PR 番号が未確定でクロス参照や CI 待機の段取りが組めない | +| release PR で個別 PR 範囲の指摘を解決しようとする | 該当 PR が既に閉じている場合、コミット意図がずれる | +| release PR の body を個別 PR リンクの列挙だけにする | レビュアーは release PR 単体で変更を把握できず、個別 PR や plan を辿ることになる。body は self-contained 必須 (Step 3 / Step 8) | +| body 最終化せずに Ready for review にする | Draft 作成時の plan ベースの暫定 body のままだと実装の最終形と乖離する | +| 検証ブランチを feature/release に merge する | `feature → main` PR への汚染 (詳細: `/ndf:branch-fix-strategy`) | + +## 関連 skill + +- `/ndf:implementation-plan` — plan ファイルのフォーマット (本 skill が依存) +- `/ndf:branch-fix-strategy` — ブランチ汚染を避ける原則 +- `/ndf:pr` — 通常の PR 作成 / 更新 +- `/ndf:cherry-pick-pr` — 検証ブランチへの cherry-pick PR +- `/ndf:review` / `/ndf:review-branch` / `/ndf:cross-review` — レビュー +- `/ndf:fix` / `/ndf:resolve-pr-comments` — コメント対応 +- `/ndf:playwright-scenario-test` — release ブランチでの E2E 結合テスト diff --git a/plugins/ndf/skills/knowledge-reorg/SKILL.md b/plugins/ndf-shared/skills/knowledge-reorg/SKILL.md similarity index 100% rename from plugins/ndf/skills/knowledge-reorg/SKILL.md rename to plugins/ndf-shared/skills/knowledge-reorg/SKILL.md diff --git a/plugins/ndf-shared/skills/logging-guidelines/SKILL.md b/plugins/ndf-shared/skills/logging-guidelines/SKILL.md new file mode 100644 index 0000000..007b969 --- /dev/null +++ b/plugins/ndf-shared/skills/logging-guidelines/SKILL.md @@ -0,0 +1,112 @@ +--- +name: logging-guidelines +description: "Design safe and useful application logging." +when_to_use: "コードにログを追加・修正・整理するとき。Triggers: 'ログ追加', 'log追加', 'logger', 'logging', 'ログレベル', 'log level', 'デバッグログ', 'エラーログ', 'logger.info', 'logger.error', 'print文をログに'" +--- + +# ログ運用ガイドライン + +コードにログを追加・修正する際は、以下のルールに従うこと。言語/フレームワークに依存しない原則として記述している。 + +## ログレベルの選択基準 + +| レベル | 用途 | 本番出力(推奨) | +|--------|------|---------------| +| `error` | 例外発生、処理失敗 | o | +| `warning` | データ不備でスキップ、処理継続可能な異常 | o | +| `info` | バッチ開始/完了、重要なビジネスイベント | 環境による(本番off推奨) | +| `debug` | 開発向けデバッグ情報 | x | + +**推奨**: 本番は `LOG_LEVEL=warning` 以上。info/debug は開発・ステージングのみで出力する。 + +## 使用を避けるログレベル + +以下は用途が曖昧または過剰なため、明示的な運用規則がない限り使わない: + +- `notice` — error/warning/info と区別が曖昧 +- `critical`, `alert`, `emergency` — 通常のアプリには過剰。運用規則として「PagerDuty起動基準」などが定義されていない限り使わない + +## ループ内ログのルール + +### 原則: ループ内では info/warning を出力しない + +ループ内で1件ずつログを出力すると、大量データ処理時にログが爆発する。ループ後にサマリーとしてまとめて出力すること。 + +### サマリーログ化パターン(擬似コード) + +``` +# NG: ループ内で1件ずつ出力 +for item in items: + log.info("処理完了", id=item.id) + +# OK: ループ後にまとめて出力 +processed_count = 0 +for item in items: + # 処理... + processed_count += 1 +log.info("バッチ処理完了", processed_count=processed_count) +``` + +### エラー蓄積パターン + +ループ内で例外が発生し処理を継続する場合は、エラー情報を蓄積してループ後にまとめて報告する。先頭N件のみ含めることで、ログサイズ爆発を防ぐ。 + +``` +errors = [] +for item in items: + try: + process(item) + except Exception as e: + errors.append({"id": item.id, "error": str(e)}) + +if errors: + log.error( + "処理で一部失敗", + total_count=len(items), + failed_count=len(errors), + sample_errors=errors[:10], # 先頭10件のみ + ) +``` + +### ループ内 debug も必要最小限 + +ループ内での debug 出力は、他に代替手段がなく調査に不可欠な場合のみ許容。デフォルトは「ループ外で件数サマリ」を基本とする。 + +## 例外処理のルール + +1. **例外は最上位でログ出力** — エントリポイント(コマンド/コントローラー/ジョブ)で catch してログ出力 +2. **再スロー時はログ不要** — 上位で出力されるため二重出力を避ける +3. **例外を握りつぶさない** — catch後に何も報告せず続行するのは禁止 +4. **広めの例外型で捕捉** — 言語の最上位例外型(Python `Exception`、PHP `Throwable`、Java `Throwable` 等)でトップレベル catch する + +## 必須ルール + +1. **コンテキスト情報を含める** — 調査に必要なID等を構造化ログとして渡す +2. **機密情報を含めない** — パスワード、トークン、クレジットカード番号、個人特定情報は禁止 +3. **メッセージは明確に** — 何が起きたか分かる言葉で記述(プロジェクトの言語ポリシーに従う) +4. **ロガー呼び出しを統一** — プロジェクトで統一ファサード/クライアントを使う(例: Laravel は `Log::`, Python は `logging.getLogger(__name__)`) +5. **グローバル/暗黙の名前空間を使わない** — 明示的にimport/useする + +## ログとメトリクスの使い分け + +- **ログ**: 個別のイベント、エラー、コンテキスト情報(構造化ログ) +- **メトリクス**: 件数、レイテンシ、成功/失敗率の集計(Prometheus/DataDog等) +- **トレース**: リクエスト横断の実行フロー(OpenTelemetry等) + +ループ件数カウントなどは、ログではなくメトリクスに寄せるのが望ましい場合が多い。 + +## アンチパターン一覧 + +| アンチパターン | 問題 | +|--------------|------| +| `log.info("")` / 空メッセージ | 意図が伝わらない | +| `log.error(e)` のみ | スタックトレース/contextが欠ける | +| 機密情報をそのままログに入れる | 情報漏洩リスク | +| ループ内で毎回 info 出力 | ログ爆発 | +| try/except で握りつぶし、何も報告しない | 障害の気配を消す | +| 複数行の ASCII ART をログに含める | grep/集計が困難 | + +## 関連スキル + +- `/ndf:problem-solving` — ログから根本原因を特定する手順 +- `/ndf:investigation-rules` — ログをエビデンスとして扱う際の注意点 diff --git a/plugins/ndf-shared/skills/markdown-writing/01-diagram-guide.md b/plugins/ndf-shared/skills/markdown-writing/01-diagram-guide.md new file mode 100644 index 0000000..db40f52 --- /dev/null +++ b/plugins/ndf-shared/skills/markdown-writing/01-diagram-guide.md @@ -0,0 +1,144 @@ +# 図表作成ガイド + +## mermaid 記法 + +### フローチャート + +```mermaid +graph TD + A[開始] --> B{条件判定} + B -->|Yes| C[処理A] + B -->|No| D[処理B] + C --> E[終了] + D --> E +``` + +### シーケンス図 + +```mermaid +sequenceDiagram + User->>API: リクエスト + API->>DB: クエリ + DB-->>API: 結果 + API-->>User: レスポンス +``` + +### クラス図 + +```mermaid +classDiagram + class User { + +int id + +string name + +login() + +logout() + } + class Order { + +int id + +float total + } + User "1" --> "*" Order +``` + +### ER図 + +```mermaid +erDiagram + USER ||--o{ ORDER : places + ORDER ||--|{ LINE_ITEM : contains + PRODUCT ||--o{ LINE_ITEM : "ordered in" +``` + +## plantUML 記法 + +### コンポーネント図 + +```plantuml +@startuml +package "Frontend" { + [React App] +} +package "Backend" { + [API Server] + [Database] +} +[React App] --> [API Server] +[API Server] --> [Database] +@enduml +``` + +### アクティビティ図 + +```plantuml +@startuml +start +:ユーザー入力; +if (有効?) then (yes) + :処理実行; +else (no) + :エラー表示; +endif +stop +@enduml +``` + +## ASCII 許可例(ツリーのみ) + +ディレクトリ構造はASCIIで表現可能: + +``` +project/ +├── src/ +│ ├── components/ +│ └── utils/ +├── tests/ +└── docs/ +``` + +## よくある間違い + +### 避けるべき: ASCII ARTで図を描く + +``` + ┌─────────┐ + │ User │ + └────┬────┘ + │ + ┌────▼────┐ + │ API │ + └─────────┘ +``` + +上記のような図は **mermaid** で描いてください: + +```mermaid +graph TD + User --> API +``` + +### 避けるべき: 順序prefixなしで分割 + +``` +docs/ +├── introduction.md ← NG: prefixがない +├── setup.md +└── usage.md +``` + +正しい方法: + +``` +docs/ +├── 01-introduction.md ← OK +├── 02-setup.md +└── 03-usage.md +``` + +## ベストプラクティス + +| DO | DON'T | +|----|-------| +| mermaid/plantUMLで図を描く | ASCII ARTで図を描く | +| 300行以内に収める | 1000行超の巨大ファイル | +| 順序prefixで分割 | prefixなしで分割 | +| 2桁パディング(01-, 02-) | 1桁(1-, 2-) | diff --git a/plugins/ndf-shared/skills/markdown-writing/SKILL.md b/plugins/ndf-shared/skills/markdown-writing/SKILL.md new file mode 100644 index 0000000..56b3583 --- /dev/null +++ b/plugins/ndf-shared/skills/markdown-writing/SKILL.md @@ -0,0 +1,58 @@ +--- +name: markdown-writing +description: "Write Markdown docs, diagrams, and split files." +when_to_use: "Markdown 文書 / 図表を作成 / 編集するとき。Triggers: 'Markdown作成', 'ドキュメント作成', '文書作成', '図を描く', 'mermaid', 'create document', 'write docs'" +allowed-tools: + - Read + - Write + - Edit +--- + +# Markdown Writing Skill + +## 重要ルール + +### 1. 図表作成ルール + +**mermaid または plantUML を使用**(ASCII ART禁止、ツリー除く) + +```mermaid +graph TD + A[開始] --> B{条件判定} + B -->|Yes| C[処理A] + B -->|No| D[処理B] +``` + +### 2. 文書の長さと分割ルール + +| ページ数 | 対応 | +|---------|-----| +| ~300行 | そのまま | +| 301~600行 | 2ファイルに分割 | +| 600行以上 | セクションごとに分割 | + +**分割時のファイル名**: 順序prefix(01-, 02-, ...)+ ケバブケース + +``` +docs/feature-guide/ +├── 01-introduction.md +├── 02-installation.md +└── 03-usage.md +``` + +## チェックリスト + +- [ ] 図表はmermaid/plantUML使用(ツリー除く) +- [ ] ファイル長は300行以内(超える場合は分割) +- [ ] 分割時は順序prefix使用(01-, 02-, ...) + +## 詳細ガイド + +| ファイル | 内容 | +|---------|------| +| `01-diagram-guide.md` | mermaid/plantUML記法、よくある間違い | + +## 関連リソース + +- [Mermaid公式ドキュメント](https://mermaid.js.org/) +- [PlantUML公式ドキュメント](https://plantuml.com/) diff --git a/plugins/ndf/skills/mcp-builder/LICENSE.txt b/plugins/ndf-shared/skills/mcp-builder/LICENSE.txt similarity index 100% rename from plugins/ndf/skills/mcp-builder/LICENSE.txt rename to plugins/ndf-shared/skills/mcp-builder/LICENSE.txt diff --git a/plugins/ndf/skills/mcp-builder/SKILL.md b/plugins/ndf-shared/skills/mcp-builder/SKILL.md similarity index 100% rename from plugins/ndf/skills/mcp-builder/SKILL.md rename to plugins/ndf-shared/skills/mcp-builder/SKILL.md diff --git a/plugins/ndf/skills/mcp-builder/reference/evaluation.md b/plugins/ndf-shared/skills/mcp-builder/reference/evaluation.md similarity index 100% rename from plugins/ndf/skills/mcp-builder/reference/evaluation.md rename to plugins/ndf-shared/skills/mcp-builder/reference/evaluation.md diff --git a/plugins/ndf/skills/mcp-builder/reference/mcp_best_practices.md b/plugins/ndf-shared/skills/mcp-builder/reference/mcp_best_practices.md similarity index 100% rename from plugins/ndf/skills/mcp-builder/reference/mcp_best_practices.md rename to plugins/ndf-shared/skills/mcp-builder/reference/mcp_best_practices.md diff --git a/plugins/ndf/skills/mcp-builder/reference/node_mcp_server.md b/plugins/ndf-shared/skills/mcp-builder/reference/node_mcp_server.md similarity index 100% rename from plugins/ndf/skills/mcp-builder/reference/node_mcp_server.md rename to plugins/ndf-shared/skills/mcp-builder/reference/node_mcp_server.md diff --git a/plugins/ndf/skills/mcp-builder/reference/python_mcp_server.md b/plugins/ndf-shared/skills/mcp-builder/reference/python_mcp_server.md similarity index 100% rename from plugins/ndf/skills/mcp-builder/reference/python_mcp_server.md rename to plugins/ndf-shared/skills/mcp-builder/reference/python_mcp_server.md diff --git a/plugins/ndf/skills/mcp-builder/scripts/connections.py b/plugins/ndf-shared/skills/mcp-builder/scripts/connections.py similarity index 100% rename from plugins/ndf/skills/mcp-builder/scripts/connections.py rename to plugins/ndf-shared/skills/mcp-builder/scripts/connections.py diff --git a/plugins/ndf/skills/mcp-builder/scripts/evaluation.py b/plugins/ndf-shared/skills/mcp-builder/scripts/evaluation.py similarity index 100% rename from plugins/ndf/skills/mcp-builder/scripts/evaluation.py rename to plugins/ndf-shared/skills/mcp-builder/scripts/evaluation.py diff --git a/plugins/ndf/skills/mcp-builder/scripts/example_evaluation.xml b/plugins/ndf-shared/skills/mcp-builder/scripts/example_evaluation.xml similarity index 100% rename from plugins/ndf/skills/mcp-builder/scripts/example_evaluation.xml rename to plugins/ndf-shared/skills/mcp-builder/scripts/example_evaluation.xml diff --git a/plugins/ndf/skills/mcp-builder/scripts/requirements.txt b/plugins/ndf-shared/skills/mcp-builder/scripts/requirements.txt similarity index 100% rename from plugins/ndf/skills/mcp-builder/scripts/requirements.txt rename to plugins/ndf-shared/skills/mcp-builder/scripts/requirements.txt diff --git a/plugins/ndf-shared/skills/merged/SKILL.md b/plugins/ndf-shared/skills/merged/SKILL.md new file mode 100644 index 0000000..06af6f2 --- /dev/null +++ b/plugins/ndf-shared/skills/merged/SKILL.md @@ -0,0 +1,29 @@ +--- +name: merged +description: "Clean up after a PR is merged." +argument-hint: "[PR番号]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read +--- + +# マージ後クリーンアップコマンド + +PRマージ後のクリーンアップを実行。 + +## 手順 + +0. **事前確認**: github mcpで引数の(引数が無ければ自身が作成した最新の)PRがmainにmergeされていることを確認。mergeされていなければ終了 +1. **事前確認**: `git status`→変更あればstash +2. **main更新**: `git checkout main`→`git pull` +3. **worktreeクリーンアップ**: `git worktree list` で当該PR番号に対応する worktree (`pr`) を探し、あれば `git worktree remove ` で削除(worktree 内の `.cross_review/` も一緒に消える) +4. **ブランチ削除**: `git branch -d ` → stash復元 + +**注意**: 冪等性保証・エラー時中断・削除済み無視 + +## 作業完了報告(必須) + +- 実行サマリー(PRタイトル、マージコミット、削除したブランチ、現在のブランチ) +- mainブランチの状態 +- PR URL diff --git a/plugins/ndf/skills/ml-model-structure/SKILL.md b/plugins/ndf-shared/skills/ml-model-structure/SKILL.md similarity index 100% rename from plugins/ndf/skills/ml-model-structure/SKILL.md rename to plugins/ndf-shared/skills/ml-model-structure/SKILL.md diff --git a/plugins/ndf/skills/ml-model-structure/references/inference-and-contract.md b/plugins/ndf-shared/skills/ml-model-structure/references/inference-and-contract.md similarity index 100% rename from plugins/ndf/skills/ml-model-structure/references/inference-and-contract.md rename to plugins/ndf-shared/skills/ml-model-structure/references/inference-and-contract.md diff --git a/plugins/ndf-shared/skills/ndf-policies/SKILL.md b/plugins/ndf-shared/skills/ndf-policies/SKILL.md new file mode 100644 index 0000000..eb25c33 --- /dev/null +++ b/plugins/ndf-shared/skills/ndf-policies/SKILL.md @@ -0,0 +1,10 @@ +--- +name: ndf-policies +description: "Apply core NDF project policies." +user-invocable: false +--- + +# NDFポリシー + +このスキルはNDFプラグインの基本ポリシーを定義します。 +descriptionフィールドが常時コンテキストに注入されるため、本文の参照は不要です。 diff --git a/plugins/ndf/skills/official-skills-autoloader/SKILL.md b/plugins/ndf-shared/skills/official-skills-autoloader/SKILL.md similarity index 100% rename from plugins/ndf/skills/official-skills-autoloader/SKILL.md rename to plugins/ndf-shared/skills/official-skills-autoloader/SKILL.md diff --git a/plugins/ndf-shared/skills/plan-to-spec/SKILL.md b/plugins/ndf-shared/skills/plan-to-spec/SKILL.md new file mode 100644 index 0000000..eaea6f9 --- /dev/null +++ b/plugins/ndf-shared/skills/plan-to-spec/SKILL.md @@ -0,0 +1,182 @@ +--- +name: plan-to-spec +description: "Finalize an implemented plan into a permanent specification document. Use after implementation is complete and an issues/ plan, PLAN file, design note, or implementation plan should become the final as-is specification under docs/ or another authoritative specification location. Triggers: 'planを仕様書にして', '確定仕様書に移動', '実装完了後にplanを整理', 'planをdocsへ移動', '仕様書としてリライト', 'plan-to-spec', 'finalize plan spec'." +allowed-tools: + - Bash + - Read + - Write + - Edit + - Glob + - Grep +--- + +# Plan to Spec + +実装完了後の plan を、開発履歴ではなく **現在のコードと一致する確定仕様書**として保存する。plan は作業中の意思決定記録であり、完了後は読者が実装経緯を追わなくても仕様を理解できる形に変換する。 + +## 基本方針 + +- plan の内容をそのまま移動せず、最終実装の as-is 仕様として書き直す +- 開発中の履歴、TODO、PR 分割、作業チェックリスト、途中変更、未採用案は削除する +- 仕様書の置き場は既存 docs 構造に合わせ、なければ `docs/specifications/` を作成する +- 仕様書はコードと照合し、実装と矛盾する記述を残さない +- 完了報告は本 skill のテンプレートに従う + +## 入力 + +`$ARGUMENTS` + +引数は plan ファイルパス、issue 番号、PR 番号、または関連キーワードを受け付ける。引数がない場合は、現在ブランチの差分、`issues/`、`docs/`、`git log` から直近の plan を特定する。 + +## 手順 + +### 1. 対象 plan と実装範囲を特定する + +1. 引数がファイルパスならその plan を読む +2. 引数が PR / issue 番号なら `gh pr view` / `gh issue view` とローカルファイル検索で関連 plan を探す +3. 引数がない場合は以下を確認する: + - `git status --short` + - `git branch --show-current` + - `git log --oneline --decorate -20` + - `find issues docs -maxdepth 3 -type f \( -iname '*plan*' -o -iname '*PLAN*' \)` +4. plan が複数候補ある場合は、現在ブランチ・PR・変更ファイルと最も関連が強いものを選ぶ。不明ならユーザーに確認する +5. 実装範囲を特定する: + - PR がある場合: `gh pr diff` / `gh pr view --json files,title,body` + - ローカル変更の場合: `git diff --stat` / `git diff` + - merge 済みの場合: 関連コミット範囲の `git show` / `git diff` + +### 2. 仕様書の配置先を決める + +既存 docs の分類に合わせて配置する。優先順位: + +1. 同種の仕様書がある既存ディレクトリ (`docs/specifications/`, `docs/specs/`, `docs/features/`, `docs/architecture/`, `docs/modules/` など) +2. 対象機能に対応する既存 docs 配下 +3. 適切な場所がなければ `docs/specifications/` を作成 + +ファイル名は英数字・ハイフン中心にし、内容が分かる名前にする。例: + +```text +docs/specifications/auth-session-management.md +docs/features/review-workflow.md +docs/architecture/plugin-skill-loading.md +``` + +移動は履歴が追えるように、可能なら `git mv ` を使う。plan を残す必要がある運用の場合は、ユーザーに確認してからコピーに切り替える。 + +### 3. 仕様書としてリライトする + +他の仕様書の章立て・表記・粒度を先に確認し、同じ体裁へ合わせる。標準章立ては以下を使う。既存 docs に明確な型がある場合はそちらを優先する。 + +```markdown +# {仕様名} + +## 概要 + +## 背景 + +## 対象範囲 + +## 仕様 + +## データ・設定 + +## 外部連携 + +## エラー処理 + +## セキュリティ + +## 運用 + +## テスト観点 + +## 関連リンク +``` + +該当しない章は削除してよい。小さな仕様では `概要`、`仕様`、`運用`、`テスト観点`、`関連リンク` 程度に圧縮する。 + +### 4. 削除・変換ルール + +削除するもの: + +- 実装タスクのチェックリスト +- PR 分割計画、worktree 運用、作業担当、レビュー進捗 +- 「これから実装する」「予定」「案」「未定」など完了前提の表現 +- 開発中に破棄された方針、調査メモ、試行錯誤 +- AI エージェント向けの作業指示 + +変換するもの: + +- 「実装する」→「提供する」「使用する」「保持する」 +- 「修正対象」→「構成」「関連ファイル」 +- 「テスト計画」→「テスト観点」または「検証方法」 +- 「背景・問題」→ 現在の仕様を理解するために必要な背景だけ残す + +### 5. リンクを再調査して修正する + +仕様書内のリンクは移動後の位置から有効になるように直す。 + +- 相対リンクは新しい配置先基準で更新する +- 存在しないファイルリンクは `find` / `rg --files` で移動先を探す +- GitHub issue / PR / 外部ドキュメントのリンクは必要最小限にする +- 開発中の一時リンク、ローカル絶対パス、エージェント固有の transcript リンクは削除する + +リンク先が確認できない場合は、推測で残さず削除するか、確実な上位ドキュメントへ差し替える。 + +### 6. コードと仕様を照合する + +仕様書を書いた後、実装と一致しているかレビューする。 + +確認観点: + +- 仕様書に書いた機能・制約・設定名・ファイルパスが実コードに存在する +- 実コードに存在する重要な挙動が仕様書から抜けていない +- エラー処理、権限、環境変数、永続化、外部連携の記述が実装と一致する +- テスト観点が実装されたテストや手動確認に対応している +- 廃止された名前、過剰な略語、未承認の用語を使っていない + +用語確認では、`AGENTS.md`、`CLAUDE.md`、`docs/`、README、既存仕様書の表記を優先し、プロジェクトで authorize された名称に合わせる。略語は初出で正式名称を併記し、ローカルな作業略称は使わない。 + +### 7. 仕様書レビューを行い修正する + +セルフレビューを 1 回行い、必要に応じて修正する。レビュー観点: + +- 他の仕様書と章立て・見出し粒度・表記が揃っているか +- plan 由来の作業履歴が残っていないか +- as-is 仕様として読めるか +- コードと矛盾していないか +- リンクが移動後のパスで正しいか +- 用語がプロジェクト標準に合っているか +- 仕様書として過不足がないか + +レビュー結果で修正した内容は、完了報告に要約する。 + +### 8. 完了報告 + +完了時は以下のテンプレートで報告する。 + +```markdown +## Plan to Spec 完了報告 + +### 対象 +- 元 plan: `{元planパス}` +- 確定仕様書: `{仕様書パス}` + +### 実施内容 +- plan を `{配置先ディレクトリ}` に移動し、確定仕様書としてリライト +- 開発履歴・TODO・PR分割などの作業情報を削除 +- リンクを移動後のパス基準で修正 + +### レビュー結果 +- 体裁: `{他仕様書との整合結果}` +- コード一致: `{照合した主な実装ファイルと結果}` +- 用語: `{標準用語への修正有無}` + +### 検証 +- `{実行したコマンドや確認内容}` + +### 補足 +- `{残課題がなければ「なし」}` +``` + +残課題がある場合は、仕様書に曖昧な記述を残さず、完了報告の補足に明確に分離する。 diff --git a/plugins/ndf/skills/playwright-browser-connect/SKILL.md b/plugins/ndf-shared/skills/playwright-browser-connect/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-browser-connect/SKILL.md rename to plugins/ndf-shared/skills/playwright-browser-connect/SKILL.md diff --git a/plugins/ndf/skills/playwright-browser-connect/scripts/start-host-chrome.sh b/plugins/ndf-shared/skills/playwright-browser-connect/scripts/start-host-chrome.sh similarity index 100% rename from plugins/ndf/skills/playwright-browser-connect/scripts/start-host-chrome.sh rename to plugins/ndf-shared/skills/playwright-browser-connect/scripts/start-host-chrome.sh diff --git a/plugins/ndf/skills/playwright-evidence-drive/SKILL.md b/plugins/ndf-shared/skills/playwright-evidence-drive/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-evidence-drive/SKILL.md rename to plugins/ndf-shared/skills/playwright-evidence-drive/SKILL.md diff --git a/plugins/ndf/skills/playwright-execution/SKILL.md b/plugins/ndf-shared/skills/playwright-execution/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-execution/SKILL.md rename to plugins/ndf-shared/skills/playwright-execution/SKILL.md diff --git a/plugins/ndf/skills/playwright-kit-ops/.gitignore b/plugins/ndf-shared/skills/playwright-kit-ops/.gitignore similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/.gitignore rename to plugins/ndf-shared/skills/playwright-kit-ops/.gitignore diff --git a/plugins/ndf/skills/playwright-kit-ops/SKILL.md b/plugins/ndf-shared/skills/playwright-kit-ops/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/SKILL.md rename to plugins/ndf-shared/skills/playwright-kit-ops/SKILL.md diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/__init__.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/__init__.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/__init__.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/__init__.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/accessibility.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/accessibility.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/accessibility.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/accessibility.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/body_check.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/body_check.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/body_check.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/body_check.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/config.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/config.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/config.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/config.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/overlay.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/overlay.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/overlay.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/overlay.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_report.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/pytest_report.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_report.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/pytest_report.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/video.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/video.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/video.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/video.py diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/web_vitals.py b/plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/web_vitals.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/playwright_kit/web_vitals.py rename to plugins/ndf-shared/skills/playwright-kit-ops/playwright_kit/web_vitals.py diff --git a/plugins/ndf/skills/playwright-kit-ops/pyproject.toml b/plugins/ndf-shared/skills/playwright-kit-ops/pyproject.toml similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/pyproject.toml rename to plugins/ndf-shared/skills/playwright-kit-ops/pyproject.toml diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/_drive_auth.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/_drive_auth.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/_drive_auth.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/_drive_auth.py diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/check_cwv.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/check_cwv.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/check_cwv.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/check_cwv.py diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/classify_page_role.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/classify_page_role.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/classify_page_role.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/classify_page_role.py diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/init_project.bat b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/init_project.bat similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/init_project.bat rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/init_project.bat diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/init_project.sh b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/init_project.sh similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/init_project.sh rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/init_project.sh diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/record_scenario.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/record_scenario.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/record_scenario.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/record_scenario.py diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/run_a11y_scan.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/run_a11y_scan.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/run_a11y_scan.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/run_a11y_scan.py diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/upload_evidence.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/upload_evidence.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/upload_evidence.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/upload_evidence.py diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py b/plugins/ndf-shared/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py rename to plugins/ndf-shared/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/conftest.py.template b/plugins/ndf-shared/skills/playwright-kit-ops/templates/conftest.py.template similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/conftest.py.template rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/conftest.py.template diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/pyproject.toml.runtime b/plugins/ndf-shared/skills/playwright-kit-ops/templates/pyproject.toml.runtime similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/pyproject.toml.runtime rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/pyproject.toml.runtime diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/run.bat b/plugins/ndf-shared/skills/playwright-kit-ops/templates/run.bat similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/run.bat rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/run.bat diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/run.sh b/plugins/ndf-shared/skills/playwright-kit-ops/templates/run.sh similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/run.sh rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/run.sh diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/runtime-README.md b/plugins/ndf-shared/skills/playwright-kit-ops/templates/runtime-README.md similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/runtime-README.md rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/runtime-README.md diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/runtime-gitignore b/plugins/ndf-shared/skills/playwright-kit-ops/templates/runtime-gitignore similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/runtime-gitignore rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/runtime-gitignore diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/scenario.config.yaml b/plugins/ndf-shared/skills/playwright-kit-ops/templates/scenario.config.yaml similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/scenario.config.yaml rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/scenario.config.yaml diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/test_auth.py.template b/plugins/ndf-shared/skills/playwright-kit-ops/templates/test_auth.py.template similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/test_auth.py.template rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/test_auth.py.template diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/test_dashboard.py.template b/plugins/ndf-shared/skills/playwright-kit-ops/templates/test_dashboard.py.template similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/test_dashboard.py.template rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/test_dashboard.py.template diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/test_form.py.template b/plugins/ndf-shared/skills/playwright-kit-ops/templates/test_form.py.template similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/test_form.py.template rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/test_form.py.template diff --git a/plugins/ndf/skills/playwright-kit-ops/templates/test_list.py.template b/plugins/ndf-shared/skills/playwright-kit-ops/templates/test_list.py.template similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/templates/test_list.py.template rename to plugins/ndf-shared/skills/playwright-kit-ops/templates/test_list.py.template diff --git a/plugins/ndf-shared/skills/playwright-kit-ops/tests/__init__.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/conftest.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/conftest.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/conftest.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/conftest.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_auth_cache.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_auth_cache.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_auth_cache.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_auth_cache.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_body_check.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_body_check.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_body_check.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_body_check.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_config_basic_auth.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_config_basic_auth.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_config_basic_auth.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_config_basic_auth.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_evidence_fixture.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_evidence_fixture.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_evidence_fixture.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_evidence_fixture.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_makereport_user_properties.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_makereport_user_properties.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_makereport_user_properties.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_makereport_user_properties.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_report.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytest_report.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_report.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytest_report.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_pytester_integration.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytester_integration.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_pytester_integration.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_pytester_integration.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_same_origin.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_same_origin.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_same_origin.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_same_origin.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_upload_evidence.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_upload_evidence.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_upload_evidence.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_upload_evidence.py diff --git a/plugins/ndf/skills/playwright-kit-ops/tests/test_video_default.py b/plugins/ndf-shared/skills/playwright-kit-ops/tests/test_video_default.py similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/tests/test_video_default.py rename to plugins/ndf-shared/skills/playwright-kit-ops/tests/test_video_default.py diff --git a/plugins/ndf/skills/playwright-kit-ops/uv.lock b/plugins/ndf-shared/skills/playwright-kit-ops/uv.lock similarity index 100% rename from plugins/ndf/skills/playwright-kit-ops/uv.lock rename to plugins/ndf-shared/skills/playwright-kit-ops/uv.lock diff --git a/plugins/ndf/skills/playwright-report/SKILL.md b/plugins/ndf-shared/skills/playwright-report/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-report/SKILL.md rename to plugins/ndf-shared/skills/playwright-report/SKILL.md diff --git a/plugins/ndf/skills/playwright-scenario-test/SKILL.md b/plugins/ndf-shared/skills/playwright-scenario-test/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-scenario-test/SKILL.md rename to plugins/ndf-shared/skills/playwright-scenario-test/SKILL.md diff --git a/plugins/ndf/skills/playwright-script-creation/SKILL.md b/plugins/ndf-shared/skills/playwright-script-creation/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-script-creation/SKILL.md rename to plugins/ndf-shared/skills/playwright-script-creation/SKILL.md diff --git a/plugins/ndf/skills/playwright-test-planning/SKILL.md b/plugins/ndf-shared/skills/playwright-test-planning/SKILL.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/SKILL.md rename to plugins/ndf-shared/skills/playwright-test-planning/SKILL.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/01-methodology.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/01-methodology.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/01-methodology.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/01-methodology.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/02-page-roles.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/02-page-roles.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/02-page-roles.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/02-page-roles.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/03-test-techniques.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/03-test-techniques.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/03-test-techniques.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/03-test-techniques.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/04-playwright-mapping.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/04-playwright-mapping.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/04-playwright-mapping.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/04-playwright-mapping.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/05-bug-report.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/05-bug-report.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/05-bug-report.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/05-bug-report.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/06-pytest-playwright.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/06-pytest-playwright.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/06-pytest-playwright.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/06-pytest-playwright.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/README.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/README.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/README.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/README.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-auth.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-auth.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-auth.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-auth.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-cart-checkout.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-cart-checkout.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-cart-checkout.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-cart-checkout.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-common.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-common.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-common.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-common.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-dashboard.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-dashboard.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-dashboard.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-dashboard.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-edit.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-edit.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-edit.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-edit.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-form.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-form.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-form.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-form.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-item.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-item.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-item.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-item.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-list.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-list.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-list.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-list.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-lp.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-lp.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-lp.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-lp.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-modal-wizard.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-modal-wizard.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-modal-wizard.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-modal-wizard.md diff --git a/plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-search.md b/plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-search.md similarity index 100% rename from plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-search.md rename to plugins/ndf-shared/skills/playwright-test-planning/docs/checklists/checklist-search.md diff --git a/plugins/ndf-shared/skills/pr-tests/SKILL.md b/plugins/ndf-shared/skills/pr-tests/SKILL.md new file mode 100644 index 0000000..39f13de --- /dev/null +++ b/plugins/ndf-shared/skills/pr-tests/SKILL.md @@ -0,0 +1,31 @@ +--- +name: pr-tests +description: "Run PR test plans and comment results." +argument-hint: "[PR番号]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Glob + - Grep +--- + +# PR Test Plan実行 + +`/ndf:pr`で作成されたPRのTest Plan(チェックリスト形式)を読み取り、各テスト項目を自動実行する。 + +**制約**: テスト実行と結果報告のみ。コード修正・git操作・ファイル編集は行わない。 + +## 引数 + +- PR番号(オプション): 省略時は現在ブランチから自動検出 + +## 手順 + +1. **PR情報取得**: PR本文からTest Planセクションの`- [ ]`項目をパース +2. **テスト実行計画**: テスト種類の判定、実行順序の決定、自動/手動の分類 +3. **テスト実行**: 各項目を順次実行し成功/失敗を記録 +4. **結果反映**: + - 成功: PR本文の`- [ ]`を`- [x]`に更新 + - 失敗: PRにコメント追加(問題点・対策案・エラー詳細) +5. **最終報告**: テスト結果サマリーをPRにコメント(総テスト数、成功/失敗/スキップ数、成功率) diff --git a/plugins/ndf-shared/skills/pr/SKILL.md b/plugins/ndf-shared/skills/pr/SKILL.md new file mode 100644 index 0000000..22000e4 --- /dev/null +++ b/plugins/ndf-shared/skills/pr/SKILL.md @@ -0,0 +1,161 @@ +--- +name: pr +description: "Commit, push, and create or update PRs." +argument-hint: "[--draft] [base-branch] or [commit-message]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Glob + - Grep +--- + +# PR作成 + +このプロジェクトのコードをcommit, pushし、GitHubでPull Requestを作成する。既にPRがあればPR説明を最新の変更内容に更新する。 + +**制約**: デフォルトブランチ(main, masterなど)で直接コミット禁止 + +## 使用方法 + +``` +/ndf:pr # main へ通常PR作成 +/ndf:pr --draft # main へドラフトPR作成 +/ndf:pr "新機能の追加" # コミットメッセージ指定 +/ndf:pr --draft "wip: 作業中" # ドラフトPR + メッセージ指定 +/ndf:pr qa/staging # base非main → cherry-pick-prへ誘導 +``` + +## 引数の解釈 + +- `--draft` が含まれていればドラフトPR +- 既知のベースブランチ名(`main`, `master`, `qa/*`, `release/*`, `staging/*` 等)が末尾にあればベース指定 +- それ以外の文字列はコミットメッセージとして扱う +- デフォルトは `main` ベース、非ドラフト + +## 手順 + +### 0. PR確認 + +- `git branch --show-current` で現在ブランチを確認 +- `gh pr list --head ` で既存PR確認 +- 既にPRが存在しOPEN状態なら: + - `git add` → `git commit`(日本語メッセージ)→ `git push` + - **既存PR説明を更新** する(「PR説明の更新」節を参照) + - 終了報告 +- PRがない、またはmerge/close済みなら次へ + +### 1. ブランチ確認・切り替え + +- デフォルトブランチの場合: 新featureブランチを作成して切り替え +- デフォルトブランチ以外: `git stash` → `git pull origin `(コンフリクト時は停止しユーザに報告)→ `git stash pop` + +### 2. ベースブランチ判定 + +- 引数の末尾が `main`/`master` 以外のベースブランチ名(`qa/staging`, `release/v2` 等)の場合: + - **警告を出して `/ndf:cherry-pick-pr ` に誘導する** + - 理由: base非mainのPRに直接pushすると `feature → main` のPRに環境固有コードが混入する(詳細は `/ndf:branch-fix-strategy`) + - ユーザーが明示的に継続を指示した場合のみ進める + +### 3. 変更コミット + +- `git status` → `git add` → `git commit`(日本語メッセージ) +- 引数で指定されたコミットメッセージがあればそれを使用、なければ差分から生成 +- 上位階層を含むすべての変更をcommit + +### 4. プッシュ + +```bash +git push -u origin +``` + +### 5. PR作成 + +- `.github/pull_request_template.md` が存在すれば適用 +- `--draft` 指定ならドラフトPR作成 +- タイトル・説明は日本語、body は `## Summary` + `## Test plan` +- 機密情報(トークン、パスワード、APIキー等)を含めない +- body 末尾に `` を入れる +- **body は必ずHEREDOC形式で渡す**(`\n` リテラル混入防止): + +```bash +gh pr create --title "タイトル" $DRAFT_FLAG --body "$(cat <<'EOF' +## Summary +- 変更内容 + +## Test plan +- [ ] テスト項目 + + +EOF +)" +``` + +`DRAFT_FLAG` は `--draft` 指定時のみ `--draft`、それ以外は空。 + +## PR説明の更新(既存PRがある場合) + +既存PRがある場合、以下の手順でPR説明を更新する: + +1. **変更内容の分析**: + - `git log origin/..HEAD` でブランチ全体のコミット履歴 + - `git diff origin/..HEAD --stat` で変更ファイル一覧 + - 必要に応じて変更ファイルの詳細を取得 +2. **既存PR説明の確認**: + - `gh pr view --json body` で現在のPR説明を取得 + - 既存の関連リンク(Issue参照、設計ドキュメント等)は保持する +3. **PR説明の生成**: + - `.github/pull_request_template.md` のテンプレート構造に従う + - ブランチの**全コミット**の変更内容を反映する(最新コミットだけでなく全体) + - 「Summary」「Test plan」「やらないこと」等を適切に記述 +4. **更新の実行**: + ```bash + gh pr edit --body "" + ``` + +## 命名規則 + +- ブランチ: 英語(github flow) +- コミット・PR: 日本語 +- コミットメッセージ prefix 例: + - `feat:` 新機能 + - `fix:` バグ修正 + - `refactor:` リファクタリング + - `docs:` ドキュメント + - `test:` テスト + - `chore:` その他 + +## 検証ブランチ(qa/*等)へのPR作成 + +**重要: featureブランチから直接検証ブランチへPRを作成してはいけません。** + +### アンチパターン(禁止) + +``` +feature/xxx ──PR──→ qa/staging ← ❌ qa/staging をmergeするとmainが汚染される +``` + +### 正しい手順 + +`/ndf:cherry-pick-pr ` を使う(自動化済み)。詳細な理由と手順は: +- `/ndf:cherry-pick-pr` — 自動化コマンド +- `/ndf:branch-fix-strategy` — 原則と手順 + +## 作業完了報告(必須) + +PR作成/更新完了後、以下を報告: + +- 基本情報(PRタイトル、ベース/ソースブランチ、PR番号、ドラフト有無) +- 変更サマリー(コミット数、変更ファイル数、変更行数、主な変更内容) +- コミット履歴 +- PR本文の概要(Summary、Test plan) +- PR URL + +## 関連 + +- `/ndf:cherry-pick-pr` — 環境ブランチへのcherry-pick PR +- `/ndf:deploy` — 環境ブランチへのデプロイPR(ブランチ全体) +- `/ndf:pr-tests` — Test Plan 自動実行 +- `/ndf:review` — PR単位レビュー +- `/ndf:sync-main` — 現ブランチに main を取り込み +- `/ndf:branch-fix-strategy` — ブランチ戦略の原則 diff --git a/plugins/ndf-shared/skills/problem-solving/SKILL.md b/plugins/ndf-shared/skills/problem-solving/SKILL.md new file mode 100644 index 0000000..1b3c110 --- /dev/null +++ b/plugins/ndf-shared/skills/problem-solving/SKILL.md @@ -0,0 +1,162 @@ +--- +name: problem-solving +description: "Solve bugs, incidents, and data inconsistencies at root cause." +when_to_use: "データ不整合 / バグ / 障害対応時に自動参照。「つじつま合わせ」を避けて上流で直す判断が必要なとき。Triggers: 'バグ修正', 'データ不整合', '障害対応', '根本原因', 'root cause analysis', 'data inconsistency', 'incident', '上流で直す', 'patch vs fix'" +--- + +# 問題解決ガイドライン + +データ不整合、バグ、障害対応における問題解決の原則と手順。 + +## 1. 根本原因を探る(つじつま合わせをしない) + +### 原則: 上流で直す + +問題はデータフローの**最も上流**で修正する。下流でのパッチ(migrationによるデータ修正、SQL直接更新、出力時の辻褄合わせ)は最終手段。 + +``` +❌ 悪いパターン + DBに異常データがある → migrationで論理削除 → バッチ再実行 + +✅ 良いパターン + DBに異常データがある → なぜ入ったか調査 → 取り込みロジックにバリデーション追加 + → 異常データを論理削除 → バッチ再実行 +``` + +### 判断フロー + +``` +1. 症状を確認(どのデータ・どの機能が、どう間違っているか) +2. データフロー/呼び出しチェーンを遡る(結果 → 計算 → 素材 → 取り込み → 外部ソース) +3. 最初に異常が発生した地点を特定 +4. その地点のコードを修正 +5. 下流にも防御的チェックを追加(多層防御) +6. 修正後にデータ修復(パイプライン再実行) +``` + +### 典型的な見逃しパターン + +| 症状 | 表層の「原因」 | 真の根本原因 | +|------|-------------|-------------| +| 料金が異常値 | 計算ロジックのバグ | 上流の取り込み時に異常値が混入、バリデーション欠如 | +| レコードの2WD/4WD逆転 | 割当ロジックの不具合 | ORM(Eloquent等)のリレーション型不一致(VARCHAR↔INT)でEager Loadマッチングずれ | +| 一部ユーザーで通知が届かない | 通知送信ロジックの問題 | 論理削除フラグの扱いが `delete()` と `forceDelete()` で異なる | + +## 2. ハルシネーション防止チェック + +### 原則: 自分の推論を疑い、必ず裏取りする + +コードリーディングだけで判断せず、以下を必ず実行する。 + +### チェックリスト + +| チェック項目 | 方法 | +|------------|------| +| カラム/フィールドが存在するか | `SHOW COLUMNS` / スキーマ定義ファイル確認(コード読みだけで判断しない) | +| データが存在するか | `SELECT COUNT(*) FROM table WHERE ...` / サンプル取得 | +| 型が一致するか | DB定義(INT/VARCHAR等)とコード側(`$casts`, dataclass 等)の両方を確認 | +| 外部キー/制約が存在するか | マイグレーション履歴を追跡(追加→削除→再追加の変遷を確認) | +| 論理削除ポリシーは何か | `SoftDeletes` / `deleted_at` の有無を確認(`delete()` と `forceDelete()` の挙動が異なる) | +| 環境差異がないか | dev/staging/prod で同じクエリを実行して比較 | + +### やってはいけないこと + +- コードを読んだだけで「このカラムは存在しない」と断定する +- 1つのテーブル/ファイルだけ見て「データに問題はない」と結論づける +- マイグレーションの最終状態だけ見てFK制約の有無を判断する +- 論理削除の有無を確認せず `delete()` を使う + +### 型不一致の検出パターン + +ORMリレーションで以下の組み合わせは危険: + +| ローカルキー型 | 外部キー型 | リスク | +|--------------|----------|-------| +| VARCHAR | INT | Eager Load マッチングでずれる可能性 | +| INT | VARCHAR | 同上 | +| string | integer | strict comparison で不一致 | + +**対策**: 型キャストで揃えるか、リレーション定義時に明示的に型変換する。 + +## 3. データとコードの整合性検証 + +### 原則: 仮説を立てたら、データとコードの両面から検証する + +``` +仮説: 「処理Aが項目Xを逆に割り当てている」 + → コード確認: 処理AのJOIN/代入ロジックを読む + → データ確認: 上流テーブルの値と処理A結果の値を並べて比較 + → 結論: 上流データは正常、処理A結果が逆転 → 処理Aの問題 +``` + +### 検証手順テンプレート + +```sql +-- Step 1: 元データ(上流)を確認 +SELECT * FROM source_table WHERE conditions ORDER BY updated_at DESC; + +-- Step 2: 中間データを確認 +SELECT * FROM intermediate_table WHERE conditions; + +-- Step 3: 最終データ(下流)を確認 +SELECT * FROM result_table WHERE conditions; + +-- Step 4: 上流と下流を突合 +-- 値が一致するか、変換ロジックが正しいか確認 +``` + +## 4. 多層防御 + +### 1つの修正だけに頼らず、複数レイヤーで防御する + +``` +Layer 1: 取り込み時バリデーション(異常値を入れない) +Layer 2: 型整合性(正しくマッチングする) +Layer 3: 処理時の防御条件(異常値があっても除外する) +Layer 4: 出力時検証(結果の妥当性チェック) +``` + +単一レイヤーだけだと、将来別の経路で同じ問題が再発する可能性が残る。 + +## 5. 修正の進め方 + +### 修正順序 + +1. **コードの修正**を先に行う(根本原因の解消) +2. **デプロイ**する +3. **データの修復**は修正済みコードで再実行する(migration や SQL 直接修正ではなく、パイプライン再実行が望ましい) +4. **検証**で修正を確認する + +### コミット・PR戦略 + +- 根本原因の修正とデータ修復は**別コミット**にする(Revertしやすい) +- 重複コードは発見次第リファクタリングする +- 検証環境向けPRは `cherry-pick-pr` 方式で作成し、mainブランチを汚染しない(詳細は `branch-fix-strategy` スキル参照) + +## 6. 調査レポートの書き方 + +### 必須項目 + +1. **症状**: 何がどう間違っているか(定量的に) +2. **根本原因**: コードレベルでどこが問題か(ファイル名:行番号) +3. **エビデンス**: DB クエリ結果 / ログ / コマンド出力で裏付ける +4. **修正方針**: どのフェーズで何を直すか +5. **検証手順**: 修正後にどう確認するか + +### エビデンスの書き方 + +```markdown +**エビデンス(staging DB)**: +| key | value | updated_at | 状態 | +|---|---|---|---| +| A100 | 103,081 | 2026-04-01 10:49 | 異常(最新) | +| A100 | 44,390 | 2026-03-11 13:42 | 正常 | +``` + +SQLクエリ結果をそのまま貼り、「コードを読んだ推測」と「DBで確認した事実」を明確に区別する。 + +## 関連スキル + +- `/ndf:investigation-rules` — 調査レポート作成時のエビデンス主義 +- `/ndf:branch-fix-strategy` — 複数ブランチへの修正適用戦略 +- `/ndf:logging-guidelines` — ログ設計(原因特定を容易にする) diff --git a/plugins/ndf-shared/skills/python-execution/01-uv-setup.md b/plugins/ndf-shared/skills/python-execution/01-uv-setup.md new file mode 100644 index 0000000..aafd4f1 --- /dev/null +++ b/plugins/ndf-shared/skills/python-execution/01-uv-setup.md @@ -0,0 +1,85 @@ +# uv詳細セットアップガイド + +> **Note**: 基本的な`uv sync`と`uv run python`はSKILL.mdを参照。このファイルは初回セットアップや詳細設定が必要な場合のみ参照。 + +## uvインストール + +```bash +# Linux/macOS +curl -LsSf https://astral.sh/uv/install.sh | sh + +# pip経由(代替) +pip install uv + +# 確認 +uv --version +``` + +## 依存関係管理 + +```bash +# uv.lockがある場合(推奨) +uv sync + +# uv.lockがない場合 +uv lock && uv sync + +# 開発用依存関係も含める +uv sync --dev + +# 特定のextraを含める +uv sync --extra test +``` + +## Pythonバージョン管理 + +```bash +# 特定バージョンをインストール +uv python install 3.12 +uv python install 3.11 + +# プロジェクトで使用するバージョンを固定 +uv python pin 3.12 + +# インストール済みバージョン一覧 +uv python list +``` + +## 実行オプション + +```bash +# スクリプト実行 +uv run python script.py + +# モジュール実行 +uv run python -m pytest +uv run python -m mypy . + +# 引数付き +uv run python script.py --arg value + +# インタラクティブシェル +uv run python +``` + +## プロジェクト初期化(新規作成時) + +```bash +# 新規プロジェクト作成 +uv init my-project +cd my-project + +# 依存関係追加 +uv add requests +uv add --dev pytest + +# ロックファイル生成 +uv lock +``` + +## uv環境の利点 + +- **高速**: Rustで実装、pip比10-100倍速 +- **再現性**: uv.lockで完全な依存関係固定 +- **Pythonバージョン管理**: pyenvなしでバージョン切り替え +- **グローバル環境を汚染しない**: プロジェクト単位で隔離 diff --git a/plugins/ndf-shared/skills/python-execution/02-troubleshooting.md b/plugins/ndf-shared/skills/python-execution/02-troubleshooting.md new file mode 100644 index 0000000..3475b7e --- /dev/null +++ b/plugins/ndf-shared/skills/python-execution/02-troubleshooting.md @@ -0,0 +1,112 @@ +# Python実行 トラブルシューティング + +## よくある問題と解決策 + +### Q: `uv: command not found` + +**原因**: uvがインストールされていない + +**解決策**: +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +# シェルを再起動するか、パスを通す +source ~/.bashrc # または ~/.zshrc +``` + +### Q: `ModuleNotFoundError` + +**原因**: 依存関係がインストールされていない + +**解決策**: +```bash +# uv環境の場合 +uv sync + +# venv環境の場合 +.venv/bin/pip install -r requirements.txt + +# pyproject.tomlがある場合 +.venv/bin/pip install -e . +``` + +### Q: `python: command not found` + +**原因**: Pythonがインストールされていない、またはパスが通っていない + +**解決策**: +```bash +# python3を試す +python3 --version + +# uvでPythonをインストール +uv python install 3.12 +``` + +### Q: 異なるPythonバージョンが必要 + +**解決策(uv環境)**: +```bash +# 特定バージョンをインストール +uv python install 3.11 + +# プロジェクトで使用するバージョンを固定 +uv python pin 3.11 + +# そのバージョンで実行 +uv run python script.py +``` + +### Q: `pyproject.toml`はあるが`uv.lock`がない + +**解決策**: +```bash +# ロックファイルを生成 +uv lock + +# 依存関係をインストール +uv sync +``` + +### Q: 仮想環境が壊れている + +**解決策**: +```bash +# 仮想環境を削除して再作成 +rm -rf .venv + +# uv環境の場合 +uv sync + +# 手動で作成する場合 +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +``` + +### Q: パーミッションエラー + +**解決策**: +```bash +# 仮想環境を使用(推奨) +uv sync +uv run python script.py + +# どうしてもグローバルにインストールする場合(非推奨) +pip install --user package_name +``` + +### Q: SSL証明書エラー + +**解決策**: +```bash +# macOSの場合 +/Applications/Python\ 3.x/Install\ Certificates.command + +# または環境変数で一時的に無効化(非推奨) +export PYTHONHTTPSVERIFY=0 +``` + +## 関連リソース + +- [uv公式ドキュメント](https://docs.astral.sh/uv/) +- [Python venv](https://docs.python.org/3/library/venv.html) +- [pyproject.toml仕様](https://packaging.python.org/en/latest/specifications/pyproject-toml/) diff --git a/plugins/ndf-shared/skills/python-execution/SKILL.md b/plugins/ndf-shared/skills/python-execution/SKILL.md new file mode 100644 index 0000000..a07705a --- /dev/null +++ b/plugins/ndf-shared/skills/python-execution/SKILL.md @@ -0,0 +1,86 @@ +--- +name: python-execution +description: "Detect and run the right Python environment." +when_to_use: "Python スクリプトを実行 / セットアップするとき。Triggers: 'python', 'uv', 'スクリプト', 'python環境'" +allowed-tools: + - Read + - Bash + - Glob +--- + +# Python Execution Skill + +## 概要 + +Pythonコードを実行する前に、プロジェクトの実行環境を調査し、適切な方法で実行するためのガイドラインです。 + +## Step 1: 環境検出 + +```bash +ls -la pyproject.toml uv.lock .venv/ venv/ requirements.txt 2>/dev/null +``` + +## Step 2: 実行コマンド選択 + +| 検出ファイル | 実行方法 | 優先度 | +|-------------|---------|-------| +| `pyproject.toml` | `uv run python` | 最高 | +| `.venv/` | `.venv/bin/python` | 中 | +| `venv/` | `venv/bin/python` | 中 | +| 何もなし | `python3` | 最低 | + +## Step 3: 実行 + +### uv環境(pyproject.tomlあり) + +```bash +# 依存関係インストール(初回のみ) +uv sync + +# 実行 +uv run python script.py +uv run python -m module_name +``` + +**uvがない場合のインストール**: +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +source ~/.bashrc # パスを反映 +``` + +### venv環境(.venv/あり) + +```bash +# 依存関係インストール(初回のみ) +.venv/bin/pip install -r requirements.txt + +# 実行 +.venv/bin/python script.py +``` + +### システムPython + +```bash +python3 script.py +``` + +## ベストプラクティス + +| DO | DON'T | +|----|-------| +| 実行前に環境を調査 | 環境を確認せずに実行 | +| README.md/CLAUDE.mdの指示を優先 | グローバル環境に依存関係をインストール | +| pyproject.tomlがあればuv使用 | source activateに依存 | +| 仮想環境のPythonをパス指定で実行 | python2を使用 | + +## 詳細ガイド(必要時のみ参照) + +| ファイル | 内容 | 参照タイミング | +|---------|------|--------------| +| `01-uv-setup.md` | uv詳細セットアップ、Pythonバージョン管理 | 初回セットアップ時 | +| `02-troubleshooting.md` | エラー解決策 | 問題発生時 | + +## 関連Skill + +- **corder-code-templates**: Pythonコードテンプレート +- **corder-test-generation**: Pythonテスト生成 diff --git a/plugins/ndf/skills/qa-security-scan/01-owasp-checklist.md b/plugins/ndf-shared/skills/qa-security-scan/01-owasp-checklist.md similarity index 100% rename from plugins/ndf/skills/qa-security-scan/01-owasp-checklist.md rename to plugins/ndf-shared/skills/qa-security-scan/01-owasp-checklist.md diff --git a/plugins/ndf/skills/qa-security-scan/02-auth-checklist.md b/plugins/ndf-shared/skills/qa-security-scan/02-auth-checklist.md similarity index 100% rename from plugins/ndf/skills/qa-security-scan/02-auth-checklist.md rename to plugins/ndf-shared/skills/qa-security-scan/02-auth-checklist.md diff --git a/plugins/ndf/skills/qa-security-scan/03-report-template.md b/plugins/ndf-shared/skills/qa-security-scan/03-report-template.md similarity index 100% rename from plugins/ndf/skills/qa-security-scan/03-report-template.md rename to plugins/ndf-shared/skills/qa-security-scan/03-report-template.md diff --git a/plugins/ndf/skills/qa-security-scan/SKILL.md b/plugins/ndf-shared/skills/qa-security-scan/SKILL.md similarity index 100% rename from plugins/ndf/skills/qa-security-scan/SKILL.md rename to plugins/ndf-shared/skills/qa-security-scan/SKILL.md diff --git a/plugins/ndf-shared/skills/resolve-pr-comments/SKILL.md b/plugins/ndf-shared/skills/resolve-pr-comments/SKILL.md new file mode 100644 index 0000000..433a72d --- /dev/null +++ b/plugins/ndf-shared/skills/resolve-pr-comments/SKILL.md @@ -0,0 +1,146 @@ +--- +name: resolve-pr-comments +description: "Reply to and resolve fixed PR comments." +argument-hint: "[PR番号]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read +--- + +# PRコメントResolveコマンド + +対応済みのPRコメント全てに返信し、スレッドを resolved にする。`/ndf:fix` で修正完了後に呼び出す**クロージング専用**コマンド。 + +## 使用方法 + +``` +/ndf:resolve-pr-comments # 現在のブランチのPRを対象 +/ndf:resolve-pr-comments 9352 # PR番号を指定 +``` + +## `/ndf:fix` との使い分け + +| 観点 | fix | resolve-pr-comments | +|---|---|---| +| 動作 | コード修正+commit+push | 返信+スレッドresolve | +| 前提 | レビュー後、修正が必要 | 修正済み、クロージングのみ | +| 推奨順序 | 先に実行 | fix後の最後に実行 | + +## 処理フロー + +### 1. PR情報の取得 + +```bash +PR_NUMBER="${ARGUMENTS:-$(gh pr view --json number --jq .number)}" +``` + +### 2. PRコメント取得 + +GitHub API でレビューコメントを取得: + +```bash +gh api "repos/:owner/:repo/pulls/$PR_NUMBER/comments" +``` + +### 3. 対応状況の確認 + +各コメントについて、対応済みかどうかを確認する: +- コードの変更履歴(`git log`, `git diff`)と照合 +- 指摘された問題が修正されているか確認 +- PR body の「やらないこと」セクションで別PR対応と明記されているか確認 + +### 4. コメントへの返信 + +対応済みのコメントに対して、内容に応じた返信を投稿する: + +#### 修正対応した場合 +``` +対応しました。 + +{修正内容の簡潔な説明} +``` + +#### 別PRで対応予定の場合 +``` +別PRで対応予定です。 + +PR説明の「やらないこと」に記載の通り、{理由}のため別PRで対応します。 +``` + +#### 対応不要と判断した場合 +``` +確認しました。 + +{対応不要と判断した理由} +``` + +### 5. gh CLI コマンド + +#### レビューコメントに返信(スレッド内) + +```bash +gh api "repos/:owner/:repo/pulls/$PR_NUMBER/comments" \ + -f body="返信メッセージ" \ + -f in_reply_to= +``` + +#### スレッドをResolve(GraphQL) + +まず Thread Node ID を取得: + +```bash +gh api "repos/:owner/:repo/pulls/comments/" --jq '.node_id' +``` + +その上でResolve: + +```bash +gh api graphql -f query=' + mutation { + resolveReviewThread(input: {threadId: ""}) { + thread { isResolved } + } + } +' +``` + +### 6. 実行フロー + +各コメントに対して以下を順次実行: + +1. コメントの内容と対応状況を確認 +2. 適切な返信メッセージを生成 +3. 返信を投稿 +4. スレッドをresolve +5. 結果を報告 + +### 7. 出力フォーマット + +```markdown +## PR #XXXX コメント対応結果 + +### 処理結果 +| # | コメント | 返信内容 | Resolve | +|---|---------|---------|---------| +| 1 | {指摘要約} | 対応しました | ✅ | +| 2 | {指摘要約} | 別PRで対応予定 | ✅ | + +### サマリー +- 処理済み: X件 +- Resolved: X件 +- エラー: X件 +``` + +## 重要ルール + +- **確認してから実行**: 各コメントの対応状況を必ず確認してから返信 +- **コード修正はしない**: 修正は `/ndf:fix` の責務。このコマンドはクロージングのみ +- **適切な返信**: 対応内容に応じた適切な返信メッセージを使用 +- **エラーハンドリング**: API エラー発生時は報告して継続 +- **ユーザー確認**: 判断に迷う場合はユーザーに確認を求める + +## 関連 + +- `/ndf:review-pr-comments` — コメント分類・優先度判定 (READ-ONLY) +- `/ndf:fix` — コメント対応の修正を実施 diff --git a/plugins/ndf-shared/skills/review-branch/SKILL.md b/plugins/ndf-shared/skills/review-branch/SKILL.md new file mode 100644 index 0000000..951e5ea --- /dev/null +++ b/plugins/ndf-shared/skills/review-branch/SKILL.md @@ -0,0 +1,129 @@ +--- +name: review-branch +description: "Review the current branch before opening a PR." +when_to_use: "PR作成前にローカルブランチの実装をセルフレビューしたいとき。Triggers: 'ブランチをレビュー', 'PR前にレビュー', 'セルフレビュー', 'review my branch', 'review before PR', 'self review', 'pre-PR review'" +argument-hint: "[focus-area] (例: security, performance, tests)" +allowed-tools: + - Bash + - Read + - Glob + - Grep +--- + +# ブランチ実装レビューコマンド + +現在のブランチで実装された変更を**PR作成前に**コードレビューする。mainブランチとの差分を分析し、コード品質・セキュリティ・パフォーマンスの観点でフィードバックを返す。 + +## `/ndf:review` との使い分け + +| 観点 | review-branch | review | +|---|---|---| +| 対象 | ローカルブランチの差分(PR前) | GitHub上の既存PR | +| 判定 | フィードバックを返す | Approve / Request Changes を判定 | +| 用途 | PR作成前のセルフレビュー | PR作成後のレビュー | + +## 使用方法 + +``` +/ndf:review-branch # 全般レビュー +/ndf:review-branch security # セキュリティに焦点 +/ndf:review-branch performance # パフォーマンスに焦点 +/ndf:review-branch tests # テスト網羅性に焦点 +/ndf:review-branch "ビジネスロジック" # 任意のフォーカス +``` + +## レビュー手順 + +### 1. 変更の把握 + +```bash +git diff main --name-only # 変更ファイル一覧 +git diff main --stat # 差分の統計 +git log main..HEAD --oneline # コミット履歴 +``` + +### 2. 変更内容の分析 + +各変更ファイルに対して以下を確認: + +- **追加・変更されたロジック**: 意図が明確か、正しく実装されているか +- **テストカバレッジ**: 適切なテストが追加されているか +- **コーディング規約**: プロジェクトの規約に準拠しているか + +### 3. 品質チェック観点 + +#### コード品質 +- 命名規則の一貫性 +- 関数/メソッドの責務(単一責任原則) +- DRY原則(重複コードの排除) +- 可読性・保守性 +- 過剰な抽象化がないか(YAGNI) + +#### セキュリティ +- SQLインジェクション対策 +- XSS対策 +- CSRF対策 +- 入力値バリデーション +- 認証・認可の適切性 +- 機密情報(トークン、キー、PII)の取り扱い + +#### パフォーマンス +- N+1 クエリの有無 +- 不要なデータベースアクセス +- メモリ使用量 +- インデックスの活用 + +#### エラーハンドリング +- 例外が適切に捕捉されているか +- ログ出力の妥当性(詳細は `/ndf:logging-guidelines`) +- リトライ/タイムアウトの設計 + +### 4. レビュー結果の報告 + +```markdown +## レビュー結果 + +### 概要 +- 変更ファイル数: X +- 追加行数: +XXX +- 削除行数: -XXX + +### Good(良い点) +- ... + +### Suggestions(改善提案) +- `path/to/file.ext:123` — 提案内容 + +### Issues(要修正) +- `path/to/file.ext:456` — 問題点と修正方針 +``` + +## 使用例 + +```bash +# 全般的なレビュー +/ndf:review-branch + +# セキュリティ重視(認証系変更など) +/ndf:review-branch security + +# N+1クエリ等のパフォーマンス問題に焦点 +/ndf:review-branch performance + +# テストの網羅性を確認 +/ndf:review-branch tests +``` + +## 注意事項 + +- 大量の変更がある場合、重要な変更から優先的にレビューする +- 自動品質チェック(linter, formatter, type checker)は事前実行済みを前提とする +- レビュー結果は提案であり、最終判断は開発者が行う +- **コード修正は行わない**(分析とフィードバックのみ。修正は `/ndf:fix` で別途実行) + +## 関連 + +- `/ndf:review` — PR単位レビュー (Approve/Request Changes判定) +- `/ndf:review-pr-comments` — 既存PRコメントの分類 +- `/ndf:fix` — PRレビューコメントの修正対応 +- `/ndf:logging-guidelines` — ログ設計 diff --git a/plugins/ndf-shared/skills/review-pr-comments/SKILL.md b/plugins/ndf-shared/skills/review-pr-comments/SKILL.md new file mode 100644 index 0000000..1b51139 --- /dev/null +++ b/plugins/ndf-shared/skills/review-pr-comments/SKILL.md @@ -0,0 +1,110 @@ +--- +name: review-pr-comments +description: "Classify existing PR comments before fixing." +when_to_use: "既存PRのレビューコメントを分類・優先度判定したいとき (修正前)。Triggers: 'PRコメントを確認', 'PRコメントを分類', 'コメント対応の優先度', 'PR comments review', 'classify PR comments', 'PRレビュー結果を見て'" +argument-hint: "[PR番号]" +allowed-tools: + - Bash + - Read + - Glob + - Grep +--- + +# PRコメント分析コマンド (READ-ONLY) + +GitHub PRのレビューコメントを全て確認し、対応可否を判定する。**修正は一切行わない。分析・判定のみ**。 + +## 使用方法 + +``` +/ndf:review-pr-comments # 現在のブランチのPRを対象 +/ndf:review-pr-comments 9352 # PR番号を指定 +``` + +## `/ndf:fix` との使い分け + +| 観点 | review-pr-comments | fix | +|---|---|---| +| 動作 | 分類・優先度判定のみ | 実際にコード修正 | +| 出力 | 分類テーブル+推奨アクション | 修正差分+commit | +| 推奨順序 | 最初に実行 | review-pr-commentsの結果を見て実行 | + +「まず全体像を把握 → 優先度を決めてから修正」という流れに使う。 + +## 処理フロー + +### 1. PR情報の取得 + +引数でPR番号が指定されていればそれを使用、なければ現在のブランチから取得。 + +```bash +CURRENT_BRANCH=$(git branch --show-current) +PR_NUMBER="${ARGUMENTS:-$(gh pr view --json number --jq .number)}" +``` + +### 2. PRコメント取得 (3 ソース) + +fix skill の共有スクリプトで インラインコメント / レビュー body / PR レベルコメントを一括取得: + +```bash +FETCH_SCRIPT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/skills/fix/scripts/fetch-pr-comments.sh" +"$FETCH_SCRIPT" "$(gh repo view --json nameWithOwner -q .nameWithOwner)" "$PR_NUMBER" +``` + +補助情報 (reviewDecision 等): + +```bash +gh pr view "$PR_NUMBER" --json reviewDecision +``` + +GitHub MCP を使う場合は `mcp__github__get_pull_request_comments` を利用。 + +### 3. コメント分析・分類 + +各コメントを以下のカテゴリに分類する: + +| カテゴリ | 説明 | 対応判断 | +|---------|------|---------| +| 🔴 重大 | セキュリティ、データ整合性、クラッシュの可能性 | **対応必須** | +| 🟡 改善推奨 | コード品質、保守性、ベストプラクティス | **対応推奨** | +| 🟢 軽微 | タイポ、フォーマット、命名規則 | **対応すべき** | +| ⚪ 参考 | 提案、質問、情報共有 | **対応任意** | +| 🔵 別PR対応 | 別PRで対応予定と明記されている内容 | **対応不要** | + +### 4. 出力フォーマット + +```markdown +## PR #XXXX コメントレビュー結果 + +### サマリー +- 総コメント数: X件 +- 対応必須: X件 +- 対応推奨: X件 +- 対応すべき: X件 +- 対応任意/不要: X件 + +### 詳細 + +| # | ファイル | 行 | 指摘内容 | 分類 | 対応判断 | +|---|---------|----|---------|----|---------| +| 1 | path/to/file.ext | 123 | 指摘の要約 | 🔴 重大 | **対応必須** | +| 2 | ... | ... | ... | ... | ... | + +### 推奨アクション +1. まず対応すべき項目(重大+軽微) +2. 次に対応推奨項目 +3. 別PRで対応(コメントで返信推奨) +``` + +## 重要ルール + +- **READ-ONLY**: コードの修正は一切行わない +- **PR説明文を確認**: 「やらないこと」「別PR対応」セクションに記載されている内容は「🔵 別PR対応」として分類 +- **コンテキスト理解**: コメントが指摘している問題の本質を理解して分類 +- **判断根拠**: なぜその分類になったかの理由を簡潔に説明 + +## 関連 + +- `/ndf:fix` — 分類結果を踏まえてコード修正を実施 +- `/ndf:resolve-pr-comments` — 修正完了後の返信+Resolve +- `/ndf:review` — PRを新規にレビューする (Approve/Request Changes判定) diff --git a/plugins/ndf-shared/skills/review/SKILL.md b/plugins/ndf-shared/skills/review/SKILL.md new file mode 100644 index 0000000..cfb920b --- /dev/null +++ b/plugins/ndf-shared/skills/review/SKILL.md @@ -0,0 +1,337 @@ +--- +name: review +description: "Review PRs and post approve or changes verdicts." +argument-hint: "[PR番号] [AIエージェント(codex|gemini)]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Glob + - Grep +--- + +# PRレビューコマンド + +直前PR、または引数で指定されたPRを専門家としてレビュー。 + +## 引数 + +- 第一引数 `[PR番号]`: レビュー対象のPR番号(省略時は直前のPR) +- 第二引数 `[AIエージェント]`: レビュー実行者(任意) + - 省略時: Claude(自身)でレビュー + - `codex`: Codex CLI に委譲 + - `gemini`: Gemini CLI に委譲 + +## 実行 + +- 問題点・改善点あり → 「Request Changes」 +- 指摘なし → 「Approve」 +- **レビュー結果は必ず GitHub PR 上に投稿する**(後述「レビュー結果の投稿」参照) + - 指摘は可能な限り **コード行に紐付くインラインコメント** として書く + - ファイル横断・設計レベルの所見のみ review body(総評)に書く + +## 観点 + +言語慣用性(Idiomatic)・可読性・コード品質・保守性・セキュリティ・テストカバレッジ +- 上から順に優先して指摘 + +### 具体的なチェックポイント + +- **その言語らしい記述方式**: イディオム・標準ライブラリ・言語機能の活用 +- **メモリ効率・演算性能を意識したコード** + - キャッシュ利用 + - Python: numpy 利用、内包表記、ジェネレータ + - PHP: switch 文の map(連想配列)化 + - 不要なループ・コピーの排除 +- **関数・メソッド・ファイル行数の適正化** + - 目安: 関数/メソッド 50 行、ファイル 300 行 + - ただしプロジェクトの慣例に従う +- **重複・冗長コードの排除** + - PR 範囲にこだわらず積極的にまとめるよう指摘 +- **柔軟性を損なう定数化の排除** + - 数字をそのまま定数にするような硬直化を避ける + - 定数よりも DB の master テーブル、または json/yaml による外部化を検討 + +## レビュー結果の投稿 + +レビュー結果は **GitHub の PR レビュー機能** を使って必ず PR 上に書き込む。 +個別指摘は **コード行に紐付くインラインコメント** が原則。総評(review body)にだけ書くのは避ける。 + +### 指摘の振り分け + +| 指摘の種類 | 投稿先 | +|---|---| +| 特定ファイル・特定行への指摘 | **インラインコメント** (`comments[].path` + `line`) | +| 複数ファイルにまたがる設計指摘 | 代表箇所にインラインコメント + review body に補足 | +| 設計レベル・PR全体の所見 | review body(総評) | +| ファイル単位の指摘(行を絞れない) | そのファイルの代表行にインラインコメント | + +### 投稿フロー(推奨: 1 リクエストで一括投稿) + +`gh api` の Reviews API を使い、**総評 + 複数のインラインコメント + 判定(event)を 1 回で送信** する。 + +```bash +PR= +OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +SHA=$(gh pr view "$PR" --json headRefOid -q .headRefOid) + +# 1. インラインコメントを JSON 配列で組み立て +# (path / line / side / body の 4 つが必須。複数行レンジは start_line を併用) +# +# ▼ 推奨: jq -n でシェル変数を安全に流し込む(特殊文字混入時の JSON 破損を防ぐ) +SUMMARY=$'## 総評\n\n... 全体所見をここに ...' +jq -n \ + --arg sha "$SHA" \ + --arg event "REQUEST_CHANGES" \ + --arg body "$SUMMARY" \ + '{ + commit_id: $sha, + event: $event, + body: $body, + comments: [ + {path: "src/foo.py", line: 42, side: "RIGHT", + body: "[major / 可読性] この関数は 70 行ある。〇〇 と △△ に分割を推奨。"}, + {path: "src/bar.py", start_line: 10, line: 25, side: "RIGHT", + body: "[minor / 性能] このループは内包表記化できる。"} + ] + }' > /tmp/review-payload.json + +# 2. Reviews API に POST +gh api -X POST "repos/$OWNER_REPO/pulls/$PR/reviews" --input /tmp/review-payload.json +``` + +> 💡 **JSON 組み立てに heredoc (`< JSON が壊れる(あるいはクオート未エスケープで JSON injection になる)。`jq -n --arg` 経由なら値が自動で +> JSON エスケープされるため安全。クオート付き heredoc (`<<'JSON'`) は逆に `$SHA` が展開されず使えない。 + +**`event` の値**: +- `APPROVE` — 指摘なし +- `REQUEST_CHANGES` — 修正必須の指摘あり +- `COMMENT` — 任意の指摘のみ(マージブロックしない) + +### インラインコメント本文の書式 + +各 `comments[].body` の先頭に **`[重要度 / カテゴリ]`** を付けて視認性を上げる: + +``` +[critical / セキュリティ] SQL がエスケープなしで連結されている。プレースホルダ必須。 +[major / 可読性] 70 行関数。〇〇 と △△ に分割を推奨。 +[minor / 言語慣用性] Python なら内包表記で 1 行化可能。 +[nit / スタイル] スペースが揃っていない。 +``` + +重要度の目安: +- `critical` — セキュリティ・データ破損・本番障害につながる +- `major` — 保守性 / 性能 / 仕様逸脱の重要問題 +- `minor` — 改善推奨だがブロッカーではない +- `nit` — 好み・スタイル + +### 既存コメントがある場合の重複防止 + +同じ箇所への二重指摘を避けるため、投稿前に既存コメントを確認する: + +```bash +# 既存のレビューコメント一覧 +gh api "repos/$OWNER_REPO/pulls/$PR/comments" --paginate \ + | jq -r '.[] | "\(.path):\(.line) \(.body | split("\n")[0])"' +``` + +すでに同種の指摘があれば、その指摘は省くか、reply(既存コメントへの返信)にする。 + +### 補助コマンド + +```bash +# review body 単体(インラインなし)で投稿したい場合 +gh pr review "$PR" --request-changes --body "..." +gh pr review "$PR" --approve --body "..." + +# 会話タブへの普通のコメント(行に紐付かない) +gh pr comment "$PR" --body "..." + +# 1 件だけインラインコメントを追加(既存 review に含めない) +gh api -X POST "repos/$OWNER_REPO/pulls/$PR/comments" \ + -F commit_id="$SHA" \ + -F path="src/foo.py" \ + -F line=42 -F side=RIGHT \ + -F body="..." +``` + +## 外部AIへの委譲手順 + +第二引数が指定された場合、上記「観点」「具体的なチェックポイント」「レビュー結果の投稿」の内容を **レビュー指示プロンプト** として組み立て、指定された CLI に渡す。 + +### 共通: プロンプト組み立て + +1. `gh pr view --json title,body,baseRefName,headRefName,url,headRefOid` で PR メタ情報を取得 +2. `gh pr diff ` で差分を取得(または変更ファイル一覧 + 必要箇所を `gh pr view --json files` 経由で抽出) +3. 上記「観点」「具体的なチェックポイント」「レビュー結果の投稿」セクションをそのままプロンプトに転記 +4. PR タイトル・URL・差分を **対象情報** として明記 +5. **出力は GitHub Reviews API のペイロード形式(JSON)で出させる**(後述「外部AIに必須化する出力形式」参照) + +### 外部AIに必須化する出力形式と直接投稿 + +**外部AIは Reviews API ペイロードを組み立てた後、自分自身で `gh api` を呼んで PR に投稿する**。 +(旧版では生成した JSON をメインに返してメインが投稿していたが、メイン context 消費と往復回数が無駄なので削除) + +メインに返すのは「投稿が成功したか」「最終 verdict (event)」「review URL」「件数」の小さな結果サマリのみ。 + +#### プロンプトに必ず含める指示(テンプレート) + +```markdown +## 出力形式と投稿手順(必須) + +レビュー結果は以下の手順で **あなた自身が PR に投稿** してください。 +メイン側に返すのは投稿結果サマリだけです。 + +### 1. ペイロード組み立て + +以下の JSON を `/tmp/-review-pr<番号>-payload.json` に書き出す +(codex なら `apply_patch`、gemini なら `write_file` を使用): + +\`\`\`json +{ + "commit_id": "", + "event": "REQUEST_CHANGES" | "APPROVE" | "COMMENT", + "body": "## 総評\n\n...(設計レベル・PR全体所見のみ)...", + "comments": [ + { + "path": "src/foo.py", + "line": 42, + "side": "RIGHT", + "body": "[major / 可読性] ..." + } + ] +} +\`\`\` + +ルール: +- 個別指摘は必ず `comments[]` のインラインコメントにすること(行を絞れない場合はファイル代表行) +- `body` (総評) には設計・横断的な所見のみ書く。個別指摘の繰り返しは禁止 +- 各 `comments[].body` の先頭に `[重要度 / カテゴリ]` を付ける(critical/major/minor/nit) +- `path` は **PR差分に登場するファイルのみ**(事前に `gh pr diff --name-only` で取得した一覧から選ぶ) +- `line` は **差分に含まれる行**(追加行・コンテキスト行)に限る。`side=RIGHT` がデフォルト +- `commit_id` は `gh pr view --json headRefOid -q .headRefOid` の値を使う + +### 2. 投稿 + +\`\`\`bash +OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +gh api -X POST "repos/$OWNER_REPO/pulls//reviews" \ + --input /tmp/-review-pr<番号>-payload.json \ + > /tmp/-review-pr<番号>-response.json +\`\`\` + +### 3. 結果サマリの書き出し(メインが読む) + +`/tmp/-review-pr<番号>-result.json` に投稿結果を書き出す: + +\`\`\`json +{ + "status": "posted" | "failed", + "event": "REQUEST_CHANGES" | "APPROVE" | "COMMENT", + "posted_as": "REQUEST_CHANGES" | "APPROVE" | "COMMENT", + "review_url": "https://github.com/.../pull/#pullrequestreview-...", + "comments_count": 5, + "by_severity": {"critical": 0, "major": 2, "minor": 2, "nit": 1}, + "payload_path": "/tmp/-review-pr<番号>-payload.json", + "error": null +} +\`\`\` + +投稿失敗時は `status: "failed"`、`error` にエラーメッセージ、`payload_path` で payload は残す +(メイン側のフォールバック投稿で使う)。 + +**`event` と `posted_as` の使い分け**: + +- `event` — **AI 本来の判定 (intent)**。ループ収束判定(`/ndf:cross-review`)はこれを見る +- `posted_as` — **GitHub に実際投稿した event**。`event` と同じ値がデフォルト + +GitHub は **自分の PR には `REQUEST_CHANGES` で投稿できない**(`HTTP 422: Can not request changes on your own pull request`)。自分 PR レビューの場合は以下のダウングレードを行う: + +- `event = "REQUEST_CHANGES"` のままにしておく(intent 保持) +- ペイロードの `event` だけ `"COMMENT"` にして投稿 +- `posted_as = "COMMENT"` を結果サマリに記録 + +これにより、後段のループ判定で「本当は REQ なので継続が必要」と判断できる。判定にあたっては事前に `gh api user --jq .login` と `gh pr view --json author --jq .author.login` を比較すること。 + +### 4. 重要度の運用ガイド(auto-fix 判定に直結) + +| 重要度 | 定義 | 後段の扱い | +|---|---|---| +| critical | セキュリティ・データ破損・本番障害につながる | **必ず自動修正** | +| major | 保守性・性能・仕様逸脱の重要問題 | **必ず自動修正** | +| minor | 改善推奨だがブロッカーではない | **自動修正対象**(明らかな改善のみ。判断要なら nit に格下げ) | +| nit | 好み・スタイル | **修正しない、最後にユーザ判断にまとめる** | + +過剰な nit 量産は避ける。critical/major で対応すべき真の問題に集中すること。 +``` + +### `codex` 指定時 + +呼び出し手順の詳細は `/ndf:codex` skill(`plugins/ndf/skills/codex/SKILL.md`)に従う。要点: + +- プロンプトを `/tmp/codex-review-pr<番号>-prompt.md` に書き出し +- 出力先ファイルを `/tmp/codex-output-review-pr<番号>.md` として **プロンプト内で `apply_patch` 書き出しを必須化** +- `codex exec --dangerously-bypass-approvals-and-sandbox --config reasoning.effort=medium -C "$PWD" < prompt > stdout 2> err &` でバックグラウンド起動 +- `grep -q '^tokens used$' err` で完了検知 +- 「ファイル → stdout → stderr」三段フォールバックで成果物を回収 + +> ⚠️ **`--dangerously-bypass-approvals-and-sandbox` のセキュリティ注意**: このフラグは codex の bwrap サンドボックスを完全に無効化し、 +> 任意のシェル実行・任意のファイル編集を無確認で許可する。**必ず Docker / devcontainer / VM / CI ランナー等の外部隔離環境内** でのみ使用すること。 +> ホスト直接実行や本番リポジトリでは使わない。詳細な背景・代替策(`unprivileged_userns_clone` 有効化など)は `/ndf:codex` skill の +> 「サンドボックス制約」節を参照。 + +### `gemini` 指定時 + +呼び出し手順の詳細は `/ndf:gemini` skill(`plugins/ndf/skills/gemini/SKILL.md`)に従う。要点: + +- プロンプトを `/tmp/gemini-review-pr<番号>-prompt.md` に書き出し +- **AI 直接投稿フローでは `--yolo` 必須**(`gh api -X POST` がシェル実行のため、`plan` / `auto_edit` だとブロックされる) +- プロンプト側で **「リポジトリ内ファイルを編集してはならない。`gh api` で投稿するだけ」** を強く明示することで `--yolo` のリスクを抑える +- `gemini --yolo --output-format text -p "$(cat prompt.md)" > stdout 2> err &` でバックグラウンド起動 +- `kill -0 $PID` ポーリングで完了検知(Codex と異なり sentinel 不要 / プロセス exit を見る) +- 成果物は stdout サマリ + `/tmp/gemini-review-pr<番号>-result.json` で回収 + +> ⚠️ **`--yolo` の制約は依然有効**: `/ndf:gemini` skill のセキュリティ警告通り、必ず外部隔離環境内でのみ実行する。プロンプトで「リポジトリ編集禁止」を明示することは必須だが、それは sandbox の代替にはならない。 + +### メイン側の検証とフォールバック + +メインエージェントの責務は **結果サマリ読み込みと検証のみ**: + +```bash +AGENT=codex # or gemini +RESULT=/tmp/$AGENT-review-pr$PR-result.json + +if [ ! -s "$RESULT" ]; then + echo "❌ $AGENT: 結果サマリ未生成。完了検知 or プロンプト指示に問題あり" >&2 + exit 1 +fi + +STATUS=$(jq -r '.status' "$RESULT") +EVENT=$(jq -r '.event // empty' "$RESULT") + +if [ "$STATUS" = "failed" ]; then + echo "⚠️ $AGENT: 投稿失敗。payload からメインがフォールバック投稿します" >&2 + PAYLOAD=$(jq -r '.payload_path' "$RESULT") + OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) + SHA=$(gh pr view "$PR" --json headRefOid -q .headRefOid) + jq --arg sha "$SHA" '.commit_id = $sha' "$PAYLOAD" > /tmp/review-fallback.json + gh api -X POST "repos/$OWNER_REPO/pulls/$PR/reviews" --input /tmp/review-fallback.json +fi + +echo "$AGENT: event=$EVENT url=$(jq -r .review_url $RESULT)" +``` + +**Claude 自身による追加判定は行わず**、外部AIの判定(`event`)と指摘内容をそのまま採用する。 + +## 作業完了報告(必須) + +レビュー結果は **PR 上に投稿済み** であることが前提。ユーザーへの報告は以下に絞る: + +- 利用エージェント(claude / codex / gemini のいずれか) +- 投稿結果(review URL、event = APPROVE / REQUEST_CHANGES / COMMENT) +- 件数サマリ(インラインコメント数、重要度別内訳) +- 総評(review body)の要約 +- PR URL + +詳細な指摘内容は PR 上のインラインコメントに残っているため、ユーザー宛報告では繰り返さない。 diff --git a/plugins/ndf/skills/skill-stats/SKILL.md b/plugins/ndf-shared/skills/skill-stats/SKILL.md similarity index 100% rename from plugins/ndf/skills/skill-stats/SKILL.md rename to plugins/ndf-shared/skills/skill-stats/SKILL.md diff --git a/plugins/ndf/skills/skill-stats/scripts/skill-stats.py b/plugins/ndf-shared/skills/skill-stats/scripts/skill-stats.py similarity index 100% rename from plugins/ndf/skills/skill-stats/scripts/skill-stats.py rename to plugins/ndf-shared/skills/skill-stats/scripts/skill-stats.py diff --git a/plugins/ndf-shared/skills/statusline/SKILL.md b/plugins/ndf-shared/skills/statusline/SKILL.md new file mode 100644 index 0000000..ab17e4a --- /dev/null +++ b/plugins/ndf-shared/skills/statusline/SKILL.md @@ -0,0 +1,51 @@ +--- +name: statusline +description: "Switch, restore, or inspect the NDF statusline." +when_to_use: "statuslineを切り替え/復元/確認したいとき。Triggers: 'statusline', 'ステータスライン', 'statusline 切り替え', 'statusline 戻す'" +disable-model-invocation: true +allowed-tools: + - Bash +--- + +# Statusline 切り替えコマンド + +NDF 標準 statusline (コンテナ名/ホスト名 + project_dir + コンテキスト使用率) と +既存のカスタム statusline を切り替える。 + +## 表示内容 + +``` +<コンテナ名|ホスト名> [<モデル名>: 12.3k / 200k tokens (6%)] +``` + +- コンテナ環境 (`/.dockerenv` あり) ではコンテナ名、それ以外ではホスト名を表示 +- `CONTAINER_NAME` 環境変数があればそちらを優先 +- 角括弧内のラベルは利用中モデルの表示名 (例: `Opus 4.8`)。取得できない場合は `ctx` にフォールバック + +## 使用方法 + +引数に応じて以下のコマンドを実行する: + +```bash +PLUGIN_ROOT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}" + +# 状態確認 (引数なし or status) +bash "${PLUGIN_ROOT}/scripts/statusline-switch.sh" status + +# NDF 標準 statusline に切り替え (既存設定は自動バックアップ) +bash "${PLUGIN_ROOT}/scripts/statusline-switch.sh" set + +# 元の設定に復元 (バックアップが無ければ statusLine 設定を削除) +bash "${PLUGIN_ROOT}/scripts/statusline-switch.sh" restore +``` + +実行後、スクリプトの出力をそのままユーザーに報告する。 +statusline の変更は次回セッション開始時 (または statusline 再描画時) に反映される。 + +## 自動デフォルト設定 (SessionStart hook) + +プラグインインストール後の初回セッション開始時に `statusline-switch.sh ensure` が実行され、 +**statusLine が未設定の場合のみ** NDF 標準 statusline が設定される。 +既に statusline が設定されている場合はそちらが優先され、何も変更しない。 +NDF 標準 statusline の利用中は、プラグイン更新時にスクリプト +(`~/.claude/ndf-statusline.sh`) の内容が自動で追従する。 diff --git a/plugins/ndf-shared/skills/statusline/tests/__init__.py b/plugins/ndf-shared/skills/statusline/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ndf-shared/skills/statusline/tests/test_statusline_switch.py b/plugins/ndf-shared/skills/statusline/tests/test_statusline_switch.py new file mode 100644 index 0000000..c875a37 --- /dev/null +++ b/plugins/ndf-shared/skills/statusline/tests/test_statusline_switch.py @@ -0,0 +1,150 @@ +"""statusline-switch.sh `ensure` のバージョンアップ追従ロジックを検証する。 + +NDF が過去に配置した statusline コピー (マーカー付き / レガシー +statusline-command.sh) を settings.json が指している場合に、正規パス +(~/.claude/ndf-statusline.sh) 参照へ自動移行することを確認する。 +ユーザー独自の statusline は決して上書きしないこと (誤検出ガード) も検証する。 + +既存 cross-review テストの規約に倣い、隔離 HOME 上で bash スクリプトを +subprocess 実行して settings.json の最終状態を観測する。 +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +# bash / jq が無ければモジュールごと skip +for _cmd in ("bash", "jq"): + if shutil.which(_cmd) is None: + pytest.skip(f"{_cmd} not available", allow_module_level=True) + +# plugins/ndf/skills/statusline/tests/ -> plugins/ndf/scripts/statusline-switch.sh +SWITCH = Path(__file__).resolve().parents[3] / "scripts" / "statusline-switch.sh" +NDF_COMMAND = "bash ~/.claude/ndf-statusline.sh" + +# NDF statusline コピーとみなされる最小内容 (レガシー判定用: ctx ラベル + コンテナ名取得) +LEGACY_COPY = ( + "#!/bin/bash\n" + "container_name=$(hostname)\n" + 'printf "[ctx: 1k / 2k tokens (5%%)]"\n' +) +# マーカー付きコピー (将来の全コピーが該当) +MARKED_COPY = ( + "#!/bin/bash\n" + "# ndf-statusline: managed (do not edit; auto-updated by ndf:statusline)\n" + 'echo "hi"\n' +) +# NDF と無関係なユーザー独自 statusline +CUSTOM = '#!/bin/bash\necho "my custom bar"\n' + + +def _run_ensure(home: Path) -> subprocess.CompletedProcess: + """隔離 HOME で `statusline-switch.sh ensure` を実行する。""" + env = os.environ.copy() + env["HOME"] = str(home) + return subprocess.run( + ["bash", str(SWITCH), "ensure"], + capture_output=True, + text=True, + env=env, + ) + + +def _settings(home: Path) -> dict: + return json.loads((home / ".claude" / "settings.json").read_text()) + + +def _write_settings(home: Path, command: str) -> None: + (home / ".claude" / "settings.json").write_text( + json.dumps({"statusLine": {"type": "command", "command": command}}) + ) + + +def _claude(home: Path) -> Path: + d = home / ".claude" + d.mkdir(parents=True, exist_ok=True) + return d + + +def test_legacy_copy_is_migrated(tmp_path: Path) -> None: + """マーカー無しの既知レガシー statusline-command.sh は正規パスへ移行される。""" + claude = _claude(tmp_path) + (claude / "statusline-command.sh").write_text(LEGACY_COPY) + _write_settings(tmp_path, "bash ~/.claude/statusline-command.sh") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["command"] == NDF_COMMAND + # 既存設定がバックアップされていること + assert (claude / ".ndf-statusline-backup.json").is_file() + + +def test_marked_copy_is_migrated(tmp_path: Path) -> None: + """マーカー付きコピー (任意のファイル名) は正規パスへ移行される。""" + claude = _claude(tmp_path) + (claude / "my-status.sh").write_text(MARKED_COPY) + _write_settings(tmp_path, "bash ~/.claude/my-status.sh") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["command"] == NDF_COMMAND + + +def test_official_ndf_path_unchanged(tmp_path: Path) -> None: + """既に正規パスを指している場合は何もしない (deploy_script で本体追従済み)。""" + _claude(tmp_path) + _write_settings(tmp_path, NDF_COMMAND) + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["command"] == NDF_COMMAND + # 正規パスの場合はバックアップを作らない + assert not (tmp_path / ".claude" / ".ndf-statusline-backup.json").exists() + + +def test_user_custom_is_respected(tmp_path: Path) -> None: + """ユーザー独自 statusline (マーカー無し・別名) は尊重し上書きしない。""" + claude = _claude(tmp_path) + (claude / "mybar.sh").write_text(CUSTOM) + _write_settings(tmp_path, "bash ~/.claude/mybar.sh") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["command"] == "bash ~/.claude/mybar.sh" + assert not (claude / ".ndf-statusline-backup.json").exists() + + +def test_same_name_but_non_ndf_content_is_guarded(tmp_path: Path) -> None: + """statusline-command.sh でも NDF 特徴を含まなければ移行しない (誤検出ガード)。""" + claude = _claude(tmp_path) + (claude / "statusline-command.sh").write_text(CUSTOM) + _write_settings(tmp_path, "bash ~/.claude/statusline-command.sh") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert ( + _settings(tmp_path)["statusLine"]["command"] + == "bash ~/.claude/statusline-command.sh" + ) + assert not (claude / ".ndf-statusline-backup.json").exists() + + +def test_unset_statusline_gets_ndf_default(tmp_path: Path) -> None: + """statusLine 未設定なら NDF 標準を新規設定する。""" + claude = _claude(tmp_path) + (claude / "settings.json").write_text("{}") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["command"] == NDF_COMMAND diff --git a/plugins/ndf-shared/skills/sync-main/SKILL.md b/plugins/ndf-shared/skills/sync-main/SKILL.md new file mode 100644 index 0000000..87aa223 --- /dev/null +++ b/plugins/ndf-shared/skills/sync-main/SKILL.md @@ -0,0 +1,48 @@ +--- +name: sync-main +description: "Sync the current branch with main or master." +disable-model-invocation: true +allowed-tools: + - Bash + - Read +--- + +# main取り込みコマンド + +最新のデフォルトブランチ(main/master)を現在のブランチにマージする。 + +## 処理フロー + +1. **ブランチ確認** + - `git branch --show-current` で現在ブランチ確認 + - デフォルトブランチ(main/master)自身の場合は `git pull` のみ実行して終了 + +2. **作業ツリー確認** + - `git status` で未コミット変更を確認 + - 未コミット変更があれば `git stash` で退避 + +3. **最新取得** + - `git fetch origin ` でリモート最新を取得 + +4. **マージ実行** + - `git merge origin/ --no-edit` でマージ + - コンフリクト発生時: + - `git diff --name-only --diff-filter=U` でコンフリクトファイル一覧を表示 + - ユーザーに報告し、**自動解決はしない** + - ユーザー確認後に作業継続 + +5. **後処理** + - stash退避していた場合は `git stash pop` で復元 + - コンフリクトがなければ `git push` でリモートに反映 + - 完了報告(マージ済みコミット数、変更ファイル数) + +## 制約 + +- デフォルトブランチ自身での実行は `git pull` に自動フォールバック +- コンフリクトは自動解決しない(ユーザーが解決) +- 作業ツリーが汚れている場合は必ず stash で退避してから実行 + +## 関連 + +- `/ndf:branch-fix-strategy` — 複数ブランチへの修正適用戦略 +- `/ndf:cherry-pick-pr` — 環境ブランチへのcherry-pick PR作成 diff --git a/scripts/build-runtime-plugins.sh b/scripts/build-runtime-plugins.sh new file mode 100755 index 0000000..fdc07af --- /dev/null +++ b/scripts/build-runtime-plugins.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CHECK=false + +usage() { + cat <<'EOF' +Usage: bash scripts/build-runtime-plugins.sh [--check] + +Synchronize runtime plugin generated files from plugins/ndf-shared. + +Options: + --check Compare generated output with the working tree and fail on drift. + -h, --help +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --check) CHECK=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +SHARED_DIR="$ROOT_DIR/plugins/ndf-shared" + +copy_tree() { + local source_dir="$1" + local dest_dir="$2" + local tmp_dir + + if [ ! -d "$source_dir" ]; then + echo "ERROR: source directory not found: $source_dir" >&2 + exit 1 + fi + + tmp_dir="$(mktemp -d)" + cp -a "$source_dir/." "$tmp_dir/" + find "$tmp_dir" \( \ + -name .venv -o \ + -name .pytest_cache -o \ + -name __pycache__ -o \ + -name '*.pyc' -o \ + -name '*.pyo' \ + \) -exec rm -rf {} + 2>/dev/null || true + + if [ "$CHECK" = true ]; then + if [ ! -d "$dest_dir" ]; then + echo "Generated directory missing: ${dest_dir#$ROOT_DIR/}" >&2 + rm -rf "$tmp_dir" + return 1 + fi + if ! diff -ruN "$tmp_dir" "$dest_dir" >/tmp/runtime-plugin-build-check.diff; then + echo "Generated directory is out of date: ${dest_dir#$ROOT_DIR/}" >&2 + cat /tmp/runtime-plugin-build-check.diff >&2 + rm -rf "$tmp_dir" + return 1 + fi + rm -rf "$tmp_dir" + else + rm -rf "$dest_dir" + mkdir -p "$(dirname "$dest_dir")" + mv "$tmp_dir" "$dest_dir" + fi +} + +sync_skills() { + local manifest="$1" + local source_dir="$2" + local dest_dir="$3" + local tmp_dir + local skill + + if [ ! -f "$manifest" ]; then + echo "ERROR: manifest not found: $manifest" >&2 + exit 1 + fi + if [ ! -d "$source_dir" ]; then + echo "ERROR: source directory not found: $source_dir" >&2 + exit 1 + fi + + tmp_dir="$(mktemp -d)" + while IFS= read -r skill || [ -n "$skill" ]; do + skill="${skill%%#*}" + skill="${skill#"${skill%%[![:space:]]*}"}" + skill="${skill%"${skill##*[![:space:]]}"}" + [ -z "$skill" ] && continue + + case "$skill" in + */*|*..*|'') + echo "ERROR: invalid skill name in ${manifest#$ROOT_DIR/}: $skill" >&2 + rm -rf "$tmp_dir" + exit 1 + ;; + esac + + if [ ! -f "$source_dir/$skill/SKILL.md" ]; then + echo "ERROR: missing SKILL.md for $skill in ${source_dir#$ROOT_DIR/}" >&2 + rm -rf "$tmp_dir" + exit 1 + fi + cp -a "$source_dir/$skill" "$tmp_dir/$skill" + done < "$manifest" + + find "$tmp_dir" \( \ + -name .venv -o \ + -name .pytest_cache -o \ + -name __pycache__ -o \ + -name '*.pyc' -o \ + -name '*.pyo' \ + \) -exec rm -rf {} + 2>/dev/null || true + + if [ "$CHECK" = true ]; then + if [ ! -d "$dest_dir" ]; then + echo "Generated directory missing: ${dest_dir#$ROOT_DIR/}" >&2 + rm -rf "$tmp_dir" + return 1 + fi + if ! diff -ruN "$tmp_dir" "$dest_dir" >/tmp/runtime-plugin-build-check.diff; then + echo "Generated directory is out of date: ${dest_dir#$ROOT_DIR/}" >&2 + cat /tmp/runtime-plugin-build-check.diff >&2 + rm -rf "$tmp_dir" + return 1 + fi + rm -rf "$tmp_dir" + else + rm -rf "$dest_dir" + mkdir -p "$(dirname "$dest_dir")" + mv "$tmp_dir" "$dest_dir" + fi +} + +sync_legacy_ndf() { + sync_skills \ + "$SHARED_DIR/manifests/claude-skills.txt" \ + "$SHARED_DIR/skills" \ + "$ROOT_DIR/plugins/ndf/skills" + copy_tree "$SHARED_DIR/scripts" "$ROOT_DIR/plugins/ndf/scripts" +} + +sync_runtime_if_present() { + local runtime="$1" + local manifest="$2" + local plugin_dir="$ROOT_DIR/plugins/ndf-$runtime" + + [ -d "$plugin_dir" ] || return 0 + sync_skills "$manifest" "$SHARED_DIR/skills" "$plugin_dir/skills" + copy_tree "$SHARED_DIR/scripts" "$plugin_dir/scripts" +} + +sync_legacy_ndf +sync_runtime_if_present claude "$SHARED_DIR/manifests/claude-skills.txt" +sync_runtime_if_present codex "$SHARED_DIR/manifests/codex-skills.txt" +sync_runtime_if_present kiro "$SHARED_DIR/manifests/kiro-skills.txt" + +if [ "$CHECK" = true ]; then + echo "runtime plugin generated files are up to date" +else + echo "runtime plugin generated files synchronized" +fi From 99b87860f1045f05c0cfeb020e993619a72de974 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 5 Jul 2026 21:08:45 +0000 Subject: [PATCH 04/22] =?UTF-8?q?fix:=20runtime=20builder=E3=81=AE?= =?UTF-8?q?=E5=BE=8C=E6=96=B9=E4=BA=92=E6=8F=9B=E7=94=9F=E6=88=90=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/data-analyst-export/01-formats.md | 143 +++ .../skills/data-analyst-export/02-examples.md | 116 +++ .../ndf/skills/data-analyst-export/SKILL.md | 64 ++ .../data-analyst-export/scripts/export-csv.js | 151 +++ .../01-patterns.md | 145 +++ .../02-examples.md | 136 +++ .../data-analyst-sql-optimization/SKILL.md | 48 + plugins/ndf/skills/deepwiki-transfer/SKILL.md | 144 +++ .../deepwiki-transfer/scripts/fetch_wiki.py | 229 +++++ .../deepwiki-transfer/scripts/pyproject.toml | 13 + .../deepwiki-transfer/scripts/split_pages.py | 266 +++++ .../deepwiki-transfer/scripts/validate_gfm.py | 324 ++++++ plugins/ndf/skills/gemini/SKILL.md | 444 ++++++++ plugins/ndf/skills/google-auth/SKILL.md | 173 ++++ plugins/ndf/skills/google-auth/pyproject.toml | 9 + .../skills/google-auth/scripts/google_auth.py | 298 ++++++ plugins/ndf/skills/google-auth/uv.lock | 464 +++++++++ plugins/ndf/skills/google-chat/SKILL.md | 153 +++ plugins/ndf/skills/google-chat/pyproject.toml | 11 + .../skills/google-chat/scripts/gchat_read.py | 168 +++ plugins/ndf/skills/google-chat/uv.lock | 693 +++++++++++++ plugins/ndf/skills/google-drive/SKILL.md | 111 ++ .../ndf/skills/google-drive/pyproject.toml | 10 + .../google-drive/scripts/gdrive_fetch.py | 135 +++ plugins/ndf/skills/google-drive/uv.lock | 611 +++++++++++ plugins/ndf/skills/knowledge-reorg/SKILL.md | 269 +++++ plugins/ndf/skills/mcp-builder/LICENSE.txt | 202 ++++ plugins/ndf/skills/mcp-builder/SKILL.md | 236 +++++ .../mcp-builder/reference/evaluation.md | 602 +++++++++++ .../reference/mcp_best_practices.md | 249 +++++ .../mcp-builder/reference/node_mcp_server.md | 970 ++++++++++++++++++ .../reference/python_mcp_server.md | 719 +++++++++++++ .../skills/mcp-builder/scripts/connections.py | 151 +++ .../skills/mcp-builder/scripts/evaluation.py | 373 +++++++ .../scripts/example_evaluation.xml | 22 + .../mcp-builder/scripts/requirements.txt | 2 + .../ndf/skills/ml-model-structure/SKILL.md | 152 +++ .../references/inference-and-contract.md | 117 +++ .../official-skills-autoloader/SKILL.md | 121 +++ .../playwright-browser-connect/SKILL.md | 484 +++++++++ .../scripts/start-host-chrome.sh | 172 ++++ .../skills/playwright-evidence-drive/SKILL.md | 190 ++++ .../ndf/skills/playwright-execution/SKILL.md | 101 ++ .../ndf/skills/playwright-kit-ops/.gitignore | 11 + .../ndf/skills/playwright-kit-ops/SKILL.md | 119 +++ .../playwright_kit/__init__.py | 11 + .../playwright_kit/accessibility.py | 90 ++ .../playwright_kit/body_check.py | 118 +++ .../playwright_kit/config.py | 461 +++++++++ .../playwright_kit/fixtures/__init__.py | 19 + .../playwright_kit/fixtures/accessibility.py | 143 +++ .../playwright_kit/fixtures/auth.py | 318 ++++++ .../playwright_kit/fixtures/body_check.py | 194 ++++ .../playwright_kit/fixtures/evidence.py | 353 +++++++ .../playwright_kit/fixtures/web_vitals.py | 109 ++ .../playwright_kit/overlay.py | 220 ++++ .../playwright_kit/pytest_plugin.py | 491 +++++++++ .../playwright_kit/pytest_report.py | 212 ++++ .../playwright_kit/uploaders/__init__.py | 151 +++ .../playwright_kit/video.py | 71 ++ .../playwright_kit/web_vitals.py | 142 +++ .../skills/playwright-kit-ops/pyproject.toml | 50 + .../playwright-kit-ops/scripts/_drive_auth.py | 57 + .../scripts/build_gdoc_with_drive_links.py | 126 +++ .../playwright-kit-ops/scripts/check_cwv.py | 113 ++ .../scripts/classify_page_role.py | 238 +++++ .../scripts/gdrive_upload_dir.py | 75 ++ .../scripts/init_project.bat | 196 ++++ .../scripts/init_project.sh | 184 ++++ .../scripts/record_scenario.py | 104 ++ .../scripts/run_a11y_scan.py | 104 ++ .../scripts/upload_evidence.py | 225 ++++ .../scripts/upload_md_as_gdoc.py | 47 + .../templates/conftest.py.template | 127 +++ .../templates/pyproject.toml.runtime | 51 + .../playwright-kit-ops/templates/run.bat | 95 ++ .../playwright-kit-ops/templates/run.sh | 101 ++ .../templates/runtime-README.md | 130 +++ .../templates/runtime-gitignore | 28 + .../templates/scenario.config.yaml | 158 +++ .../templates/test_auth.py.template | 35 + .../templates/test_dashboard.py.template | 25 + .../templates/test_form.py.template | 33 + .../templates/test_list.py.template | 28 + .../playwright-kit-ops/tests/__init__.py | 0 .../playwright-kit-ops/tests/conftest.py | 23 + .../tests/test_a11y_cwv_routing.py | 80 ++ .../tests/test_auth_cache.py | 242 +++++ .../tests/test_body_check.py | 394 +++++++ .../tests/test_config_basic_auth.py | 163 +++ .../tests/test_evidence_fixture.py | 264 +++++ .../tests/test_makereport_user_properties.py | 147 +++ .../tests/test_pytest_plugin_bootstrap.py | 135 +++ .../tests/test_pytest_report.py | 328 ++++++ .../tests/test_pytest_terminal_summary.py | 372 +++++++ .../tests/test_pytester_integration.py | 410 ++++++++ .../tests/test_same_origin.py | 45 + .../tests/test_upload_evidence.py | 57 + .../tests/test_video_default.py | 63 ++ plugins/ndf/skills/playwright-kit-ops/uv.lock | 809 +++++++++++++++ plugins/ndf/skills/playwright-report/SKILL.md | 55 + .../skills/playwright-scenario-test/SKILL.md | 68 ++ .../playwright-script-creation/SKILL.md | 108 ++ .../skills/playwright-test-planning/SKILL.md | 97 ++ .../docs/01-methodology.md | 148 +++ .../docs/02-page-roles.md | 207 ++++ .../docs/03-test-techniques.md | 284 +++++ .../docs/04-playwright-mapping.md | 249 +++++ .../docs/05-bug-report.md | 207 ++++ .../docs/06-pytest-playwright.md | 248 +++++ .../playwright-test-planning/docs/README.md | 79 ++ .../docs/checklists/checklist-auth.md | 148 +++ .../checklists/checklist-cart-checkout.md | 158 +++ .../docs/checklists/checklist-common.md | 129 +++ .../docs/checklists/checklist-dashboard.md | 124 +++ .../docs/checklists/checklist-edit.md | 156 +++ .../docs/checklists/checklist-form.md | 160 +++ .../docs/checklists/checklist-item.md | 120 +++ .../docs/checklists/checklist-list.md | 138 +++ .../docs/checklists/checklist-lp.md | 97 ++ .../docs/checklists/checklist-modal-wizard.md | 161 +++ .../docs/checklists/checklist-search.md | 134 +++ .../qa-security-scan/01-owasp-checklist.md | 298 ++++++ .../qa-security-scan/02-auth-checklist.md | 127 +++ .../qa-security-scan/03-report-template.md | 119 +++ plugins/ndf/skills/qa-security-scan/SKILL.md | 56 + plugins/ndf/skills/skill-stats/SKILL.md | 99 ++ .../skills/skill-stats/scripts/skill-stats.py | 512 +++++++++ scripts/build-runtime-plugins.sh | 27 +- 129 files changed, 23793 insertions(+), 6 deletions(-) create mode 100644 plugins/ndf/skills/data-analyst-export/01-formats.md create mode 100644 plugins/ndf/skills/data-analyst-export/02-examples.md create mode 100644 plugins/ndf/skills/data-analyst-export/SKILL.md create mode 100755 plugins/ndf/skills/data-analyst-export/scripts/export-csv.js create mode 100644 plugins/ndf/skills/data-analyst-sql-optimization/01-patterns.md create mode 100644 plugins/ndf/skills/data-analyst-sql-optimization/02-examples.md create mode 100644 plugins/ndf/skills/data-analyst-sql-optimization/SKILL.md create mode 100644 plugins/ndf/skills/deepwiki-transfer/SKILL.md create mode 100644 plugins/ndf/skills/deepwiki-transfer/scripts/fetch_wiki.py create mode 100644 plugins/ndf/skills/deepwiki-transfer/scripts/pyproject.toml create mode 100644 plugins/ndf/skills/deepwiki-transfer/scripts/split_pages.py create mode 100644 plugins/ndf/skills/deepwiki-transfer/scripts/validate_gfm.py create mode 100644 plugins/ndf/skills/gemini/SKILL.md create mode 100644 plugins/ndf/skills/google-auth/SKILL.md create mode 100644 plugins/ndf/skills/google-auth/pyproject.toml create mode 100644 plugins/ndf/skills/google-auth/scripts/google_auth.py create mode 100644 plugins/ndf/skills/google-auth/uv.lock create mode 100644 plugins/ndf/skills/google-chat/SKILL.md create mode 100644 plugins/ndf/skills/google-chat/pyproject.toml create mode 100644 plugins/ndf/skills/google-chat/scripts/gchat_read.py create mode 100644 plugins/ndf/skills/google-chat/uv.lock create mode 100644 plugins/ndf/skills/google-drive/SKILL.md create mode 100644 plugins/ndf/skills/google-drive/pyproject.toml create mode 100644 plugins/ndf/skills/google-drive/scripts/gdrive_fetch.py create mode 100644 plugins/ndf/skills/google-drive/uv.lock create mode 100644 plugins/ndf/skills/knowledge-reorg/SKILL.md create mode 100644 plugins/ndf/skills/mcp-builder/LICENSE.txt create mode 100644 plugins/ndf/skills/mcp-builder/SKILL.md create mode 100644 plugins/ndf/skills/mcp-builder/reference/evaluation.md create mode 100644 plugins/ndf/skills/mcp-builder/reference/mcp_best_practices.md create mode 100644 plugins/ndf/skills/mcp-builder/reference/node_mcp_server.md create mode 100644 plugins/ndf/skills/mcp-builder/reference/python_mcp_server.md create mode 100644 plugins/ndf/skills/mcp-builder/scripts/connections.py create mode 100644 plugins/ndf/skills/mcp-builder/scripts/evaluation.py create mode 100644 plugins/ndf/skills/mcp-builder/scripts/example_evaluation.xml create mode 100644 plugins/ndf/skills/mcp-builder/scripts/requirements.txt create mode 100644 plugins/ndf/skills/ml-model-structure/SKILL.md create mode 100644 plugins/ndf/skills/ml-model-structure/references/inference-and-contract.md create mode 100644 plugins/ndf/skills/official-skills-autoloader/SKILL.md create mode 100644 plugins/ndf/skills/playwright-browser-connect/SKILL.md create mode 100755 plugins/ndf/skills/playwright-browser-connect/scripts/start-host-chrome.sh create mode 100644 plugins/ndf/skills/playwright-evidence-drive/SKILL.md create mode 100644 plugins/ndf/skills/playwright-execution/SKILL.md create mode 100644 plugins/ndf/skills/playwright-kit-ops/.gitignore create mode 100644 plugins/ndf/skills/playwright-kit-ops/SKILL.md create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/__init__.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/accessibility.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/body_check.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/config.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/overlay.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_report.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/video.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/playwright_kit/web_vitals.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/pyproject.toml create mode 100644 plugins/ndf/skills/playwright-kit-ops/scripts/_drive_auth.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/scripts/check_cwv.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/scripts/classify_page_role.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/scripts/init_project.bat create mode 100755 plugins/ndf/skills/playwright-kit-ops/scripts/init_project.sh create mode 100644 plugins/ndf/skills/playwright-kit-ops/scripts/record_scenario.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/scripts/run_a11y_scan.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/scripts/upload_evidence.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/templates/conftest.py.template create mode 100644 plugins/ndf/skills/playwright-kit-ops/templates/pyproject.toml.runtime create mode 100644 plugins/ndf/skills/playwright-kit-ops/templates/run.bat create mode 100755 plugins/ndf/skills/playwright-kit-ops/templates/run.sh create mode 100644 plugins/ndf/skills/playwright-kit-ops/templates/runtime-README.md create mode 100644 plugins/ndf/skills/playwright-kit-ops/templates/runtime-gitignore create mode 100644 plugins/ndf/skills/playwright-kit-ops/templates/scenario.config.yaml create mode 100644 plugins/ndf/skills/playwright-kit-ops/templates/test_auth.py.template create mode 100644 plugins/ndf/skills/playwright-kit-ops/templates/test_dashboard.py.template create mode 100644 plugins/ndf/skills/playwright-kit-ops/templates/test_form.py.template create mode 100644 plugins/ndf/skills/playwright-kit-ops/templates/test_list.py.template create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/__init__.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/conftest.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/test_auth_cache.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/test_body_check.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/test_config_basic_auth.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/test_evidence_fixture.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/test_makereport_user_properties.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_report.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/test_pytester_integration.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/test_same_origin.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/test_upload_evidence.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/tests/test_video_default.py create mode 100644 plugins/ndf/skills/playwright-kit-ops/uv.lock create mode 100644 plugins/ndf/skills/playwright-report/SKILL.md create mode 100644 plugins/ndf/skills/playwright-scenario-test/SKILL.md create mode 100644 plugins/ndf/skills/playwright-script-creation/SKILL.md create mode 100644 plugins/ndf/skills/playwright-test-planning/SKILL.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/01-methodology.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/02-page-roles.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/03-test-techniques.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/04-playwright-mapping.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/05-bug-report.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/06-pytest-playwright.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/README.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-auth.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-cart-checkout.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-common.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-dashboard.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-edit.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-form.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-item.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-list.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-lp.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-modal-wizard.md create mode 100644 plugins/ndf/skills/playwright-test-planning/docs/checklists/checklist-search.md create mode 100644 plugins/ndf/skills/qa-security-scan/01-owasp-checklist.md create mode 100644 plugins/ndf/skills/qa-security-scan/02-auth-checklist.md create mode 100644 plugins/ndf/skills/qa-security-scan/03-report-template.md create mode 100644 plugins/ndf/skills/qa-security-scan/SKILL.md create mode 100644 plugins/ndf/skills/skill-stats/SKILL.md create mode 100755 plugins/ndf/skills/skill-stats/scripts/skill-stats.py diff --git a/plugins/ndf/skills/data-analyst-export/01-formats.md b/plugins/ndf/skills/data-analyst-export/01-formats.md new file mode 100644 index 0000000..b30fc41 --- /dev/null +++ b/plugins/ndf/skills/data-analyst-export/01-formats.md @@ -0,0 +1,143 @@ +# エクスポート形式詳細 + +## CSV出力 + +### export-csv.js + +JSON配列をCSV形式に変換します。 + +**機能**: +- 自動ヘッダー生成 +- カスタムデリミタ(カンマ、タブ、セミコロン) +- 引用符エスケープ +- UTF-8 BOM対応(Excel互換) + +**使用例**: +```bash +# 基本的な使用 +node export-csv.js data.json output.csv + +# タブ区切り +node export-csv.js data.json output.tsv --delimiter="\t" + +# Excel互換(BOM付き) +node export-csv.js data.json output.csv --bom +``` + +**入力データ形式** (input.json): +```json +[ + {"id": 1, "name": "Product A", "price": 1000}, + {"id": 2, "name": "Product B", "price": 2000} +] +``` + +**出力** (output.csv): +```csv +id,name,price +1,Product A,1000 +2,Product B,2000 +``` + +## JSON出力 + +### export-json.js + +CSV/配列データをJSON形式に変換します。 + +**機能**: +- Pretty-print(整形出力) +- 圧縮出力 +- 配列またはオブジェクト形式 + +**使用例**: +```bash +# Pretty-print +node export-json.js data.csv output.json --pretty + +# 圧縮 +node export-json.js data.csv output.json --compact +``` + +## Excel出力 + +### export-excel.js + +JSON配列をExcelファイル(.xlsx)に変換します。 + +**機能**: +- 複数シート作成 +- ヘッダー書式設定 +- セルの書式設定(数値、通貨、日付) +- 列幅自動調整 + +**使用例**: +```bash +# 単一シート +node export-excel.js data.json output.xlsx + +# 複数シート(各シートのデータはdata.jsonに含む) +node export-excel.js multi-sheet-data.json output.xlsx +``` + +**multi-sheet-data.json の形式**: +```json +{ + "Sheet1": [ + {"id": 1, "name": "Item 1"} + ], + "Sheet2": [ + {"id": 2, "name": "Item 2"} + ] +} +``` + +## Markdownテーブル出力 + +### export-markdown.js + +JSON配列をMarkdownテーブルに変換します。 + +**機能**: +- GitHub Flavored Markdown形式 +- 列の自動整列 +- 見出し行の区切り + +**使用例**: +```bash +node export-markdown.js data.json output.md +``` + +**出力例**: +```markdown +| id | name | price | +|----|------|-------| +| 1 | Product A | 1000 | +| 2 | Product B | 2000 | +``` + +## トラブルシューティング + +### Q: Excelで日本語が文字化けする + +A: UTF-8 BOMを付与してください: +```bash +node export-csv.js data.json output.csv --bom +``` + +### Q: 大きなデータでメモリ不足 + +A: ストリーミング処理に変更: +```javascript +// ストリーミング版のスクリプトを使用 +node export-csv-stream.js large-data.json output.csv +``` + +### Q: Excelの行数制限(1048576行)を超える + +A: 複数ファイルに分割: +```javascript +// 100万行ごとに分割 +node export-excel.js data.json output --split 1000000 +// output-1.xlsx, output-2.xlsx, ... が生成される +``` diff --git a/plugins/ndf/skills/data-analyst-export/02-examples.md b/plugins/ndf/skills/data-analyst-export/02-examples.md new file mode 100644 index 0000000..3ec07ab --- /dev/null +++ b/plugins/ndf/skills/data-analyst-export/02-examples.md @@ -0,0 +1,116 @@ +# エクスポート実装例 + +## 例1: BigQueryクエリ結果をCSVにエクスポート + +```javascript +// クエリ実行(BigQuery MCP使用) +const results = await bigquery.query("SELECT * FROM dataset.table LIMIT 1000"); + +// CSVに変換 +const fs = require('fs'); +const exportCSV = require('./scripts/export-csv.js'); + +exportCSV(results, 'query-results.csv'); +console.log('✅ CSVにエクスポート完了: query-results.csv'); +``` + +## 例2: 分析結果をExcelレポートに出力 + +```javascript +// 複数の分析結果を取得 +const salesData = await bigquery.query("SELECT * FROM sales"); +const productData = await bigquery.query("SELECT * FROM products"); + +// 複数シートでExcel出力 +const data = { + "Sales": salesData, + "Products": productData +}; + +exportExcel(data, 'monthly-report.xlsx'); +console.log('✅ Excelレポート作成完了: monthly-report.xlsx'); +``` + +## 例3: ドキュメントにMarkdownテーブル挿入 + +```javascript +// クエリ結果を取得 +const topProducts = await bigquery.query( + "SELECT name, sales FROM products ORDER BY sales DESC LIMIT 10" +); + +// Markdownテーブルに変換 +const markdown = exportMarkdown(topProducts); + +// README.mdに挿入 +const fs = require('fs'); +const readme = fs.readFileSync('README.md', 'utf-8'); +const updatedReadme = readme.replace('', markdown); +fs.writeFileSync('README.md', updatedReadme); + +console.log('✅ README.mdにテーブルを挿入しました'); +``` + +## 例4: 日付付きファイル名でエクスポート + +```javascript +const today = new Date().toISOString().split('T')[0]; + +// 日付付きファイル名 +exportCSV(data, `sales-${today}.csv`); +exportExcel(data, `report-${today}.xlsx`); +``` + +## 例5: 大容量データの分割エクスポート + +```javascript +const CHUNK_SIZE = 100000; + +async function exportLargeData(query, outputPrefix) { + let offset = 0; + let fileIndex = 1; + let hasMore = true; + + while (hasMore) { + const results = await bigquery.query( + `${query} LIMIT ${CHUNK_SIZE} OFFSET ${offset}` + ); + + if (results.length === 0) { + hasMore = false; + break; + } + + const filename = `${outputPrefix}-${fileIndex}.csv`; + exportCSV(results, filename); + console.log(`✅ ${filename} 作成完了 (${results.length}行)`); + + offset += CHUNK_SIZE; + fileIndex++; + } + + console.log(`✅ 全${fileIndex - 1}ファイル作成完了`); +} + +// 使用 +await exportLargeData( + "SELECT * FROM large_table", + "output/large-data" +); +``` + +## 例6: フィルタ付きエクスポート + +```javascript +// 特定の条件でフィルタしてエクスポート +const results = await bigquery.query(` + SELECT * + FROM sales + WHERE date >= '2024-01-01' + AND region = 'APAC' +`); + +// ファイル名に条件を含める +exportCSV(results, 'sales-2024-apac.csv'); +exportJSON(results, 'sales-2024-apac.json', { pretty: true }); +``` diff --git a/plugins/ndf/skills/data-analyst-export/SKILL.md b/plugins/ndf/skills/data-analyst-export/SKILL.md new file mode 100644 index 0000000..59e6a2d --- /dev/null +++ b/plugins/ndf/skills/data-analyst-export/SKILL.md @@ -0,0 +1,64 @@ +--- +name: data-analyst-export +description: "Export analysis results to CSV, JSON, Excel, or Markdown." +when_to_use: "Use when saving analysis results to files. Triggers: 'export data', 'save results', 'output CSV', 'output JSON', 'output Excel', 'データ出力', '結果保存', 'エクスポート'" +allowed-tools: + - Write + - Bash +--- + +# Data Analyst Export Skill + +## 概要 + +data-analystエージェントがクエリ結果を様々な形式でエクスポートする際に使用します。CSV、JSON、Excel、Markdownテーブルなど、用途に応じた最適な形式で出力できます。 + +## クイックリファレンス + +### 形式別コマンド + +```bash +# CSV出力 +node scripts/export-csv.js input.json output.csv + +# JSON出力 +node scripts/export-json.js input.csv output.json --pretty + +# Excel出力 +node scripts/export-excel.js input.json output.xlsx + +# Markdownテーブル出力 +node scripts/export-markdown.js input.json output.md +``` + +### 形式選択ガイド + +| 形式 | 用途 | +|------|------| +| CSV | 単純なデータ、他システム連携 | +| JSON | API連携、構造化データ | +| Excel | 複雑なレポート、書式設定 | +| Markdown | ドキュメント埋め込み | + +## ベストプラクティス + +| DO | DON'T | +|----|-------| +| 適切な形式を選択 | 全データをメモリに展開 | +| ヘッダーを含める | Excel形式で巨大データ(100万行制限) | +| 大きなデータはストリーミング | 日付フォーマットの不統一 | +| UTF-8 BOMを付与(Excel用CSV) | 特殊文字のエスケープ忘れ | + +## 詳細ガイド + +| ファイル | 内容 | +|---------|------| +| `01-formats.md` | 各形式のスクリプト詳細、オプション | +| `02-examples.md` | BigQuery結果のCSVエクスポート、Excelレポート作成の実例 | + +## 関連リソース + +- **scripts/export-csv.js**: CSV出力スクリプト +- **scripts/export-json.js**: JSON出力スクリプト +- **scripts/export-excel.js**: Excel出力スクリプト +- **scripts/export-markdown.js**: Markdownテーブル出力スクリプト diff --git a/plugins/ndf/skills/data-analyst-export/scripts/export-csv.js b/plugins/ndf/skills/data-analyst-export/scripts/export-csv.js new file mode 100755 index 0000000..2a284bc --- /dev/null +++ b/plugins/ndf/skills/data-analyst-export/scripts/export-csv.js @@ -0,0 +1,151 @@ +#!/usr/bin/env node + +/** + * JSON配列をCSV形式に変換するスクリプト + * + * 使用方法: + * node export-csv.js input.json output.csv [options] + * + * オプション: + * --delimiter="," : デリミタ(デフォルト: カンマ) + * --bom : UTF-8 BOMを付与(Excel互換) + */ + +const fs = require('fs'); + +/** + * JSON配列をCSV文字列に変換 + */ +function jsonToCSV(data, options = {}) { + if (!Array.isArray(data) || data.length === 0) { + throw new Error('データは空でない配列である必要があります'); + } + + const delimiter = options.delimiter || ','; + const includeHeaders = options.includeHeaders !== false; + + // ヘッダー行を生成 + const headers = Object.keys(data[0]); + const headerLine = headers.map(h => escapeCSVValue(h, delimiter)).join(delimiter); + + // データ行を生成 + const dataLines = data.map(row => { + return headers.map(header => { + const value = row[header]; + return escapeCSVValue(value, delimiter); + }).join(delimiter); + }); + + // ヘッダーとデータを結合 + const lines = includeHeaders ? [headerLine, ...dataLines] : dataLines; + return lines.join('\n'); +} + +/** + * CSV値をエスケープ + */ +function escapeCSVValue(value, delimiter) { + if (value === null || value === undefined) { + return ''; + } + + const stringValue = String(value); + + // デリミタ、改行、ダブルクォートを含む場合はクォートで囲む + const needsQuoting = stringValue.includes(delimiter) || + stringValue.includes('\n') || + stringValue.includes('\r') || + stringValue.includes('"'); + + if (needsQuoting) { + // ダブルクォートをエスケープ(""に変換) + const escapedValue = stringValue.replace(/"/g, '""'); + return `"${escapedValue}"`; + } + + return stringValue; +} + +/** + * CSVファイルに書き込み + */ +function writeCSV(csv, outputPath, options = {}) { + let content = csv; + + // UTF-8 BOMを付与(Excel互換) + if (options.bom) { + content = '\uFEFF' + content; + } + + fs.writeFileSync(outputPath, content, 'utf-8'); +} + +/** + * メイン処理 + */ +function main() { + const args = process.argv.slice(2); + + if (args.length < 2) { + console.error('使用方法: node export-csv.js [options]'); + console.error(''); + console.error('オプション:'); + console.error(' --delimiter="," : デリミタ(デフォルト: カンマ)'); + console.error(' --bom : UTF-8 BOMを付与(Excel互換)'); + console.error(''); + console.error('例:'); + console.error(' node export-csv.js data.json output.csv'); + console.error(' node export-csv.js data.json output.tsv --delimiter="\\t"'); + console.error(' node export-csv.js data.json output.csv --bom'); + process.exit(1); + } + + const inputPath = args[0]; + const outputPath = args[1]; + + // オプションを解析 + const options = {}; + for (let i = 2; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith('--delimiter=')) { + options.delimiter = arg.split('=')[1]; + // エスケープシーケンスを処理 + options.delimiter = options.delimiter.replace(/\\t/g, '\t'); + } else if (arg === '--bom') { + options.bom = true; + } + } + + try { + // JSONファイルを読み込み + console.log(`📖 JSONファイルを読み込み中: ${inputPath}`); + const jsonData = fs.readFileSync(inputPath, 'utf-8'); + const data = JSON.parse(jsonData); + + // CSVに変換 + console.log('🔄 CSVに変換中...'); + const csv = jsonToCSV(data, options); + + // ファイルに書き込み + console.log(`💾 CSVファイルを保存中: ${outputPath}`); + writeCSV(csv, outputPath, options); + + console.log(''); + console.log('✅ CSVエクスポート完了'); + console.log(` 入力: ${inputPath}`); + console.log(` 出力: ${outputPath}`); + console.log(` 行数: ${data.length}行`); + console.log(` 列数: ${Object.keys(data[0] || {}).length}列`); + + } catch (error) { + console.error('❌ エラーが発生しました:', error.message); + process.exit(1); + } +} + +// モジュールとしてエクスポート(他のスクリプトから使用可能) +if (require.main === module) { + main(); +} else { + module.exports = { jsonToCSV, escapeCSVValue, writeCSV }; +} diff --git a/plugins/ndf/skills/data-analyst-sql-optimization/01-patterns.md b/plugins/ndf/skills/data-analyst-sql-optimization/01-patterns.md new file mode 100644 index 0000000..f179b8b --- /dev/null +++ b/plugins/ndf/skills/data-analyst-sql-optimization/01-patterns.md @@ -0,0 +1,145 @@ +# SQL最適化パターン詳細 + +## 1. N+1クエリ削減 + +**問題**: ループ内で繰り返しSELECT文を実行 + +**解決**: JOINまたはサブクエリで1回のクエリに統合 + +```sql +-- ❌ Bad: ループで実行(N+1クエリ) +SELECT * FROM users WHERE id = ?; -- N回実行 + +-- ✅ Good: 1回のクエリで取得 +SELECT u.*, o.order_count +FROM users u +LEFT JOIN ( + SELECT user_id, COUNT(*) as order_count + FROM orders + GROUP BY user_id +) o ON u.id = o.user_id; +``` + +## 2. インデックス活用 + +**問題**: WHERE句の列にインデックスがない + +**解決**: 適切なインデックスを作成 + +```sql +-- インデックス作成 +CREATE INDEX idx_orders_status_created ON orders(status, created_at); + +-- ✅ インデックス効率化のためWHERE句の順序を調整 +SELECT * FROM orders +WHERE status = 'completed' +AND created_at > '2023-01-01'; +``` + +**インデックス作成の指針**: +- WHERE句で頻繁に使用される列 +- JOIN条件の列 +- ORDER BY句の列 +- カーディナリティ(値の多様性)が高い列 + +## 3. JOIN最適化 + +**問題**: 不要な大規模テーブルのJOIN + +**解決**: 必要な列のみ取得、結合順序の最適化 + +```sql +-- ❌ Bad: SELECT * +SELECT * FROM orders o +JOIN products p ON o.product_id = p.id +JOIN users u ON o.user_id = u.id; + +-- ✅ Good: 必要な列のみ +SELECT o.id, o.total, p.name, u.email +FROM orders o +JOIN products p ON o.product_id = p.id +JOIN users u ON o.user_id = u.id; +``` + +## 4. ウィンドウ関数活用 + +**問題**: 複雑なサブクエリの入れ子 + +**解決**: ROW_NUMBER(), RANK()等のウィンドウ関数を使用 + +```sql +-- ❌ Bad: サブクエリの入れ子 +SELECT u.name, + (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) as order_count, + (SELECT SUM(total) FROM orders o WHERE o.user_id = u.id) as order_total +FROM users u; + +-- ✅ Good: ウィンドウ関数で1回のスキャン +SELECT DISTINCT u.name, + COUNT(o.id) OVER (PARTITION BY u.id) as order_count, + SUM(o.total) OVER (PARTITION BY u.id) as order_total +FROM users u +LEFT JOIN orders o ON u.id = o.user_id; +``` + +## 5. EXISTS vs IN + +**問題**: サブクエリでINを使用 + +**解決**: EXISTSに変更(多くの場合高速) + +```sql +-- ❌ 遅い場合がある +SELECT * FROM users +WHERE id IN (SELECT user_id FROM orders WHERE total > 1000); + +-- ✅ 通常より高速 +SELECT * FROM users u +WHERE EXISTS ( + SELECT 1 FROM orders o + WHERE o.user_id = u.id AND o.total > 1000 +); +``` + +## 6. LIMIT活用 + +**問題**: 全件取得後にアプリ側でフィルタ + +**解決**: SQLでLIMIT/OFFSETを使用 + +```sql +-- ✅ ページング +SELECT * FROM orders +ORDER BY created_at DESC +LIMIT 20 OFFSET 0; -- 1ページ目 +``` + +## 7. 計算列のインデックス + +**問題**: WHERE句で関数を列に適用 + +**解決**: 計算済み列を作成してインデックス + +```sql +-- ❌ Bad: 関数適用でインデックス無効 +SELECT * FROM users WHERE YEAR(created_at) = 2024; + +-- ✅ Good: 範囲検索でインデックス有効 +SELECT * FROM users +WHERE created_at >= '2024-01-01' +AND created_at < '2025-01-01'; +``` + +## パフォーマンス測定 + +### 改善前後の比較 + +1. **実行時間測定** +2. **スキャンバイト数確認**(BigQuery) +3. **実行計画比較**: `EXPLAIN SELECT ...;` + +### 目標指標 + +- **実行時間**: 50%以上削減 +- **スキャンバイト数**: 70%以上削減 +- **インデックス使用**: EXPLAINでtype=ref以上 diff --git a/plugins/ndf/skills/data-analyst-sql-optimization/02-examples.md b/plugins/ndf/skills/data-analyst-sql-optimization/02-examples.md new file mode 100644 index 0000000..ddfc636 --- /dev/null +++ b/plugins/ndf/skills/data-analyst-sql-optimization/02-examples.md @@ -0,0 +1,136 @@ +# SQL最適化 Before/After実例集 + +## 例1: N+1クエリの削減 + +**Before**: +```sql +-- ループで実行(N+1クエリ) +SELECT * FROM users WHERE id = ?; -- N回実行 +``` + +**After**: +```sql +-- 1回のクエリで取得 +SELECT u.*, o.order_count +FROM users u +LEFT JOIN ( + SELECT user_id, COUNT(*) as order_count + FROM orders + GROUP BY user_id +) o ON u.id = o.user_id; +``` + +**改善**: N+1回 → 1回のクエリ、大幅な高速化 + +## 例2: インデックス活用 + +**Before**: +```sql +SELECT * FROM orders +WHERE created_at > '2023-01-01' +AND status = 'completed'; +-- インデックスなし、フルスキャン +``` + +**After**: +```sql +-- インデックス作成 +CREATE INDEX idx_orders_status_created ON orders(status, created_at); + +-- 同じクエリがインデックスを使用 +SELECT * FROM orders +WHERE status = 'completed' +AND created_at > '2023-01-01'; +-- ORDER BY の順序を逆にしてインデックス効率化 +``` + +**改善**: フルスキャン → インデックススキャン、10倍以上高速化 + +## 例3: ウィンドウ関数活用 + +**Before**: +```sql +-- サブクエリの入れ子 +SELECT u.name, + (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) as order_count, + (SELECT SUM(total) FROM orders o WHERE o.user_id = u.id) as order_total +FROM users u; +-- usersの各行でordersを2回スキャン +``` + +**After**: +```sql +-- ウィンドウ関数で1回のスキャン +SELECT u.name, + COUNT(o.id) OVER (PARTITION BY u.id) as order_count, + SUM(o.total) OVER (PARTITION BY u.id) as order_total +FROM users u +LEFT JOIN orders o ON u.id = o.user_id; +-- 1回のJOINで完結 +``` + +**改善**: 2N回スキャン → 1回のJOIN、大幅な高速化 + +## 例4: 不要なDISTINCTの削除 + +**Before**: +```sql +SELECT DISTINCT u.id, u.name +FROM users u +JOIN orders o ON u.id = o.user_id; +-- DISTINCTで重複排除(コスト高) +``` + +**After**: +```sql +SELECT u.id, u.name +FROM users u +WHERE EXISTS ( + SELECT 1 FROM orders o WHERE o.user_id = u.id +); +-- DISTINCTなしで同じ結果 +``` + +## 例5: BigQuery向け最適化 + +**Before**: +```sql +SELECT * +FROM `project.dataset.large_table` +WHERE DATE(timestamp) = '2024-01-15'; +-- フルスキャン(高コスト) +``` + +**After**: +```sql +SELECT * +FROM `project.dataset.large_table` +WHERE timestamp >= '2024-01-15' +AND timestamp < '2024-01-16'; +-- パーティション利用(低コスト) +``` + +**改善**: パーティションプルーニングで90%以上のコスト削減 + +## トラブルシューティング + +### Q: 最適化したのに遅い + +A: 以下を確認: +- インデックスが実際に使用されているか(EXPLAIN確認) +- 統計情報が最新か(ANALYZE TABLE実行) +- データ量が想定通りか + +### Q: どのパターンを適用すべきか分からない + +A: 以下の順で確認: +1. EXPLAINで実行計画を確認 +2. フルスキャンがあればインデックス作成 +3. N+1パターンがあればJOINに統合 +4. サブクエリが複雑ならウィンドウ関数検討 + +### Q: インデックスを作成したら書き込みが遅くなった + +A: インデックスの見直しが必要: +- 使用頻度の低いインデックスを削除 +- 複合インデックスで統合できないか検討 diff --git a/plugins/ndf/skills/data-analyst-sql-optimization/SKILL.md b/plugins/ndf/skills/data-analyst-sql-optimization/SKILL.md new file mode 100644 index 0000000..740130c --- /dev/null +++ b/plugins/ndf/skills/data-analyst-sql-optimization/SKILL.md @@ -0,0 +1,48 @@ +--- +name: data-analyst-sql-optimization +description: "Optimize SQL queries and slow database workloads." +when_to_use: "Use when improving query performance or analyzing slow queries. Triggers: 'optimize SQL', 'slow query', 'improve performance', 'SQL最適化', 'クエリ改善', 'パフォーマンス向上'" +--- + +# Data Analyst SQL Optimization Skill + +## 概要 + +data-analystエージェントがSQLクエリのパフォーマンスを改善する際に使用します。実績のある最適化パターンとベストプラクティスを提供します。 + +## クイックリファレンス + +### 最適化パターン一覧 + +| # | パターン | 問題 | 解決策 | +|---|---------|------|--------| +| 1 | N+1削減 | ループ内SQL | JOINで1回に統合 | +| 2 | インデックス | フルスキャン | WHERE/JOIN列にインデックス | +| 3 | JOIN最適化 | 不要な大規模JOIN | 必要な列のみ取得 | +| 4 | ウィンドウ関数 | 複雑なサブクエリ | ROW_NUMBER(), RANK()使用 | +| 5 | EXISTS vs IN | 遅いIN句 | EXISTSに変更 | +| 6 | LIMIT活用 | 全件取得 | SQLでページング | + +### 基本的な使い方 + +1. 遅いクエリを特定 +2. EXPLAINで実行計画を確認 +3. 該当する最適化パターンを適用 +4. 再度EXPLAINで改善を確認 + +## ベストプラクティス + +| DO | DON'T | +|----|-------| +| EXPLAINで実行計画を確認 | 不要なDISTINCT | +| インデックスは選択的に作成 | 関数をWHERE句の列に適用 | +| 必要な列のみSELECT | 過剰なJOIN | +| 早期フィルタリング | サブクエリの多用 | +| 統計情報を更新 | インデックスの作り過ぎ | + +## 詳細ガイド + +| ファイル | 内容 | +|---------|------| +| `01-patterns.md` | 各最適化パターンの詳細説明 | +| `02-examples.md` | Before/After実例集 | diff --git a/plugins/ndf/skills/deepwiki-transfer/SKILL.md b/plugins/ndf/skills/deepwiki-transfer/SKILL.md new file mode 100644 index 0000000..c2933b7 --- /dev/null +++ b/plugins/ndf/skills/deepwiki-transfer/SKILL.md @@ -0,0 +1,144 @@ +--- +name: deepwiki-transfer +description: "Transfer DeepWiki content into Markdown docs." +when_to_use: "DeepWiki から Markdown としてコンテンツを取得・転載したいとき。Triggers: 'deepwiki transfer', 'deepwiki転載', 'wiki転載', 'リポジトリドキュメント取得', 'DeepWikiからMarkdown', 'transfer wiki contents'" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Write + - Edit + - Task +--- + +# DeepWiki コンテンツ転載スキル + +DeepWikiのドキュメントをリポジトリ内のディレクトリに1ページ1ファイルのMarkdownとして転載し、PRを作成する。 + +## 必要な入力 + +- **対象リポジトリ**: `owner/repo` 形式(例: `facebook/react`) +- **出力先ディレクトリ**: リポジトリ内のパス(例: `deepWiki/`) +- **出力言語**: ファイル名および内容の出力言語(デフォルト: 日本語) +- **ベースブランチ**: PRのマージ先(デフォルト: `main`) + +## スクリプトのパス + +本スキルのスクリプトは `$CLAUDE_PLUGIN_ROOT/skills/deepwiki-transfer/scripts/` に配置されている。 + +```bash +# スクリプトディレクトリの確認 +SCRIPT_DIR="$CLAUDE_PLUGIN_ROOT/skills/deepwiki-transfer/scripts" +ls "$SCRIPT_DIR" +``` + +## 処理手順 + +### Phase A: DeepWikiコンテンツの取得とファイル化 + +`scripts/fetch_wiki.py` を使って MCP サーバーに直接HTTPリクエストを送信し、全コンテンツを一時ファイルに保存する。LLMコンテキストを経由しないため、130万文字超のレスポンスも完全に保存できる。 + +```bash +# 公開リポジトリ(認証不要) +python "$SCRIPT_DIR/fetch_wiki.py" \ + --repo owner/repo \ + --output /tmp/deepwiki_raw.md \ + --public + +# プライベートリポジトリ(Devin APIキー必要) +python "$SCRIPT_DIR/fetch_wiki.py" \ + --repo owner/repo \ + --output /tmp/deepwiki_raw.md + +# Wiki構造も取得(セクション順序の決定に使用) +python "$SCRIPT_DIR/fetch_wiki.py" \ + --repo owner/repo \ + --output /tmp/deepwiki_structure.md \ + --tool read_wiki_structure \ + --public +``` + +プライベートリポジトリの場合、環境変数 `DEVIN_API_KEY` にDevin APIキーを設定しておくこと。 + +### Phase B: 機械的ファイル分割&リネーム + +`scripts/split_pages.py` を使い、一時ファイルを `# Page:` マーカーで分割し、セクション番号prefix付きファイル名で出力する。 + +```bash +# まず dry-run で確認 +python "$SCRIPT_DIR/split_pages.py" \ + /tmp/deepwiki_raw.md ./deepWiki/ \ + --structure /tmp/deepwiki_structure.md \ + --dry-run + +# 問題なければ実行 +python "$SCRIPT_DIR/split_pages.py" \ + /tmp/deepwiki_raw.md ./deepWiki/ \ + --structure /tmp/deepwiki_structure.md +``` + +ファイル名の命名規則: +- トップレベル: `XX_Title.md`(例: `01_Overview.md`) +- サブセクション: `XX_Y_Title.md`(例: `01_1_System_Architecture.md`) +- ソート順がセクション順と一致すること + +**この時点では内容を一切変更しない。** + +### Phase C: 1ページずつGFM形式変換 + +`scripts/validate_gfm.py` で自動修正した後、各ファイルを1ページずつ確認・修正する。 + +```bash +# 自動修正 +python "$SCRIPT_DIR/validate_gfm.py" ./deepWiki/ --verbose + +# 検証のみ +python "$SCRIPT_DIR/validate_gfm.py" ./deepWiki/ --check-only +``` + +自動修正の範囲: +- コードブロックの言語指定(``` → ```mermaid, ```php, ```sql 等) +- 見出し前後の空行挿入 +- 末尾改行の統一 + +自動修正後、各ファイルを1ページずつ開いて以下を確認: +- コードブロックの言語指定が正しいか(自動推定が誤っている場合は手動修正) +- テーブルのパイプ区切りやアライメント行が正しいか +- リンクや画像参照の構文が正しいか +- 見出しの階層が適切か + +**内容の意味を変えず、GFM上の記法修正のみ行う。言語変換はこのフェーズでは行わない。** + +### Phase D: 1ページずつ言語変換(出力言語が原文と異なる場合のみ) + +ユーザー指定の出力言語が原文の言語と異なる場合、各ファイルを1ページずつ処理する: + +1. **ファイル名**: タイトル部分を指定言語に翻訳してリネーム(prefixの `XX_` / `XX_Y_` は変更しない) +2. **ファイル内容**: 本文を指定言語に翻訳する。コードブロック内のコード、コマンド、変数名等は翻訳しない + +### Phase E: バリデーションとPR作成 + +1. 出力ディレクトリの `ls` でファイル一覧を表示し、ファイル数とセクション番号の整合性を確認 +2. 数件をランダムに選び、DeepWiki原文と照合して内容が一致していることを確認 +3. 変更をコミットしてプッシュ +4. PRを作成する。`.github/pull_request_template.md` がある場合はそのテンプレートに沿ってPR説明を作成 + +## 前提条件 + +- `requests` ライブラリ(`pip install requests`) +- 公開リポジトリ: 認証不要 +- プライベートリポジトリ: 環境変数 `DEVIN_API_KEY` にDevin APIキーを設定 + +## 禁止事項 + +- **Phase B完了時点では原文を一字一句そのまま保持すること。要約・省略・改変してはならない** +- Phase B・Cの時点では翻訳してはならない。言語変換はPhase Dでのみ行う +- 原文の内容を「理解して書き直す」のではなく、スクリプトで機械的に処理すること +- 出力先ディレクトリ以外のファイルを変更してはならない + +## 注意事項 + +- DeepWikiのコンテンツは通常**英語**で生成される。日本語出力が指定されている場合、Phase Dで翻訳する +- セクション番号が10以上でも0埋め2桁にすることでソート順を維持(`01_`, `02_`, ..., `10_`) +- `fetch_wiki.py` は MCP サーバーに直接HTTPリクエストを送信するため、`requests` ライブラリが必要 +- 既存ファイルがある場合は内容を比較し、差分がある部分のみ更新する diff --git a/plugins/ndf/skills/deepwiki-transfer/scripts/fetch_wiki.py b/plugins/ndf/skills/deepwiki-transfer/scripts/fetch_wiki.py new file mode 100644 index 0000000..17c2a83 --- /dev/null +++ b/plugins/ndf/skills/deepwiki-transfer/scripts/fetch_wiki.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +DeepWiki MCP から read_wiki_contents を直接HTTP呼び出しし、 +レスポンス全体をファイルに保存するスクリプト。 + +LLMコンテキストを経由せず、MCPサーバーに直接HTTPリクエストを送信して +全コンテンツをファイル化する。 + +使用方法: + # プライベートリポジトリ(Devin MCP、APIキー必要) + python fetch_wiki.py --repo owner/repo --output /tmp/deepwiki_raw.md --api-key YOUR_KEY + + # 環境変数 DEVIN_API_KEY 利用 + DEVIN_API_KEY=xxx python fetch_wiki.py --repo owner/repo --output /tmp/deepwiki_raw.md + + # 公開リポジトリ(DeepWiki MCP、認証不要) + python fetch_wiki.py --repo facebook/react --output /tmp/deepwiki_raw.md --public + + # Wiki構造のみ取得 + python fetch_wiki.py --repo owner/repo --output /tmp/deepwiki_structure.md --tool read_wiki_structure +""" + +import argparse +import json +import os +import sys +import uuid + +try: + import requests +except ImportError: + print("エラー: requests ライブラリが必要です。pip install requests を実行してください。", file=sys.stderr) + sys.exit(1) + +# MCPサーバーのエンドポイント +DEVIN_MCP_URL = "https://mcp.devin.ai/mcp" +DEEPWIKI_MCP_URL = "https://mcp.deepwiki.com/mcp" + + +def create_jsonrpc_request(method: str, params: dict, request_id: int) -> dict: + """JSON-RPC 2.0 リクエストを生成する""" + return { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + } + + +def parse_sse_json(resp: requests.Response) -> dict: + """SSE(Server-Sent Events)形式のレスポンスからJSON-RPCメッセージを抽出する""" + content_type = resp.headers.get("Content-Type", "") + if "text/event-stream" in content_type: + # SSE形式: "event: message\ndata: {...}\n\n" をパースする + for line in resp.text.split("\n"): + line = line.strip() + if line.startswith("data: "): + data_str = line[6:] + return json.loads(data_str) + raise RuntimeError("SSEレスポンスにdataフィールドが見つかりません") + else: + return resp.json() + + +def mcp_initialize(session: requests.Session, url: str) -> str | None: + """MCPサーバーとの初期化ハンドシェイクを行い、セッションIDを取得する""" + init_request = create_jsonrpc_request( + "initialize", + { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "deepwiki-fetch", "version": "1.0.0"}, + }, + 1, + ) + resp = session.post(url, json=init_request) + resp.raise_for_status() + + # SSE形式のレスポンスをパース + parse_sse_json(resp) + + # セッションIDをヘッダーから取得 + session_id = resp.headers.get("Mcp-Session-Id") + + # initialized 通知を送信 + notification = { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + } + headers = {} + if session_id: + headers["Mcp-Session-Id"] = session_id + session.post(url, json=notification, headers=headers) + + return session_id + + +def mcp_call_tool( + session: requests.Session, + url: str, + tool_name: str, + arguments: dict, + session_id: str | None, +) -> str: + """MCPツールを呼び出し、テキスト結果を返す""" + call_request = create_jsonrpc_request( + "tools/call", + {"name": tool_name, "arguments": arguments}, + 2, + ) + headers = {} + if session_id: + headers["Mcp-Session-Id"] = session_id + + resp = session.post(url, json=call_request, headers=headers, timeout=300) + resp.raise_for_status() + + result = parse_sse_json(resp) + + if "error" in result: + error = result["error"] + raise RuntimeError(f"MCPエラー: [{error.get('code')}] {error.get('message')}") + + # レスポンスからテキストコンテンツを抽出 + content_parts = result.get("result", {}).get("content", []) + texts = [] + for part in content_parts: + if part.get("type") == "text": + texts.append(part["text"]) + + if not texts: + raise RuntimeError("MCPレスポンスにテキストコンテンツが含まれていません") + + return "\n".join(texts) + + +def main(): + parser = argparse.ArgumentParser( + description="DeepWiki MCPからWikiコンテンツを取得してファイルに保存する" + ) + parser.add_argument( + "--repo", + required=True, + help="対象リポジトリ(owner/repo形式、例: volareinc/carmo-system-console)", + ) + parser.add_argument( + "--output", + required=True, + help="出力先ファイルパス(例: /tmp/deepwiki_raw.md)", + ) + parser.add_argument( + "--api-key", + default=None, + help="Devin APIキー(未指定時は環境変数 DEVIN_API_KEY を使用)", + ) + parser.add_argument( + "--public", + action="store_true", + help="公開リポジトリ用のDeepWiki MCPを使用(認証不要)", + ) + parser.add_argument( + "--tool", + default="read_wiki_contents", + choices=["read_wiki_contents", "read_wiki_structure"], + help="使用するMCPツール(デフォルト: read_wiki_contents)", + ) + args = parser.parse_args() + + # エンドポイントと認証の設定 + if args.public: + url = DEEPWIKI_MCP_URL + api_key = None + print(f"DeepWiki MCP(公開)を使用: {url}") + else: + url = DEVIN_MCP_URL + api_key = args.api_key or os.environ.get("DEVIN_API_KEY") + if not api_key: + print( + "エラー: --api-key または環境変数 DEVIN_API_KEY が必要です(公開リポジトリは --public を指定)", + file=sys.stderr, + ) + sys.exit(1) + print(f"Devin MCP(プライベート対応)を使用: {url}") + + # HTTPセッション作成 + session = requests.Session() + if api_key: + session.headers["Authorization"] = f"Bearer {api_key}" + session.headers["Content-Type"] = "application/json" + session.headers["Accept"] = "application/json, text/event-stream" + + try: + # 1. MCP初期化 + print("MCPサーバーに接続中...") + session_id = mcp_initialize(session, url) + print(f"接続完了(セッションID: {session_id or 'なし'})") + + # 2. ツール呼び出し + print(f"{args.tool} を呼び出し中(リポジトリ: {args.repo})...") + content = mcp_call_tool( + session, url, args.tool, {"repoName": args.repo}, session_id + ) + + # 3. ファイルに保存 + with open(args.output, "w", encoding="utf-8") as f: + f.write(content) + + file_size = os.path.getsize(args.output) + line_count = content.count("\n") + 1 + print(f"保存完了: {args.output}") + print(f" サイズ: {file_size:,} bytes") + print(f" 行数: {line_count:,}") + + # ページ数をカウント(read_wiki_contents の場合) + if args.tool == "read_wiki_contents": + page_count = content.count("# Page: ") + print(f" ページ数: {page_count}") + + except requests.exceptions.RequestException as e: + print(f"HTTP通信エラー: {e}", file=sys.stderr) + sys.exit(1) + except RuntimeError as e: + print(f"エラー: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf/skills/deepwiki-transfer/scripts/pyproject.toml b/plugins/ndf/skills/deepwiki-transfer/scripts/pyproject.toml new file mode 100644 index 0000000..9213b74 --- /dev/null +++ b/plugins/ndf/skills/deepwiki-transfer/scripts/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "deepwiki-transfer-scripts" +version = "1.0.0" +description = "DeepWiki MCP コンテンツ取得・分割・GFM検証スクリプト" +requires-python = ">=3.10" +dependencies = [ + "requests>=2.28.0", +] + +[project.scripts] +fetch-wiki = "fetch_wiki:main" +split-pages = "split_pages:main" +validate-gfm = "validate_gfm:main" diff --git a/plugins/ndf/skills/deepwiki-transfer/scripts/split_pages.py b/plugins/ndf/skills/deepwiki-transfer/scripts/split_pages.py new file mode 100644 index 0000000..a2ad498 --- /dev/null +++ b/plugins/ndf/skills/deepwiki-transfer/scripts/split_pages.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +""" +DeepWiki生データファイルを `# Page:` マーカーで機械的に分割し、 +セクション番号prefix付きのファイル名でリネームして出力するスクリプト。 + +分割時に内容は一切変更しない(`# Page: {タイトル}` → `# {タイトル}` の置換のみ)。 + +使用方法: + python split_pages.py /tmp/deepwiki_raw.md ./deepWiki/ + + # 構造確認のみ(ファイル出力なし) + python split_pages.py /tmp/deepwiki_raw.md ./deepWiki/ --dry-run + + # Wiki構造ファイルからセクション順序を指定 + python split_pages.py /tmp/deepwiki_raw.md ./deepWiki/ --structure /tmp/deepwiki_structure.md +""" + +import argparse +import os +import re +import sys + + +def sanitize_filename(title: str) -> str: + """タイトルをファイル名に変換する(スペース→アンダースコア、特殊文字除去)""" + name = title.strip() + name = name.replace(" ", "_") + name = name.replace("/", "_") + name = name.replace("\\", "_") + # ファイル名として安全な文字のみ残す(英数字、日本語、アンダースコア、ハイフン、ドット) + name = re.sub(r"[^\w\-.]", "", name, flags=re.UNICODE) + return name + + +def parse_raw_file(filepath: str) -> list[tuple[str, str]]: + """ + 生データファイルを読み込み、`# Page:` マーカーで分割する。 + + 戻り値: [(タイトル, 内容), ...] のリスト + 内容の先頭行は `# Page: {タイトル}` → `# {タイトル}` に置換済み + """ + with open(filepath, "r", encoding="utf-8") as f: + raw = f.read() + + # `# Page: ` で始まる行を境界として分割 + pages = [] + current_title = None + current_lines = [] + + for line in raw.splitlines(keepends=True): + if line.startswith("# Page: "): + # 前のページを保存 + if current_title is not None: + pages.append((current_title, "".join(current_lines))) + # 新しいページ開始 + current_title = line[len("# Page: "):].strip() + # `# Page: {タイトル}` → `# {タイトル}` に置換 + current_lines = [f"# {current_title}\n"] + else: + if current_title is not None: + current_lines.append(line) + + # 最後のページを保存 + if current_title is not None: + pages.append((current_title, "".join(current_lines))) + + return pages + + +def parse_structure_file(filepath: str) -> list[str]: + """ + Wiki構造ファイルからページタイトルの順序リストを取得する。 + read_wiki_structure の出力を解析する。 + """ + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + + titles = [] + for line in content.splitlines(): + line = line.strip() + if not line: + continue + # 番号付きリスト、ハイフンリスト、または単純なタイトル行を検出 + # 例: "1. Overview", "- Overview", " 1.1 System Architecture" + match = re.match(r"^[\d.\-\s]*\s*(.+)$", line) + if match: + title = match.group(1).strip() + if title and not title.startswith("#") and not title.startswith("```"): + titles.append(title) + + return titles + + +def strip_section_number(title: str) -> str: + """タイトルからセクション番号部分を除去する(ファイル名生成用)""" + # "1.1 System Architecture" → "System Architecture" + # "2. API Reference" → "API Reference" + # "Overview" → "Overview"(変更なし) + stripped = re.sub(r"^\d+\.\d+\.?\s+", "", title) + stripped = re.sub(r"^\d+\.?\s+", "", stripped) + return stripped if stripped else title + + +def assign_prefixes(pages: list[tuple[str, str]], structure_titles: list[str] | None = None) -> list[tuple[str, str, str]]: + """ + 各ページにセクション番号prefixを割り当てる。 + + structure_titles が指定されている場合、その順序に従う。 + 未指定の場合、ページの出現順に連番を振る。 + + 戻り値: [(prefix, タイトル, 内容), ...] のリスト + タイトルはセクション番号を除去済み(ファイル名生成用) + """ + # ページタイトルからセクション構造を推定 + # DeepWikiの構造: トップレベルページ → サブセクションページの順 + result = [] + section_num = 0 + subsection_counts = {} + + if structure_titles: + # 構造ファイルからの順序に基づいてprefix割り当て + title_to_content = {title: content for title, content in pages} + ordered_titles = [] + + # 構造ファイルのタイトルとページタイトルをマッチング + for struct_title in structure_titles: + for page_title, _ in pages: + if _titles_match(struct_title, page_title): + ordered_titles.append(page_title) + break + + # マッチしなかったページを末尾に追加 + matched = set(ordered_titles) + for page_title, _ in pages: + if page_title not in matched: + ordered_titles.append(page_title) + + pages_ordered = [(t, title_to_content.get(t, "")) for t in ordered_titles if t in title_to_content] + else: + pages_ordered = pages + + # セクション構造の推定とprefix割り当て + # DeepWikiの典型的な構造: + # - 最初のページはOverview(トップレベル) + # - その後はセクション番号付きのページが続く + # - サブセクションはメインセクションの直後に配置される + prev_section = 0 + section_map = {} # タイトル → (section, subsection) + + for title, content in pages_ordered: + # タイトルからセクション番号を推定 + # パターン: "1.1 Title", "1.1. Title", "Section 1: Title" など + subsection_match = re.match(r"^(\d+)\.(\d+)\.?\s", title) + section_match = re.match(r"^(\d+)\.?\s", title) + + if subsection_match: + sec = int(subsection_match.group(1)) + sub = int(subsection_match.group(2)) + prefix = f"{sec:02d}_{sub}_" + prev_section = sec + elif section_match: + sec = int(section_match.group(1)) + prefix = f"{sec:02d}_" + prev_section = sec + else: + # 番号なしのページ(Overviewなど) + section_num += 1 + # 既に番号付きセクションが出現している場合、連番を続ける + if prev_section > 0 and section_num <= prev_section: + section_num = prev_section + 1 + prefix = f"{section_num:02d}_" + prev_section = section_num + + # ファイル名用にセクション番号を除去したタイトルを使用 + clean_title = strip_section_number(title) + result.append((prefix, clean_title, content)) + + return result + + +def _titles_match(struct_title: str, page_title: str) -> bool: + """構造ファイルのタイトルとページタイトルが一致するか判定する""" + # 完全一致 + if struct_title == page_title: + return True + # 正規化して比較(スペース、ハイフン、アンダースコアの違いを無視) + normalize = lambda s: re.sub(r"[\s\-_]+", " ", s.lower().strip()) + return normalize(struct_title) == normalize(page_title) + + +def main(): + parser = argparse.ArgumentParser( + description="DeepWiki生データを # Page: マーカーでファイル分割する" + ) + parser.add_argument( + "input", + help="入力ファイルパス(fetch_wiki.py の出力ファイル)", + ) + parser.add_argument( + "output_dir", + help="出力先ディレクトリパス(例: ./deepWiki/)", + ) + parser.add_argument( + "--structure", + default=None, + help="Wiki構造ファイルパス(read_wiki_structure の出力、セクション順序の決定に使用)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="ファイル出力せず、分割結果のみ表示する", + ) + args = parser.parse_args() + + # 入力ファイル読み込み・分割 + if not os.path.exists(args.input): + print(f"エラー: 入力ファイルが見つかりません: {args.input}", file=sys.stderr) + sys.exit(1) + + print(f"入力ファイル読み込み中: {args.input}") + pages = parse_raw_file(args.input) + print(f"検出ページ数: {len(pages)}") + + if not pages: + print("エラー: ページが検出されませんでした。入力ファイルに `# Page:` マーカーが含まれているか確認してください。", file=sys.stderr) + sys.exit(1) + + # 構造ファイルがあればセクション順序を取得 + structure_titles = None + if args.structure: + if os.path.exists(args.structure): + structure_titles = parse_structure_file(args.structure) + print(f"構造ファイルから {len(structure_titles)} タイトルを読み込みました") + else: + print(f"警告: 構造ファイルが見つかりません: {args.structure}", file=sys.stderr) + + # prefix割り当て + prefixed_pages = assign_prefixes(pages, structure_titles) + + # 出力先ディレクトリ作成 + if not args.dry_run: + os.makedirs(args.output_dir, exist_ok=True) + + # ファイル出力 + print("\n--- 分割結果 ---") + for prefix, title, content in prefixed_pages: + filename = f"{prefix}{sanitize_filename(title)}.md" + filepath = os.path.join(args.output_dir, filename) + + content_size = len(content.encode("utf-8")) + line_count = content.count("\n") + print(f" {filename} ({content_size:,} bytes, {line_count} lines)") + + if not args.dry_run: + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + + print(f"\n合計: {len(prefixed_pages)} ファイル") + if args.dry_run: + print("(dry-runモード: ファイルは出力されていません)") + else: + print(f"出力先: {args.output_dir}") + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf/skills/deepwiki-transfer/scripts/validate_gfm.py b/plugins/ndf/skills/deepwiki-transfer/scripts/validate_gfm.py new file mode 100644 index 0000000..867257a --- /dev/null +++ b/plugins/ndf/skills/deepwiki-transfer/scripts/validate_gfm.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +""" +Markdownファイルを GitHub Flavored Markdown (GFM) Spec に準拠するよう検証・修正するスクリプト。 + +主な修正: + - 言語指定のないコードブロックに適切な言語を推定・付与 + - テーブルのパイプ区切りとアライメント行の修正 + - 見出し前後の空行挿入 + - 末尾改行の統一 + +使用方法: + # 単一ファイルを検証・修正 + python validate_gfm.py ./deepWiki/01_Overview.md + + # ディレクトリ内の全 .md ファイルを一括処理 + python validate_gfm.py ./deepWiki/ + + # 検証のみ(修正しない) + python validate_gfm.py ./deepWiki/ --check-only + + # 修正内容の詳細表示 + python validate_gfm.py ./deepWiki/ --verbose +""" + +import argparse +import glob +import os +import re +import sys + + +# コードブロックの言語推定パターン +LANGUAGE_PATTERNS = { + "mermaid": [ + re.compile(r"^\s*(graph\s+(TD|TB|BT|RL|LR)|flowchart\s+(TD|TB|BT|RL|LR)|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|gitGraph|journey)", re.MULTILINE), + ], + "php": [ + re.compile(r"<\?php"), + re.compile(r"\$this->"), + re.compile(r"(function|class|namespace|use)\s+\w+"), + re.compile(r"->(get|set|find|create|update|delete)\w*\("), + ], + "sql": [ + re.compile(r"^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|FROM|WHERE|JOIN|GROUP BY|ORDER BY)\s", re.MULTILINE | re.IGNORECASE), + ], + "json": [ + re.compile(r'^\s*\{[\s\S]*"[^"]+"\s*:', re.MULTILINE), + ], + "yaml": [ + re.compile(r"^\w+:\s*$", re.MULTILINE), + re.compile(r"^\s+-\s+\w+:", re.MULTILINE), + ], + "bash": [ + re.compile(r"^\s*(#!/bin/bash|#!/bin/sh|export\s|source\s|\$\(|apt-get|npm|pip|composer|docker|kubectl)", re.MULTILINE), + re.compile(r"^\s*\$\s+\w+", re.MULTILINE), + ], + "javascript": [ + re.compile(r"(const|let|var|function|=>\s*\{|import\s+.*from|require\()"), + ], + "typescript": [ + re.compile(r"(interface\s+\w+|type\s+\w+\s*=|:\s*(string|number|boolean|any)\b)"), + ], + "html": [ + re.compile(r"<(!DOCTYPE|html|head|body|div|span|table|form)\b", re.IGNORECASE), + ], + "css": [ + re.compile(r"^\s*[.#@]\w+.*\{", re.MULTILINE), + re.compile(r"(color|background|font-size|margin|padding|display)\s*:"), + ], + "python": [ + re.compile(r"(def\s+\w+|class\s+\w+|import\s+\w+|from\s+\w+\s+import)"), + ], + "xml": [ + re.compile(r"<\?xml\s"), + re.compile(r"<\w+\s+xmlns[=:]"), + ], +} + + +def detect_language(code_content: str) -> str | None: + """コードブロックの内容から言語を推定する""" + # スコアベースで最も適合する言語を選択 + scores = {} + for lang, patterns in LANGUAGE_PATTERNS.items(): + score = 0 + for pattern in patterns: + if pattern.search(code_content): + score += 1 + if score > 0: + scores[lang] = score + + if not scores: + return None + + # 最高スコアの言語を返す + return max(scores, key=scores.get) + + +def fix_code_blocks(content: str, verbose: bool = False) -> tuple[str, list[str]]: + """コードブロックに適切な言語指定を付与する""" + fixes = [] + lines = content.split("\n") + result = [] + i = 0 + + while i < len(lines): + line = lines[i] + + # 言語指定ありのコードブロック開始 → 閉じフェンスまでスキップ + if re.match(r"^```\w+", line): + result.append(line) + i += 1 + while i < len(lines) and not re.match(r"^```\s*$", lines[i]): + result.append(lines[i]) + i += 1 + if i < len(lines): + result.append(lines[i]) # 閉じフェンス + i += 1 + continue + + # 言語指定なしのコードブロック開始を検出 + if re.match(r"^```\s*$", line): + # コードブロックの内容を収集 + code_lines = [] + j = i + 1 + while j < len(lines) and not re.match(r"^```\s*$", lines[j]): + code_lines.append(lines[j]) + j += 1 + + code_content = "\n".join(code_lines) + detected = detect_language(code_content) + + if detected: + result.append(f"```{detected}") + fixes.append(f"行{i+1}: ``` → ```{detected}") + if verbose: + preview = code_content[:80].replace("\n", " ") + fixes[-1] += f" (内容: {preview}...)" + else: + result.append(line) + + # コンテンツ行を追加 + for cl in code_lines: + result.append(cl) + + # 閉じフェンスを追加してインデックスを進める + if j < len(lines): + result.append(lines[j]) # 閉じフェンス + i = j + 1 + else: + i = j + continue + + result.append(line) + i += 1 + + return "\n".join(result), fixes + + +def fix_table_formatting(content: str) -> tuple[str, list[str]]: + """テーブルのパイプ区切りとアライメント行を修正する""" + fixes = [] + lines = content.split("\n") + result = [] + i = 0 + + while i < len(lines): + line = lines[i] + + # テーブルヘッダー行の検出(パイプで区切られた行) + if "|" in line and i + 1 < len(lines): + # 次の行がアライメント行かチェック + next_line = lines[i + 1] + if re.match(r"^\s*\|[\s\-:|]+\|\s*$", next_line): + # アライメント行の列数がヘッダーと一致するか確認 + header_cols = len([c for c in line.split("|") if c.strip()]) + sep_cols = len([c for c in next_line.split("|") if c.strip() or c == ""]) + + # アライメント行のフォーマットを統一 + sep_parts = [p.strip() for p in next_line.split("|")] + sep_parts = [p for p in sep_parts if p or p == ""] + + result.append(line) + else: + result.append(line) + else: + result.append(line) + + i += 1 + + return "\n".join(result), fixes + + +def fix_heading_spacing(content: str) -> tuple[str, list[str]]: + """見出し前後に適切な空行を挿入する""" + fixes = [] + lines = content.split("\n") + result = [] + + for i, line in enumerate(lines): + # 見出し行の検出(# で始まる行) + if re.match(r"^#{1,6}\s+", line): + # 前の行が空行でない場合、空行を挿入(ファイル先頭を除く) + if result and result[-1].strip(): + result.append("") + fixes.append(f"行{i+1}: 見出し前に空行を挿入") + + result.append(line) + + # 見出し行の次が空行でない場合、空行を挿入 + if re.match(r"^#{1,6}\s+", line) and i + 1 < len(lines): + next_line = lines[i + 1] + if next_line.strip() and not re.match(r"^#{1,6}\s+", next_line): + # 次のループで追加されるので、ここでは何もしない + pass + + return "\n".join(result), fixes + + +def fix_trailing_newline(content: str) -> tuple[str, list[str]]: + """ファイル末尾の改行を統一する(1つの改行で終わる)""" + fixes = [] + if not content.endswith("\n"): + content += "\n" + fixes.append("ファイル末尾に改行を追加") + elif content.endswith("\n\n"): + content = content.rstrip("\n") + "\n" + fixes.append("ファイル末尾の余分な改行を削除") + return content, fixes + + +def validate_and_fix(filepath: str, check_only: bool = False, verbose: bool = False) -> list[str]: + """Markdownファイルを検証・修正する""" + with open(filepath, "r", encoding="utf-8") as f: + original = f.read() + + content = original + all_fixes = [] + + # 1. コードブロックの言語指定 + content, fixes = fix_code_blocks(content, verbose) + all_fixes.extend(fixes) + + # 2. テーブルフォーマット + content, fixes = fix_table_formatting(content) + all_fixes.extend(fixes) + + # 3. 見出し前後の空行 + content, fixes = fix_heading_spacing(content) + all_fixes.extend(fixes) + + # 4. 末尾改行 + content, fixes = fix_trailing_newline(content) + all_fixes.extend(fixes) + + # 修正を適用 + if all_fixes and not check_only: + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + + return all_fixes + + +def main(): + parser = argparse.ArgumentParser( + description="MarkdownファイルをGFM準拠に検証・修正する" + ) + parser.add_argument( + "path", + help="対象ファイルまたはディレクトリ", + ) + parser.add_argument( + "--check-only", + action="store_true", + help="検証のみ行い、ファイルを修正しない", + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="修正内容の詳細を表示する", + ) + args = parser.parse_args() + + # 対象ファイル一覧を取得 + if os.path.isfile(args.path): + files = [args.path] + elif os.path.isdir(args.path): + files = sorted(glob.glob(os.path.join(args.path, "*.md"))) + else: + print(f"エラー: パスが見つかりません: {args.path}", file=sys.stderr) + sys.exit(1) + + if not files: + print(f"対象の .md ファイルが見つかりません: {args.path}") + sys.exit(0) + + total_fixes = 0 + files_with_fixes = 0 + + for filepath in files: + filename = os.path.basename(filepath) + fixes = validate_and_fix(filepath, args.check_only, args.verbose) + + if fixes: + files_with_fixes += 1 + total_fixes += len(fixes) + status = "要修正" if args.check_only else "修正済" + print(f" [{status}] {filename}: {len(fixes)} 件") + if args.verbose: + for fix in fixes: + print(f" - {fix}") + else: + if args.verbose: + print(f" [OK] {filename}") + + print(f"\n合計: {len(files)} ファイル, {files_with_fixes} ファイルに {total_fixes} 件の{'問題' if args.check_only else '修正'}") + + if args.check_only and total_fixes > 0: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf/skills/gemini/SKILL.md b/plugins/ndf/skills/gemini/SKILL.md new file mode 100644 index 0000000..e2156ed --- /dev/null +++ b/plugins/ndf/skills/gemini/SKILL.md @@ -0,0 +1,444 @@ +--- +name: gemini +description: "Delegate coding, review, or research to Gemini CLI." +when_to_use: "外部 AI (Gemini)へコード生成 / レビュー / 調査を委譲したいとき。Triggers: 'geminiで調査', 'geminiレビュー', '第二意見レビュー (Gemini)', 'gemini exec', 'external AI review (Gemini)'" +--- + +# Gemini 外部AI委譲スキル + +## 概要 + +`gemini` CLI(Google Gemini、通常は `/usr/bin/gemini` または `npm` 経由でインストール)を直接実行して、コード生成・独立レビュー・コードベース調査を外部AIに委譲するためのスキル。 + +`codex` skill と同等の用途だが、Gemini CLI は以下の点で扱いやすい: + +- **stdout に最終 response が直接出る**(text/json いずれも空にならない既知挙動なし) +- **bwrap サンドボックスに依存しない**(WSL2 でも追加フラグ不要) +- **プロセス exit で完了判定可能**(sentinel grep が不要) + +## NDFとの関係 + +- `/ndf:review gemini` のように、`review` skill の第二引数 `gemini` 指定時の委譲先として利用される +- 専用エージェント(`corder` 相当)は未整備。委譲時はメインエージェントから本 skill を参照して直接 CLI を起動する +- Codex との使い分けは「既知制約とコスト」節を参照 + +## いつ使うか + +### 使うべきケース +- **独立第二意見レビュー**: 設計書・PR・仕様書を Gemini にレビューさせる(Codex とのクロスチェックに有用) +- **コードベース横断調査**: Gemini はワークスペース全体を走査する設計のため、複数ディレクトリ横断の調査に向く +- **長文生成**: ドキュメント生成・要約・翻訳など +- **Codex でうまくいかない / レート制限に当たったときの代替** + +### 使わないべきケース +- 短時間(1〜2分以内)で済むタスク → メインエージェントで直接対応 +- ユーザとの対話が必要な設計相談 → Plan Mode等で対話しながら進める +- 単純な質問回答 → WebFetch / WebSearch で足りる +- 機密情報を含むコード → 外部API送信の可否を確認してから + +## 前提条件 + +```bash +# インストール確認 +which gemini +gemini --version + +# 初回ログイン(OAuth) +gemini # 起動 → /auth でログイン +``` + +未インストールの場合は以下でセットアップ: + +```bash +# npm 経由 +npm install -g @google/gemini-cli + +# 動作確認 +gemini -p "hello" --output-format text +``` + +## 基本実行パターン + +### 1. 承認モード(重要) + +Gemini CLI は対話モードでは tool 実行ごとに承認を求める。非対話で確実に走らせるには `--yolo` か `--approval-mode yolo` を付ける。 + +```bash +# ❌ 非対話モードで tool 承認待ちで止まる +gemini -p "..." + +# ✅ ツール自動承認(外部隔離前提) +gemini --yolo -p "..." + +# ✅ 読み取り専用モード(plan mode、編集系 tool は走らない) +gemini --approval-mode plan -p "..." +``` + +| モード | 用途 | +|---|---| +| `default` | 対話で都度承認(非対話では止まる) | +| `auto_edit` | 編集系のみ自動承認 | +| `yolo` (`--yolo`) | 全 tool 自動承認 | +| `plan` | 読み取り専用(調査・レビュー向け) | + +**レビュー/調査タスクの推奨**: `--approval-mode plan`(編集事故を防ぐ) +**コード生成タスクの推奨**: `--yolo`(実ファイル編集が必要) + +> ⚠️ **`--yolo` のセキュリティ注意**: 全 tool 自動承認は `rm -rf` / 任意のシェル実行 / 任意のファイル編集を **無確認で許可** する。 +> 必ず以下のいずれかの **外部隔離環境** 内でのみ使用すること: +> - Docker コンテナ / devcontainer +> - VM / CI ランナー +> - 隔離された worktree(ホスト本体のリポジトリでは使わない) +> +> ホスト直接実行や本番リポジトリ作業中の `--yolo` は厳禁。コード生成タスクでも、ホスト直接実行なら +> `--approval-mode auto_edit`(編集系のみ自動承認、シェル実行は都度承認)への降格を検討する。 + +### 2. プロンプトは一時ファイル経由で渡す + +長いプロンプトをシェル引数に直接渡すとエスケープが破綻するので、ファイル経由で stdin か `$(cat ...)` 経由にする。 + +```bash +# Step 1: プロンプトを一時ファイルに書く +cat > /tmp/gemini-prompt.md <<'EOF' +## タスク +以下のファイルを読み込み、設計意図とコードの整合性をレビューしてください。 + +## 対象ファイル(絶対パスで指定) +/absolute/path/to/design.md + +## 出力形式 +Markdown で標準出力に吐いてください。 +EOF + +# Step 2a: stdin 経由(推奨) +gemini --yolo --output-format text -p "$(cat /tmp/gemini-prompt.md)" \ + > /tmp/gemini-stdout.md \ + 2> /tmp/gemini-err.log + +# Step 2b: あるいは stdin パイプ +cat /tmp/gemini-prompt.md | gemini --yolo --output-format text -p "" \ + > /tmp/gemini-stdout.md \ + 2> /tmp/gemini-err.log +``` + +### 3. 出力ストリームの扱い + +Gemini CLI の出力構造(codex と異なる点に注意): + +| ストリーム | `--output-format text` の内容 | `--output-format json` の内容 | +|---|---|---| +| **stdout** | 最終 assistant response の本文(Markdown / プレーンテキスト) | JSON 1 オブジェクト: `{session_id, response, stats}` | +| **stderr** | 警告のみ(例: `Ripgrep is not available. Falling back to GrepTool.`)— 通常数行 | + +**実務上の扱い**: +- 成果物が欲しい → `--output-format text` の stdout をそのまま採用 +- 統計(トークン数・tool 呼び出し履歴)が欲しい → `--output-format json` で stdout を `jq` 解析 + +```bash +# 成果物だけ取りたい +gemini --yolo --output-format text -p "$(cat prompt.md)" > out.md + +# 統計込みで取りたい +gemini --yolo --output-format json -p "$(cat prompt.md)" > out.json +jq -r '.response' out.json > out.md +jq '.stats' out.json > stats.json +``` + +### 4. 最終出力をファイル経由で保証する(補強策) + +Gemini は codex のような「最終 message を返さずに終わる」既知挙動は今のところ確認されていない。 +ただし長尺タスクで途中エラーが起きた場合の保険として、**`apply_patch` 相当の write_file tool で書き出させる指示** をプロンプトに加えると安全: + +```markdown +## 出力先(推奨) + +最終結果を `/tmp/gemini-output-.md` にも書き出してください。 +(stdout には同内容をそのまま出力すれば冪等で問題ありません。) +``` + +回収側は「stdout → ファイル → stderr」の順でフォールバック: + +```bash +# 命名規約: +# STDOUT = gemini の `> リダイレクト` 先(本 skill では /tmp/gemini-stdout.md で統一) +# OUTPUT_FILE = プロンプト指示で `write_file` させた保険ファイル(task ごとに固有名) +OUTPUT_FILE=/tmp/gemini-output-pr13734-review.md +STDOUT=/tmp/gemini-stdout.md + +if [ -s "$STDOUT" ]; then + cp "$STDOUT" ./result.md +elif [ -s "$OUTPUT_FILE" ]; then + cp "$OUTPUT_FILE" ./result.md +else + echo "WARN: Gemini の最終出力を回収できませんでした。stderr を確認:" >&2 + tail -200 /tmp/gemini-err.log +fi +``` + +### 5. バックグラウンド実行 + 待機パターン + +Gemini も大規模調査タスクでは数分かかる。エージェントハーネスのシェルタイムアウト(通常2〜3分)に引っかかる可能性があるため、**バックグラウンド実行 + 待機** が安全。 + +```bash +# 1. プロンプトファイル書き出し +# -> /tmp/gemini-prompt.md + +# 2. gemini をバックグラウンドで起動 +gemini --yolo --output-format text -p "$(cat /tmp/gemini-prompt.md)" \ + > /tmp/gemini-stdout.md \ + 2> /tmp/gemini-err.log & +PID=$! +echo "PID: $PID" + +# 3. 完了検知 — Gemini は exit するので PID watch で OK +# (codex の zombie 問題はないが、念のため出力ファイルサイズも併用すると堅牢) +until ! kill -0 $PID 2>/dev/null; do + sleep 30 +done +echo "DONE" + +# 4. 終了コード確認 +wait $PID +EXIT=$? +echo "exit=$EXIT" +``` + +**注意**: +- Gemini は codex と違って `^tokens used$` のような sentinel を吐かないため、stderr grep では完了判定できない +- 代わりに **プロセスの終了** を見るのが正しい(`kill -0` でプロセス存在確認、`wait $PID` で終了コード回収) + +### 6. 待機間隔のチューニング + +エージェントの context cache TTL は通常5分。これを超えると prompt cache がミスして再送料金が発生する: + +- **短い間隔**: 60〜270秒(TTL=5分内に収まる、軽量) +- **長い間隔**: 1200秒以上(1回のキャッシュミスを長時間で償却) +- **避けるべき**: 300秒前後(キャッシュミス+短時間の最悪) + +Gemini の典型実行時間(数十秒〜5分)に対しては **60〜270秒ポーリング** で十分。 + +### 7. プロセス確認・ログ追跡 + +```bash +# 完了したか +kill -0 $PID 2>/dev/null && echo "RUNNING" || echo "DONE" + +# 進捗を覗く(Gemini は実行中の stdout 出力は限定的なので stderr 側を見る) +tail -30 /tmp/gemini-err.log +``` + +## プロンプト設計のコツ + +### 必須要素 +1. **対象ファイルの絶対パス**(Gemini はワークスペース外のファイルも参照可能だが絶対パスが安全) +2. **調査観点を具体化**(箇条書きで3〜5項目に絞る) +3. **出力形式の指定**(Markdownテンプレートを提示) +4. **スコープ外の明示**(脱線防止) +5. **出力サイズ目安**(例: 400〜500行) + +### レビュー依頼テンプレート + +```markdown +あなたは<役割(例: シニアバックエンドエンジニア / セキュリティレビュアー)>として、 +以下をレビューしてください。 + +## 対象ファイル(必ず最初に読むこと) +`/absolute/path/to/target.md` + +## 観点 +1. <観点1: 例「仕様とコードの整合性」> +2. <観点2: 例「既存APIとの後方互換性」> + +## 調査対象コード(必要に応じて読む) +- `src/...` +- `lib/...` + +## 背景コンテキスト +- <プロジェクト概要> +- <関連PR / Issue番号> +- <既存レビューで対応済みの事項(重複指摘を避けるため)> + +## 出力形式 + +以下を Markdown で **stdout に出力** してください。 +(保険として `/tmp/gemini-output-.md` にも `write_file` で書き出してください。) + +# <タイトル> + +## 総評 +## 1. <観点1> に関する指摘 +### 1.1 正確な主張 +### 1.2 訂正推奨 +## 2. <観点2> に関する指摘 +## 3. 追加提案 +## 4. 承認可否 + +**必須**: 行番号・ファイルパスに紐付けて具体的に指摘してください。400〜500行程度、日本語で出力してください。 +``` + +### コード生成依頼テンプレート + +```markdown +以下の実装タスクを実行してください。 + +## タスク +<具体的な実装内容> + +## 制約 +- <技術制約: 言語バージョン、依存ライブラリ> +- <コーディング規約: ESLint / Prettier / rustfmt等> +- <テスト要件: ユニットテスト必須等> + +## 対象ファイル +- <既存ファイルのパス> +- <新規ファイルのパス案> + +## 背景 +<なぜこの実装が必要か、設計判断の経緯> + +## 完了基準 +- [ ] テストがパスする +- [ ] 型チェック / lint がパスする +- [ ] <追加の受け入れ条件> + +**必須**: ファイル編集は実際に行い、最後に変更ファイル一覧と要点を +stdout に Markdown で出力してください。 +保険として `/tmp/gemini-output-.md` にも `write_file` で書き出してください。 +``` + +## 実例: レビュー依頼の完全フロー + +```bash +# === 1. プロンプト書き出し === +FINAL=/tmp/gemini-output-api-v2-review.md + +cat > /tmp/review-prompt.md < /tmp/gemini-stdout.md \ + 2> /tmp/gemini-err.log & + +PID=$! +echo "gemini PID: $PID" + +# === 3. 完了確認(プロセス終了を待つ) === +until ! kill -0 $PID 2>/dev/null; do + sleep 30 +done +wait $PID +EXIT=$? +echo "DONE exit=$EXIT" + +# === 4. 成果物を回収(stdout 優先 → ファイルフォールバック) === +if [ -s /tmp/gemini-stdout.md ]; then + cp /tmp/gemini-stdout.md ./review-result.md + echo "✅ stdout から回収" +elif [ -s "$FINAL" ]; then + cp "$FINAL" ./review-result.md + echo "⚠ ファイルからフォールバック回収" +else + echo "❌ Gemini の最終出力を回収できませんでした。stderr を確認:" >&2 + tail -200 /tmp/gemini-err.log + exit 1 +fi +``` + +## トラブルシューティング + +### Q1. 非対話モードなのにプロセスがハングする +**原因**: 承認が必要な tool 呼び出しで止まっている(`default` / `auto_edit` モードのまま)。 + +**対処**: `--yolo` または `--approval-mode plan` を付ける。レビュー/調査なら `plan` が安全。 + +### Q2. stdout に思考のような余計な出力が混ざる +**原因**: `--output-format text` でも一部の進捗 / モデル切替メッセージが混ざる場合がある。 + +**対処**: +- `--output-format json` を使い、`jq -r '.response'` で本文だけ抽出する +- プロンプトで「最終結果のみを出力すること、思考や前置きは不要」と明記 + +### Q3. ファイルを読めない / ワークスペース外アクセスで止まる +**原因**: Gemini のワークスペース範囲外のファイル参照、または承認待ち。 + +**対処**: +- `--include-directories /path/to/extra` で対象ディレクトリを追加 +- プロンプトには**絶対パス**を書く +- それでも止まるなら `--yolo` または `--skip-trust` + +### Q4. 出力が途中で切れる / トークン上限 +**原因**: モデルの出力トークン上限に達した。 + +**対処**: +- プロンプトで「400行以内」など出力サイズを指定 +- 観点を絞って分割実行 +- `-m ` でより長い context のモデルを指定(gemini-3-pro 等、利用可能なものに応じて) + +### Q5. 認証エラー (`Authentication required` / `token expired`) +**原因**: OAuth セッション失効。 + +**対処**: +```bash +# 対話モードで再ログイン +gemini +# プロンプト上で /auth を叩いてブラウザ認証 +``` + +### Q6. ハーネスのシェルタイムアウトで kill される +**原因**: フォアグラウンド実行のまま長尺タスクを走らせた。 + +**対処**: 必ず `&` でバックグラウンド化し、`kill -0 $PID` ポーリングで待機する(5節参照)。 + +## 既知の制約とコスト + +1. **承認モード必須**: 非対話実行では `--yolo` か `--approval-mode plan` を必ず付ける +2. **stderr 警告は無害**: `Ripgrep is not available. Falling back to GrepTool.` 等は通常運用上問題なし +3. **ログイン状態**: 初回は対話モードで `/auth` 経由のログインが必要 +4. **APIコスト**: Google AI Studio / Vertex 経由のトークン課金。Codex より安価な傾向だが従量制 +5. **機密情報**: 外部APIにコードが送信されるため、社外秘コードの扱いは組織ポリシーに従うこと +6. **モデル選択**: デフォルトモデルは時期により変動。安定性を求めるなら `-m gemini-2.5-pro` 等を明示 + +## Codex との使い分け + +| 観点 | Codex (`/ndf:codex`) | Gemini (本スキル) | +|---|---|---| +| stdout の信頼性 | 最終 message が落ちることがある(要ファイル書き出し) | stdout に response が直接出る | +| サンドボックス | WSL2 で `--dangerously-bypass-approvals-and-sandbox` 必須 | 追加フラグ不要 | +| 完了判定 | `^tokens used$` sentinel | プロセス exit | +| 出力フォーマット | Markdown 本文のみ | text / json 選択可(json は統計付き) | +| 強み | コード逐語照合、長時間の深い調査 | 横断調査、長文生成、軽量タスク | +| 弱み | セットアップ・運用が煩雑 | 高難度コード解析でやや浅くなることがある | + +**指針**: +- 第二意見が欲しい場合、両方走らせてクロスチェックすると最も堅い +- 短時間で済む独立レビュー → Gemini を先に +- 行番号・件数の逐語確認 → Codex を併用 + +## 関連 + +- **`/ndf:codex` skill**: Codex CLI 経由の同等スキル(本スキルと併用してクロスチェック可能) +- **`/ndf:review` skill**: 第二引数 `gemini` 指定時に本スキルの手順を参照 +- **Gemini CLI 公式ドキュメント**: `gemini --help` +- **他のAI委譲方法**: `codex`, `claude`, `ollama` 等のCLI も同様のパターンで利用可 diff --git a/plugins/ndf/skills/google-auth/SKILL.md b/plugins/ndf/skills/google-auth/SKILL.md new file mode 100644 index 0000000..006019d --- /dev/null +++ b/plugins/ndf/skills/google-auth/SKILL.md @@ -0,0 +1,173 @@ +--- +name: google-auth +description: "Set up OAuth for Google APIs." +when_to_use: "Google API の OAuth2 認証が必要なときに自動参照。Triggers: 'Google認証', 'OAuth', 'google_token', 'spreadsheets', 'Google API', 'client_secret'" +allowed-tools: + - Read + - Bash(python *) + - Bash(uv *) +--- + +# Google OAuth2 認証ガイド + +Google API を使う操作 (スプレッドシート読み書き、GAS 実行、Drive アップロード、Chat メッセージ取得 等) で +認証が必要な場合に参照する。`${CLAUDE_SKILL_DIR}/scripts/google_auth.py` が CLI と Python ライブラリの両方として使える。 + +## 提供機能 + +| 機能 | 概要 | +|---|---| +| 単一トークン管理 | `~/.config/gcloud/google_token.json` (永続) に全スコープを保存 | +| 自動スコープマージ | `--scopes drive.file` 等で追加した分を既存スコープと自動マージして再認証 | +| 自動リフレッシュ | 期限切れは `refresh_token` で透過更新 | +| 手動 copy-paste フロー | `--manual` でローカルサーバ不要 (ポート競合・コンテナ環境で有用) | +| `--show` / `--clear` | トークン情報の表示・削除 | +| Python ライブラリ | `from google_auth import get_credentials` で他スキルから import 可能 | + +## 前提条件 + +1. **`client_secret.json` の準備** (初回のみ): + - [GCP コンソール](https://console.cloud.google.com/apis/credentials) を開く + - 「認証情報を作成」→「OAuth クライアント ID」→ アプリケーションの種類「デスクトップアプリ」 + - 作成後、JSON をダウンロードして以下のいずれかに `client_secret.json` として配置: + - `${CLAUDE_SKILL_DIR}/client_secret.json` (推奨、skill ローカル) + - 環境変数 `GOOGLE_CLIENT_SECRET` で示すパス + - 実行時のカレントディレクトリ + - **注意**: `.gitignore` に追加し、絶対にコミットしないこと + +2. **依存パッケージのインストール** (skill ローカルの `pyproject.toml` を使う): + + ```bash + uv pip install -r ${CLAUDE_SKILL_DIR}/pyproject.toml + # もしくは: uv run --project ${CLAUDE_SKILL_DIR} python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py ... + ``` + +## CLI 使用法 + +認証はインタラクティブなブラウザ操作が必要なので、**ユーザに `!` プレフィックスで実行を案内する**。 +エージェントが Bash で直接呼び出すとサーバ待ちでハングする。 + +```bash +# デフォルト (ローカルサーバ port 9123、スコープ spreadsheets.readonly) +! python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py + +# スコープ指定 (ショートハンド) +! python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py spreadsheets drive + +# スコープ指定 (フル URL) +! python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py https://www.googleapis.com/auth/script.projects + +# 手動 copy-paste フロー (ローカルサーバ不要、ポート競合・コンテナ環境で確実) +! python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py --manual drive.file + +# client_secret.json のパスを明示 +! python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py --client-secret /path/to/client_secret.json drive + +# トークン情報を表示 (現在保存されているスコープ等) +python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py --show + +# トークン削除 (再認証用) +python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py --clear +``` + +### 利用可能なスコープショートハンド + +| ショートハンド | フル URL | +|---|---| +| `spreadsheets` / `spreadsheets.readonly` | `https://www.googleapis.com/auth/spreadsheets[.readonly]` | +| `drive` / `drive.readonly` / `drive.file` | `https://www.googleapis.com/auth/drive[.readonly|.file]` | +| `script.projects` / `script.external_request` | `https://www.googleapis.com/auth/script.*` | +| `chat.messages` / `chat.messages.readonly` / `chat.spaces.readonly` | `https://www.googleapis.com/auth/chat.*` | +| `calendar` / `calendar.readonly` | `https://www.googleapis.com/auth/calendar[.readonly]` | + +未定義のショートハンドは `https://www.googleapis.com/auth/` として展開される。フル URL も直接渡せる。 + +### 認証フロー + +#### 既定: ローカルサーバ方式 + +1. スクリプトがポート 9123 (`--port` で変更可) でローカルサーバを起動 +2. ユーザが表示された URL をブラウザで開いて承認 +3. ブラウザ → ローカルサーバへの自動リダイレクトでトークン取得 +4. `~/.config/gcloud/google_token.json` に保存 + +#### `--manual`: 手動 copy-paste 方式 + +1. スクリプトが空きポートを動的に確保し、それを redirect_uri に指定 (サーバは立てない) +2. 表示された URL をブラウザで開いて承認 +3. リダイレクト先は接続失敗するが、アドレスバーの URL をターミナルにコピー貼り付け +4. スクリプトがその URL から認可コードを取り出してトークン化 +5. `~/.config/gcloud/google_token.json` に保存 + +ポートが空いていないコンテナ・サーバでも確実に動く。 + +## Python ライブラリ用法 + +他のスキルやスクリプトから import: + +```python +import sys +sys.path.insert(0, '${CLAUDE_SKILL_DIR}/scripts') # 実際は実行時に解決 +from google_auth import get_credentials + +# 既存トークン (有効) があればそのまま、期限切れなら自動リフレッシュ +creds = get_credentials() + +# 追加スコープが必要な場合のみ指定 (既存スコープと自動マージして再認証) +creds = get_credentials(['drive.file']) + +# Bash 環境で確実に動かしたい場合 +creds = get_credentials(['drive.file'], manual=True) +``` + +引数: + +| 引数 | デフォルト | 説明 | +|---|---|---| +| `scopes` | `None` (= spreadsheets.readonly) | 追加スコープのリスト | +| `port` | `9123` | ローカルサーバのポート (manual=True 時は無視) | +| `manual` | `False` | True で手動 copy-paste フロー | +| `client_secret` | env `GOOGLE_CLIENT_SECRET` または `${CLAUDE_SKILL_DIR}/client_secret.json` または CWD | client_secret.json のパス | +| `token_file` | env `GOOGLE_TOKEN_FILE` または `~/.config/gcloud/google_token.json` | トークン保存先 | + +## トークン管理 + +- **永続ファイル**: `~/.config/gcloud/google_token.json` (コンテナ再起動でも残る) +- **単一ファイルで全スコープ管理**: `--scopes` で追加するたびに既存スコープと merge して再認証 +- **自動リフレッシュ**: 期限切れで `refresh_token` を使って透過更新 +- **スコープ不足検出**: 既存トークンに要求スコープが含まれていなければ自動的に再認証フローへ + +## トラブルシューティング + +### 403 Insufficient scopes + +スコープ不足の場合は `--clear` してから必要なスコープ付きで再認証: + +```bash +python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py --clear +! python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py drive.file spreadsheets +``` + +### 404 File not found / 権限エラー + +サービスアカウントにはユーザのファイルへのアクセス権がない。本スキルは **ユーザの OAuth 認証** を使う前提。 +`GOOGLE_APPLICATION_CREDENTIALS` 環境変数が設定されていると干渉するので、明示的にクリア: + +```bash +GOOGLE_APPLICATION_CREDENTIALS="" python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py drive +``` + +### ポート競合 + +ポート 9123 が使用中の場合: + +```bash +! python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py --port 9124 drive +# または手動フロー (ポート不要) +! python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py --manual drive +``` + +### gcloud CLI での認証 (非推奨) + +gcloud CLI の OAuth 認証はインタラクティブ入力の制約で多くのエージェント環境では動作しない。 +本スキルの `google_auth.py` を使うこと。 diff --git a/plugins/ndf/skills/google-auth/pyproject.toml b/plugins/ndf/skills/google-auth/pyproject.toml new file mode 100644 index 0000000..2d87846 --- /dev/null +++ b/plugins/ndf/skills/google-auth/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "google-auth-skill" +version = "0.2.0" +description = "Google API 共通 OAuth2 認証モジュールの依存関係" +requires-python = ">=3.9" +dependencies = [ + "google-auth>=2.0.0", + "google-auth-oauthlib>=1.0.0", +] diff --git a/plugins/ndf/skills/google-auth/scripts/google_auth.py b/plugins/ndf/skills/google-auth/scripts/google_auth.py new file mode 100644 index 0000000..9bf6e57 --- /dev/null +++ b/plugins/ndf/skills/google-auth/scripts/google_auth.py @@ -0,0 +1,298 @@ +"""Google API 共通 OAuth2 認証モジュール + +CLI とライブラリの両方として使える: + + # ライブラリ用途 (他スキルから import) + from google_auth import get_credentials + creds = get_credentials(['drive.file']) # 既存スコープに追加して再認証 + + # CLI: 初回認証 (デフォルトはローカルサーバ port 9123) + python google_auth.py spreadsheets drive + + # CLI: 手動 copy-paste フロー (ポート不要、Bash 環境で確実) + python google_auth.py --manual spreadsheets + + # CLI: トークン情報表示 / 削除 + python google_auth.py --show + python google_auth.py --clear + +特徴: + - トークンは `~/.config/gcloud/google_token.json` に永続化 (`GOOGLE_TOKEN_FILE` で上書き可) + - 期限切れは `refresh_token` で自動更新 + - 既存トークンのスコープに不足があれば、要求スコープと既存スコープをマージして再認証 + - `--manual` で手動 copy-paste フロー (ローカルサーバ不要、空きポート動的割当) +""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import sys +from pathlib import Path + +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow + + +SCRIPT_DIR = Path(__file__).resolve().parent +SKILL_DIR = SCRIPT_DIR.parent + +DEFAULT_TOKEN_FILE = Path.home() / ".config" / "gcloud" / "google_token.json" +DEFAULT_PORT = 9123 + +# よく使うスコープのショートハンド +SCOPE_SHORTCUTS = { + 'spreadsheets': 'https://www.googleapis.com/auth/spreadsheets', + 'spreadsheets.readonly': 'https://www.googleapis.com/auth/spreadsheets.readonly', + 'drive': 'https://www.googleapis.com/auth/drive', + 'drive.readonly': 'https://www.googleapis.com/auth/drive.readonly', + 'drive.file': 'https://www.googleapis.com/auth/drive.file', + 'script.projects': 'https://www.googleapis.com/auth/script.projects', + 'script.external_request': 'https://www.googleapis.com/auth/script.external_request', + 'chat.messages': 'https://www.googleapis.com/auth/chat.messages', + 'chat.messages.readonly': 'https://www.googleapis.com/auth/chat.messages.readonly', + 'chat.spaces.readonly': 'https://www.googleapis.com/auth/chat.spaces.readonly', + 'calendar': 'https://www.googleapis.com/auth/calendar', + 'calendar.readonly': 'https://www.googleapis.com/auth/calendar.readonly', +} + +DEFAULT_SCOPE = 'https://www.googleapis.com/auth/spreadsheets.readonly' + + +def _expand_scopes(scopes: list[str]) -> list[str]: + """ショートハンド or フル URL の混在リストをすべて URL に展開する。""" + out: list[str] = [] + for s in scopes: + if s.startswith('https://'): + out.append(s) + elif s in SCOPE_SHORTCUTS: + out.append(SCOPE_SHORTCUTS[s]) + else: + out.append(f'https://www.googleapis.com/auth/{s}') + return out + + +def _resolve_token_file(token_file: str | os.PathLike | None) -> Path: + if token_file: + return Path(token_file).expanduser() + env = os.environ.get('GOOGLE_TOKEN_FILE') + if env: + return Path(env).expanduser() + return DEFAULT_TOKEN_FILE + + +def _resolve_client_secret(client_secret: str | os.PathLike | None) -> Path: + """`--client-secret` arg → env `GOOGLE_CLIENT_SECRET` → `/client_secret.json` + → `/client_secret.json` の順で探す。""" + candidates: list[Path] = [] + if client_secret: + candidates.append(Path(client_secret).expanduser()) + env = os.environ.get('GOOGLE_CLIENT_SECRET') + if env: + candidates.append(Path(env).expanduser()) + candidates.append(SKILL_DIR / 'client_secret.json') + candidates.append(Path.cwd() / 'client_secret.json') + for c in candidates: + if c.exists(): + return c + raise FileNotFoundError( + "client_secret.json が見つかりません。下記いずれかに配置してください:\n" + + "\n".join(f" - {c}" for c in candidates) + + "\n\nGCP コンソールで OAuth 2.0 クライアント ID (デスクトップアプリ) を作成し、" + "ダウンロードした JSON を `client_secret.json` として配置:\n" + " https://console.cloud.google.com/apis/credentials" + ) + + +def _save_token(creds: Credentials, token_file: Path) -> None: + """OAuth トークンを `0600` permission で保存する。 + + トークンは access_token / refresh_token を含むため、他ユーザから読まれない + よう所有者のみ read/write 可とする (CWE-732 対策)。親ディレクトリも `0700` で作成。 + """ + token_file.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + token_file.write_text(creds.to_json()) + token_file.chmod(0o600) + print(f'Token saved: {token_file} (mode 0600)', file=sys.stderr) + + +def _pick_free_port() -> int: + """空きポートを取得 (redirect_uri のダミーとして使う)。""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('127.0.0.1', 0)) + return s.getsockname()[1] + + +def _authorize_manual(flow: InstalledAppFlow) -> Credentials: + """ローカルサーバを立てない手動 copy-paste OAuth フロー。 + + redirect_uri は `http://127.0.0.1:<空きポート>/` で動的割当 (どのポートにもサーバを + 立てないので競合しない)。ブラウザは「このサイトにアクセスできません」になるが、 + アドレスバーの URL をユーザに貼り付けてもらえば認可コードを取り出せる。 + + `OAUTHLIB_INSECURE_TRANSPORT` は localhost redirect_uri (loopback IP) のための + 一時許可。プロセス全体に影響しないよう try/finally で元の値に復元する (CWE-319 対策)。 + """ + old_insecure = os.environ.get('OAUTHLIB_INSECURE_TRANSPORT') + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' + try: + flow.redirect_uri = f'http://127.0.0.1:{_pick_free_port()}/' + auth_url, _ = flow.authorization_url( + access_type='offline', prompt='consent', include_granted_scopes='true' + ) + print('', file=sys.stderr) + print('=== 手動認証フロー ===', file=sys.stderr) + print('1. 以下の URL をブラウザで開いて承認してください:', file=sys.stderr) + print('', file=sys.stderr) + print(auth_url, file=sys.stderr) + print('', file=sys.stderr) + print(f'2. 承認後、ブラウザは {flow.redirect_uri}?code=... へリダイレクトします', file=sys.stderr) + print(' (ローカルにサーバを立てていないため接続失敗ページになりますが、', file=sys.stderr) + print(' アドレスバーの URL をそのままコピーしてください)', file=sys.stderr) + print('', file=sys.stderr) + response_url = input('3. その URL をここに貼り付けて Enter: ').strip() + flow.fetch_token(authorization_response=response_url) + return flow.credentials + finally: + if old_insecure is None: + os.environ.pop('OAUTHLIB_INSECURE_TRANSPORT', None) + else: + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = old_insecure + + +def get_credentials( + scopes: list[str] | None = None, + *, + port: int | None = DEFAULT_PORT, + manual: bool = False, + client_secret: str | os.PathLike | None = None, + token_file: str | os.PathLike | None = None, +) -> Credentials: + """OAuth2 認証情報を取得する。 + + Args: + scopes: 追加スコープ (ショートハンドまたはフル URL)。指定がなければデフォルトの + spreadsheets.readonly。既存トークンに含まれるスコープは自動的にマージされる。 + port: ローカルコールバックサーバのポート (デフォルト 9123)。`manual=True` の場合は無視。 + manual: True なら手動 copy-paste フローを使う (ポート競合や Bash 環境で有用)。 + client_secret: client_secret.json のパス。省略時は env GOOGLE_CLIENT_SECRET → SKILL_DIR + → CWD の順で探す。 + token_file: トークン保存先。省略時は env GOOGLE_TOKEN_FILE → + ~/.config/gcloud/google_token.json。 + """ + requested = _expand_scopes(scopes) if scopes else [] + token_path = _resolve_token_file(token_file) + + creds: Credentials | None = None + if token_path.exists(): + try: + creds = Credentials.from_authorized_user_file(str(token_path)) + except Exception as exc: + print(f'既存トークン読み込み失敗: {exc}', file=sys.stderr) + creds = None + + existing_scopes = set(creds.scopes or []) if creds else set() + needed = set(requested) if requested else {DEFAULT_SCOPE} + + # 既存トークンが有効 + 要求スコープを満たしていればそのまま返す + if creds and creds.valid and needed.issubset(existing_scopes): + return creds + + # 期限切れだけならリフレッシュ + if ( + creds and creds.expired and creds.refresh_token + and needed.issubset(existing_scopes) + ): + try: + creds.refresh(Request()) + _save_token(creds, token_path) + return creds + except Exception as exc: + print(f'トークンリフレッシュ失敗、再認証します: {exc}', file=sys.stderr) + creds = None + + # 再認証: 既存スコープと要求スコープをマージ + merged = list(existing_scopes | needed) + flow = InstalledAppFlow.from_client_secrets_file( + str(_resolve_client_secret(client_secret)), merged, + ) + if manual or port is None: + creds = _authorize_manual(flow) + else: + creds = flow.run_local_server(port=port, open_browser=False) + _save_token(creds, token_path) + return creds + + +# --- CLI --------------------------------------------------------- + +def _show_token(token_file: Path) -> int: + if not token_file.exists(): + print(f'トークン未作成: {token_file}') + return 1 + try: + data = json.loads(token_file.read_text()) + except Exception as exc: + print(f'読み取りエラー: {exc}') + return 1 + print(token_file) + print(' scopes:') + for s in data.get('scopes', []): + print(f' - {s}') + if data.get('expiry'): + print(f" expiry: {data['expiry']}") + return 0 + + +def _clear_token(token_file: Path) -> int: + if token_file.exists(): + token_file.unlink() + print(f'削除: {token_file}') + return 0 + print(f'トークンなし: {token_file}') + return 0 + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description='Google API 共通 OAuth2 認証') + p.add_argument('scopes', nargs='*', + help='追加スコープ (ショートハンドまたはフル URL)。既存トークンと自動マージ') + p.add_argument('--client-secret', default=None, + help='client_secret.json のパス (env GOOGLE_CLIENT_SECRET または SKILL_DIR/CWD の同名ファイルでも可)') + p.add_argument('--token-file', default=None, + help=f'トークン保存先 (default: {DEFAULT_TOKEN_FILE})') + p.add_argument('--port', type=int, default=DEFAULT_PORT, + help=f'ローカルコールバックサーバのポート (default: {DEFAULT_PORT})') + p.add_argument('--manual', action='store_true', + help='手動 copy-paste フローを使う (ポート競合や Bash 環境で有用)') + p.add_argument('--show', action='store_true', help='現在のトークン情報を表示して終了') + p.add_argument('--clear', action='store_true', help='トークンを削除して終了') + return p + + +def main() -> int: + args = _build_parser().parse_args() + token_path = _resolve_token_file(args.token_file) + + if args.show: + return _show_token(token_path) + if args.clear: + return _clear_token(token_path) + + creds = get_credentials( + args.scopes or None, + port=args.port, manual=args.manual, + client_secret=args.client_secret, token_file=token_path, + ) + print('認証成功') + print('スコープ:') + for s in creds.scopes or []: + print(f' - {s}') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/plugins/ndf/skills/google-auth/uv.lock b/plugins/ndf/skills/google-auth/uv.lock new file mode 100644 index 0000000..43b316f --- /dev/null +++ b/plugins/ndf/skills/google-auth/uv.lock @@ -0,0 +1,464 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" }, + { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" }, + { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" }, + { url = "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", size = 216498, upload-time = "2025-09-08T23:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", size = 203243, upload-time = "2025-09-08T23:23:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", size = 203158, upload-time = "2025-09-08T23:23:55.169Z" }, + { url = "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", size = 216548, upload-time = "2025-09-08T23:23:56.506Z" }, + { url = "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", size = 218897, upload-time = "2025-09-08T23:23:57.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", size = 211249, upload-time = "2025-09-08T23:23:59.139Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", size = 218041, upload-time = "2025-09-08T23:24:00.496Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", size = 172138, upload-time = "2025-09-08T23:24:01.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/ef725f8eb19b5a261b30f78efa9252ef9d017985cb499102f6f49834cd12/charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", size = 299121, upload-time = "2026-04-02T09:28:14.372Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/2f12878fbc680fbbb52386cd39a379801f62eaca74fc8b323381325f0f04/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", size = 200612, upload-time = "2026-04-02T09:28:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b6/10c84e789126ca97d4a7228863a30481e786980a8b8cfcbf4f30658ca63c/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", size = 221041, upload-time = "2026-04-02T09:28:17.554Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/c414866a138400b2e81973d006da7f694cfeaf895ef07d2cba9a8743841a/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", size = 216323, upload-time = "2026-04-02T09:28:18.863Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/bdcf94997e06b223d826df3abed45a5ad6e17f609b7df9d25cd23b5bde30/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", size = 208419, upload-time = "2026-04-02T09:28:20.332Z" }, + { url = "https://files.pythonhosted.org/packages/1a/64/3f9142293c88b1b10e199649ed1330f070c2a68e305335a5819fa7f25fa7/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", size = 195016, upload-time = "2026-04-02T09:28:21.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d1/d8a6b7dd5c5636b76ce0d080bc57d8e56c7bbd6bc2ac941529a35e41d84a/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", size = 206115, upload-time = "2026-04-02T09:28:23.259Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8c/60ebe912379627d023eb96995b40bc50308729f210f43d66109ca0a7bbd2/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", size = 204022, upload-time = "2026-04-02T09:28:24.779Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2a/41816ceda78a551cbfdfbeab6f3891152b0e3f758ce6580c2c18c829f774/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", size = 195914, upload-time = "2026-04-02T09:28:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9b/7c7f4b7f11525fcbdfba752455314ac60646bae91cdd671d531c1f7a97c6/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", size = 222159, upload-time = "2026-04-02T09:28:27.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/301682e7469bdbfa2ce219a804f0668b2266ab8520570d85d3b3ef483ea3/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", size = 206154, upload-time = "2026-04-02T09:28:28.848Z" }, + { url = "https://files.pythonhosted.org/packages/20/ec/90339ff5cdc598b265748c1f231c7d7fbd9123a92cee10f757e0b1448de4/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", size = 217423, upload-time = "2026-04-02T09:28:30.248Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e7/a7a6147f8e3375676309cf584b25c72a3bab784ea4085b0011fa07b23aeb/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", size = 210604, upload-time = "2026-04-02T09:28:31.736Z" }, + { url = "https://files.pythonhosted.org/packages/1a/62/d9340c7a79c393e57807d7fb6c57e82060687891f81b74d3201958b919c1/charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", size = 144631, upload-time = "2026-04-02T09:28:33.158Z" }, + { url = "https://files.pythonhosted.org/packages/21/e7/92901117e2ddc8facfe8235a3ecd4eb482185b2ad5d5b6606b37c1afea06/charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", size = 154710, upload-time = "2026-04-02T09:28:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4f/e1fb138201ad9a32499dd9a98aa4a5a5441fbf7f56b52b619a54b7ee8777/charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", size = 143716, upload-time = "2026-04-02T09:28:35.908Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "cryptography" +version = "47.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, + { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, + { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, +] + +[[package]] +name = "google-auth" +version = "2.49.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/fc/e925290a1ad95c975c459e2df070fac2b90954e13a0370ac505dff78cb99/google_auth-2.49.2.tar.gz", hash = "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", size = 333958, upload-time = "2026-04-10T00:41:21.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/76/d241a5c927433420507215df6cac1b1fa4ac0ba7a794df42a84326c68da8/google_auth-2.49.2-py3-none-any.whl", hash = "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5", size = 240638, upload-time = "2026-04-10T00:41:14.501Z" }, +] + +[[package]] +name = "google-auth-oauthlib" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/82/62482931dcbe5266a2680d0da17096f2aab983ecb320277d9556700ce00e/google_auth_oauthlib-1.3.1.tar.gz", hash = "sha256:14c22c7b3dd3d06dbe44264144409039465effdd1eef94f7ce3710e486cc4bfa", size = 21663, upload-time = "2026-03-30T22:49:56.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl", hash = "sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8", size = 19247, upload-time = "2026-03-30T20:02:23.894Z" }, +] + +[[package]] +name = "google-auth-skill" +version = "0.2.0" +source = { virtual = "." } +dependencies = [ + { name = "google-auth" }, + { name = "google-auth-oauthlib" }, +] + +[package.metadata] +requires-dist = [ + { name = "google-auth", specifier = ">=2.0.0" }, + { name = "google-auth-oauthlib", specifier = ">=1.0.0" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "urllib3", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] diff --git a/plugins/ndf/skills/google-chat/SKILL.md b/plugins/ndf/skills/google-chat/SKILL.md new file mode 100644 index 0000000..cffb61c --- /dev/null +++ b/plugins/ndf/skills/google-chat/SKILL.md @@ -0,0 +1,153 @@ +--- +name: google-chat +description: "Read Google Chat spaces and messages." +when_to_use: "Google Chat スペースのメッセージ取得・スペース一覧が必要なとき。Triggers: 'Google Chat', 'chat.spaces', 'chat.messages', 'Chatスペース', 'メッセージ取得', 'チャット履歴'" +allowed-tools: + - Read + - Bash(python *) + - Bash(uv *) +--- + +# Google Chat アクセス + +## 概要 + +Google Chat API (Python) を使ってチャットスペースのメッセージを取得する。 +認証は `ndf:google-auth` スキルの共通モジュール (`get_credentials()`) を使用。 + +## 提供物 + +``` +google-chat/ +├── SKILL.md ← このファイル +├── pyproject.toml ← uv プロジェクト (Chat API 依存) +└── scripts/ + └── gchat_read.py ← CLI: メッセージ一覧 / スペース一覧 +``` + +`gchat_read.py` は実行時に `ndf:google-auth` スキルの `google_auth.py` を sys.path に追加して +`get_credentials()` を呼ぶ。`google-auth` 側で OAuth2 トークンを取得済みであれば追加の認証は不要。 + +## 前提条件 + +| 項目 | 値 | +|---|---| +| 認証 | `ndf:google-auth` スキル (共通 OAuth2 モジュール) | +| Python 実行 | `uv run --project ${CLAUDE_SKILL_DIR} python ...` または `uv run --with ...` | +| client_secret.json | `ndf:google-auth` の手順で配置済み | +| 既存トークン | `~/.config/gcloud/google_token.json` (`ndf:google-auth` で取得済み) | +| Cloud Console | **Google Chat API の有効化**が必要 | +| アカウント | Google Workspace (Business / Enterprise)。無料 Gmail では利用不可 | + +## URL から Space ID を取得 + +Google Chat URL の末尾がそのまま Space ID になる。 + +``` +https://mail.google.com/mail/u/0/#chat/space/AAQA6AWG1iE + ^^^^^^^^^^^ + これが Space ID +``` + +API 呼び出し時は `spaces/AAQA6AWG1iE` の形式で指定する (スクリプトは ID のみ受け取る)。 + +## クイックスタート + +```bash +SKILL_DIR=${CLAUDE_SKILL_DIR} +SCRIPT=$SKILL_DIR/scripts/gchat_read.py + +# スペース一覧を表示 +uv run --project $SKILL_DIR python $SCRIPT --list-spaces + +# メッセージ一覧 (デフォルト Space ID) +uv run --project $SKILL_DIR python $SCRIPT + +# Space ID を指定 +uv run --project $SKILL_DIR python $SCRIPT --space AAQA6AWG1iE + +# 日付フィルタ (RFC-3339 形式) +uv run --project $SKILL_DIR python $SCRIPT --space AAQA6AWG1iE \ + --after "2024-01-01T00:00:00+09:00" + +# 出力先を変更 +uv run --project $SKILL_DIR python $SCRIPT --space AAQA6AWG1iE --output /tmp/my_chat.json +``` + +### 出力 + +- `--output` で指定した JSON ファイル (デフォルト: `/tmp/gchat_messages.json`) +- 標準出力に直近 5 件のプレビュー + +## API パラメータリファレンス + +### ListMessagesRequest + +| パラメータ | 型 | 説明 | +|---|---|---| +| `parent` | string | `spaces/{space_id}` 形式 (必須) | +| `page_size` | int | 最大取得件数 (デフォルト 25、最大 1,000) | +| `page_token` | string | ページネーション用トークン | +| `filter` | string | `createTime` や `thread.name` でフィルタ | +| `order_by` | string | `createTime ASC` または `createTime DESC` | +| `show_deleted` | bool | 削除済みメッセージを含めるか | + +### フィルタ構文 + +``` +# 特定日時以降 +createTime > "2024-01-01T00:00:00+09:00" + +# 日付範囲 +createTime > "2024-03-01T00:00:00+09:00" AND createTime < "2024-04-01T00:00:00+09:00" + +# スレッド指定 +thread.name = "spaces/AAQA6AWG1iE/threads/THREAD_ID" +``` + +### 必要な OAuth スコープ + +| スコープ | 用途 | +|---|---| +| `chat.spaces.readonly` | スペース一覧取得 (読み取り専用) | +| `chat.messages.readonly` | メッセージ一覧取得 (読み取り専用) | +| `chat.messages` | メッセージ読み書き (送信が必要な場合) | + +`ndf:google-auth` でこれらのスコープを取得しておく: + +```bash +! python ${CLAUDE_SKILLS_DIR:-${CLAUDE_PROJECT_DIR}/.claude/skills}/google-auth/scripts/google_auth.py \ + chat.messages.readonly chat.spaces.readonly +``` + +## トラブルシューティング + +### 403 PERMISSION_DENIED + +- Google Cloud Console で Chat API が有効化されているか確認 +- OAuth クライアント ID が Desktop app 用か確認 +- Google Workspace (Business / Enterprise) アカウントでログインしているか確認 +- 無料 Gmail アカウントでは利用不可 + +### 403 Insufficient scopes + +スコープ変更時はトークンを削除して再認証 (`ndf:google-auth` の `--clear` を使う): + +```bash +python ${CLAUDE_SKILLS_DIR:-${CLAUDE_PROJECT_DIR}/.claude/skills}/google-auth/scripts/google_auth.py --clear +! python ${CLAUDE_SKILLS_DIR:-${CLAUDE_PROJECT_DIR}/.claude/skills}/google-auth/scripts/google_auth.py \ + chat.messages.readonly chat.spaces.readonly +``` + +### INVALID_ARGUMENT (filter) + +- 日付は RFC-3339 形式: `"2024-01-01T00:00:00+09:00"` +- スレッド名はフルパス: `spaces/SPACE_ID/threads/THREAD_ID` + +### `GOOGLE_APPLICATION_CREDENTIALS` の干渉 + +サービスアカウントを指している場合は明示的にクリアする: + +```bash +GOOGLE_APPLICATION_CREDENTIALS="" uv run --project $SKILL_DIR python $SCRIPT --list-spaces +``` diff --git a/plugins/ndf/skills/google-chat/pyproject.toml b/plugins/ndf/skills/google-chat/pyproject.toml new file mode 100644 index 0000000..f3d5b80 --- /dev/null +++ b/plugins/ndf/skills/google-chat/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "google-chat-skill" +version = "0.1.0" +description = "Google Chat API アクセスの依存関係 (ndf:google-auth と組み合わせて使用)" +requires-python = ">=3.9" +dependencies = [ + "google-apps-chat>=0.1.0", + "google-auth>=2.0.0", + "google-auth-httplib2>=0.2.0", + "google-auth-oauthlib>=1.0.0", +] diff --git a/plugins/ndf/skills/google-chat/scripts/gchat_read.py b/plugins/ndf/skills/google-chat/scripts/gchat_read.py new file mode 100644 index 0000000..b98f382 --- /dev/null +++ b/plugins/ndf/skills/google-chat/scripts/gchat_read.py @@ -0,0 +1,168 @@ +"""Google Chat API メッセージ読み取りスクリプト + +Usage: + # デフォルトSpace IDでメッセージ取得 + python3 gchat_read.py + + # Space IDを指定 + python3 gchat_read.py --space AAQA6AWG1iE + + # 日付フィルタ付き + python3 gchat_read.py --space AAQA6AWG1iE --after "2024-01-01T00:00:00+09:00" + + # 出力先を変更 + python3 gchat_read.py --space AAQA6AWG1iE --output /tmp/my_chat.json + + # スペース一覧を表示 + python3 gchat_read.py --list-spaces +""" + +import argparse +import json +import os +import sys +from pathlib import Path + +# google-auth スキルの get_credentials() を使う。 +# 環境変数 GOOGLE_AUTH_SCRIPTS で google-auth/scripts のパスを指定可能。 +# 未指定の場合は次の候補を順に探す: +# 1. ~/.claude/skills/google-auth/scripts (uttarov 互換) +# 2. ../../google-auth/scripts (ndf プラグイン内の隣接スキル) +_CANDIDATES = ( + os.environ.get("GOOGLE_AUTH_SCRIPTS"), + os.path.expanduser("~/.claude/skills/google-auth/scripts"), + str(Path(__file__).resolve().parent.parent.parent / "google-auth" / "scripts"), +) +for _p in _CANDIDATES: + if _p and os.path.isdir(_p): + if _p not in sys.path: + sys.path.insert(0, _p) + break +from google_auth import get_credentials as _get_credentials # type: ignore # noqa: E402 + +from google.apps import chat_v1 as google_chat # noqa: E402 + +SCOPES = [ + 'chat.messages.readonly', + 'chat.spaces.readonly', +] + +DEFAULT_SPACE_ID = os.environ.get('GCHAT_DEFAULT_SPACE') or None +DEFAULT_OUTPUT = '/tmp/gchat_messages.json' + + +def get_credentials(): + return _get_credentials(SCOPES) + + +def create_client(): + creds = get_credentials() + full_scopes = [f'https://www.googleapis.com/auth/{s}' for s in SCOPES] + return google_chat.ChatServiceClient( + credentials=creds, + client_options={"scopes": full_scopes}, + ) + + +def list_spaces(): + client = create_client() + request = google_chat.ListSpacesRequest(filter='space_type = "SPACE"') + for space in client.list_spaces(request): + print(f"{space.name} - {space.display_name}") + + +def list_messages(space_id, page_size=200, filter_str=None, order_by='createTime DESC'): + client = create_client() + kwargs = { + 'parent': f'spaces/{space_id}', + 'page_size': page_size, + 'order_by': order_by, + } + if filter_str: + kwargs['filter'] = filter_str + + request = google_chat.ListMessagesRequest(**kwargs) + messages = [] + for message in client.list_messages(request): + d = type(message).to_dict(message) + + # attachment からDriveリンクを抽出 + attachments = [] + for att in (d.get('attachment') or []): + drive_ref = att.get('drive_data_ref') or {} + file_id = drive_ref.get('drive_file_id') + content_name = att.get('content_name', '') + content_type = att.get('content_type', '') + if file_id: + url = f'https://drive.google.com/file/d/{file_id}/view' + if 'folder' in content_type: + url = f'https://drive.google.com/drive/folders/{file_id}' + elif 'spreadsheet' in content_type: + url = f'https://docs.google.com/spreadsheets/d/{file_id}/edit' + elif 'document' in content_type: + url = f'https://docs.google.com/document/d/{file_id}/edit' + elif 'presentation' in content_type: + url = f'https://docs.google.com/presentation/d/{file_id}/edit' + attachments.append({ + 'name': content_name, + 'type': content_type, + 'url': url, + }) + + # annotation からrich_linkを抽出 + rich_links = [] + for ann in (d.get('annotations') or []): + rl = ann.get('rich_link_metadata') + if rl and rl.get('uri'): + rich_links.append(rl['uri']) + + messages.append({ + 'name': message.name, + 'sender': message.sender.name if message.sender else None, + 'create_time': message.create_time.isoformat() if message.create_time else None, + 'text': message.text or '', + 'thread': message.thread.name if message.thread else None, + 'attachments': attachments, + 'rich_links': rich_links, + }) + return messages + + +def main(): + parser = argparse.ArgumentParser(description='Google Chat メッセージ読み取り') + parser.add_argument('--space', default=DEFAULT_SPACE_ID, + help='Space ID (URL末尾。env GCHAT_DEFAULT_SPACE で既定値も指定可)') + parser.add_argument('--output', default=DEFAULT_OUTPUT, help='出力ファイルパス') + parser.add_argument('--after', help='この日時以降のメッセージを取得 (RFC-3339形式)') + parser.add_argument('--before', help='この日時以前のメッセージを取得 (RFC-3339形式)') + parser.add_argument('--list-spaces', action='store_true', help='スペース一覧を表示') + parser.add_argument('--page-size', type=int, default=200, help='1ページあたりの取得件数 (最大1000)') + args = parser.parse_args() + + if args.list_spaces: + list_spaces() + return + + if not args.space: + parser.error("--space が必要です (または env GCHAT_DEFAULT_SPACE で既定値を指定)") + + # フィルタ構築 + filters = [] + if args.after: + filters.append(f'createTime > "{args.after}"') + if args.before: + filters.append(f'createTime < "{args.before}"') + filter_str = ' AND '.join(filters) if filters else None + + messages = list_messages(args.space, page_size=args.page_size, filter_str=filter_str) + with open(args.output, 'w', encoding='utf-8') as f: + json.dump(messages, f, ensure_ascii=False, indent=2) + print(f'OK: {len(messages)} messages saved to {args.output}') + + for msg in messages[:5]: + text_preview = msg['text'][:100] if msg['text'] else '(empty)' + print(f"[{msg['create_time']}] {text_preview}") + + +if __name__ == '__main__': + main() diff --git a/plugins/ndf/skills/google-chat/uv.lock b/plugins/ndf/skills/google-chat/uv.lock new file mode 100644 index 0000000..c2cf875 --- /dev/null +++ b/plugins/ndf/skills/google-chat/uv.lock @@ -0,0 +1,693 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" }, + { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" }, + { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" }, + { url = "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", size = 216498, upload-time = "2025-09-08T23:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", size = 203243, upload-time = "2025-09-08T23:23:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", size = 203158, upload-time = "2025-09-08T23:23:55.169Z" }, + { url = "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", size = 216548, upload-time = "2025-09-08T23:23:56.506Z" }, + { url = "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", size = 218897, upload-time = "2025-09-08T23:23:57.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", size = 211249, upload-time = "2025-09-08T23:23:59.139Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", size = 218041, upload-time = "2025-09-08T23:24:00.496Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", size = 172138, upload-time = "2025-09-08T23:24:01.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/ef725f8eb19b5a261b30f78efa9252ef9d017985cb499102f6f49834cd12/charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", size = 299121, upload-time = "2026-04-02T09:28:14.372Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/2f12878fbc680fbbb52386cd39a379801f62eaca74fc8b323381325f0f04/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", size = 200612, upload-time = "2026-04-02T09:28:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b6/10c84e789126ca97d4a7228863a30481e786980a8b8cfcbf4f30658ca63c/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", size = 221041, upload-time = "2026-04-02T09:28:17.554Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/c414866a138400b2e81973d006da7f694cfeaf895ef07d2cba9a8743841a/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", size = 216323, upload-time = "2026-04-02T09:28:18.863Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/bdcf94997e06b223d826df3abed45a5ad6e17f609b7df9d25cd23b5bde30/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", size = 208419, upload-time = "2026-04-02T09:28:20.332Z" }, + { url = "https://files.pythonhosted.org/packages/1a/64/3f9142293c88b1b10e199649ed1330f070c2a68e305335a5819fa7f25fa7/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", size = 195016, upload-time = "2026-04-02T09:28:21.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d1/d8a6b7dd5c5636b76ce0d080bc57d8e56c7bbd6bc2ac941529a35e41d84a/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", size = 206115, upload-time = "2026-04-02T09:28:23.259Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8c/60ebe912379627d023eb96995b40bc50308729f210f43d66109ca0a7bbd2/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", size = 204022, upload-time = "2026-04-02T09:28:24.779Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2a/41816ceda78a551cbfdfbeab6f3891152b0e3f758ce6580c2c18c829f774/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", size = 195914, upload-time = "2026-04-02T09:28:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9b/7c7f4b7f11525fcbdfba752455314ac60646bae91cdd671d531c1f7a97c6/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", size = 222159, upload-time = "2026-04-02T09:28:27.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/301682e7469bdbfa2ce219a804f0668b2266ab8520570d85d3b3ef483ea3/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", size = 206154, upload-time = "2026-04-02T09:28:28.848Z" }, + { url = "https://files.pythonhosted.org/packages/20/ec/90339ff5cdc598b265748c1f231c7d7fbd9123a92cee10f757e0b1448de4/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", size = 217423, upload-time = "2026-04-02T09:28:30.248Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e7/a7a6147f8e3375676309cf584b25c72a3bab784ea4085b0011fa07b23aeb/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", size = 210604, upload-time = "2026-04-02T09:28:31.736Z" }, + { url = "https://files.pythonhosted.org/packages/1a/62/d9340c7a79c393e57807d7fb6c57e82060687891f81b74d3201958b919c1/charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", size = 144631, upload-time = "2026-04-02T09:28:33.158Z" }, + { url = "https://files.pythonhosted.org/packages/21/e7/92901117e2ddc8facfe8235a3ecd4eb482185b2ad5d5b6606b37c1afea06/charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", size = 154710, upload-time = "2026-04-02T09:28:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4f/e1fb138201ad9a32499dd9a98aa4a5a5441fbf7f56b52b619a54b7ee8777/charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", size = 143716, upload-time = "2026-04-02T09:28:35.908Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "cryptography" +version = "47.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, + { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, + { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.30.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274, upload-time = "2026-04-09T22:57:16.198Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-apps-card" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/5a/6cd7a16e30841773067b122d09f1a7fa51224620b2af2544d0af5b50042e/google_apps_card-0.6.0.tar.gz", hash = "sha256:d105fdb0e79535d681df876d799abcc54eebbab860faf5865fad9a100dc8b26b", size = 39499, upload-time = "2026-03-30T22:50:21.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/f8/ac95b3175c5de49c4f8560bd406696b707462ed58e26dc659bed668a149a/google_apps_card-0.6.0-py3-none-any.whl", hash = "sha256:807ec85caec67cf4df8ddb0e31992c71fc53eecd573a1648bae78e8f2c16ac43", size = 40002, upload-time = "2026-03-30T22:49:17.962Z" }, +] + +[[package]] +name = "google-apps-chat" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-apps-card" }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/b8/05d86a16bf48c3e5e75e8f3754b4cdae5e3e4dd9bd9cb1a82dceddb5fadd/google_apps_chat-0.8.0.tar.gz", hash = "sha256:c86f405c05e46fcd03a244a99b0afe9050f6b152bdfc1809b3f9597b52840270", size = 240159, upload-time = "2026-04-10T00:41:29.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/88/e37eef4d7cb5dd48015469efe610f3e0079435f924f3bde004664b200f3c/google_apps_chat-0.8.0-py3-none-any.whl", hash = "sha256:196ee1a667a59b1b0542dd10b855746a950db86f0fe4ef475c56f82f54defa15", size = 199850, upload-time = "2026-04-10T00:41:08.112Z" }, +] + +[[package]] +name = "google-auth" +version = "2.49.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/fc/e925290a1ad95c975c459e2df070fac2b90954e13a0370ac505dff78cb99/google_auth-2.49.2.tar.gz", hash = "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", size = 333958, upload-time = "2026-04-10T00:41:21.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/76/d241a5c927433420507215df6cac1b1fa4ac0ba7a794df42a84326c68da8/google_auth-2.49.2-py3-none-any.whl", hash = "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5", size = 240638, upload-time = "2026-04-10T00:41:14.501Z" }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/99/107612bef8d24b298bb5a7c8466f908ecda791d43f9466f5c3978f5b24c1/google_auth_httplib2-0.3.1.tar.gz", hash = "sha256:0af542e815784cb64159b4469aa5d71dd41069ba93effa006e1916b1dcd88e55", size = 11152, upload-time = "2026-03-30T22:50:26.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/e9/93afb14d23a949acaa3f4e7cc51a0024671174e116e35f42850764b99634/google_auth_httplib2-0.3.1-py3-none-any.whl", hash = "sha256:682356a90ef4ba3d06548c37e9112eea6fc00395a11b0303a644c1a86abc275c", size = 9534, upload-time = "2026-03-30T22:49:03.384Z" }, +] + +[[package]] +name = "google-auth-oauthlib" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/82/62482931dcbe5266a2680d0da17096f2aab983ecb320277d9556700ce00e/google_auth_oauthlib-1.3.1.tar.gz", hash = "sha256:14c22c7b3dd3d06dbe44264144409039465effdd1eef94f7ce3710e486cc4bfa", size = 21663, upload-time = "2026-03-30T22:49:56.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl", hash = "sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8", size = 19247, upload-time = "2026-03-30T20:02:23.894Z" }, +] + +[[package]] +name = "google-chat-skill" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "google-apps-chat" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "google-auth-oauthlib" }, +] + +[package.metadata] +requires-dist = [ + { name = "google-apps-chat", specifier = ">=0.1.0" }, + { name = "google-auth", specifier = ">=2.0.0" }, + { name = "google-auth-httplib2", specifier = ">=0.2.0" }, + { name = "google-auth-oauthlib", specifier = ">=1.0.0" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/cd/bb7b7e54084a344c03d68144450da7ddd5564e51a298ae1662de65f48e2d/grpcio-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c", size = 6050363, upload-time = "2026-03-30T08:46:20.894Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/1417f5c3460dea65f7a2e3c14e8b31e77f7ffb730e9bfadd89eda7a9f477/grpcio-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388", size = 12026037, upload-time = "2026-03-30T08:46:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/43/98/c910254eedf2cae368d78336a2de0678e66a7317d27c02522392f949b5c6/grpcio-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02", size = 6602306, upload-time = "2026-03-30T08:46:27.593Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f8/88ca4e78c077b2b2113d95da1e1ab43efd43d723c9a0397d26529c2c1a56/grpcio-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc", size = 7301535, upload-time = "2026-03-30T08:46:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f9/96/f28660fe2fe0f153288bf4a04e4910b7309d442395135c88ed4f5b3b8b40/grpcio-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a", size = 6808669, upload-time = "2026-03-30T08:46:31.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/3f68a5e955779c00aeef23850e019c1c1d0e032d90633ba49c01ad5a96e0/grpcio-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9", size = 7409489, upload-time = "2026-03-30T08:46:34.684Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a7/d2f681a4bfb881be40659a309771f3bdfbfdb1190619442816c3f0ffc079/grpcio-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199", size = 8423167, upload-time = "2026-03-30T08:46:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/29b4589c204959aa35ce5708400a05bba72181807c45c47b3ec000c39333/grpcio-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81", size = 7846761, upload-time = "2026-03-30T08:46:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d2/ed143e097230ee121ac5848f6ff14372dba91289b10b536d54fb1b7cbae7/grpcio-1.80.0-cp310-cp310-win32.whl", hash = "sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069", size = 4156534, upload-time = "2026-03-30T08:46:42.026Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c9/df8279bb49b29409995e95efa85b72973d62f8aeff89abee58c91f393710/grpcio-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58", size = 4889869, upload-time = "2026-03-30T08:46:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" }, + { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" }, + { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, + { url = "https://files.pythonhosted.org/packages/08/58/7151ffa07cb3faf4bdd1a1902c067d2d162a4ba24678afd2ad5084a42382/grpcio-1.80.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:aacdfb4ed3eb919ca997504d27e03d5dba403c85130b8ed450308590a738f7a4", size = 6048562, upload-time = "2026-03-30T08:48:40.068Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/0287051dc65c2760155977d9775d1f3c87939e4d575a29aac40f9006b357/grpcio-1.80.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:a361c20ec1ccd3c3953d20fb6d7b4125093bdd10dff44c5e2bbb39e58917cedc", size = 12031536, upload-time = "2026-03-30T08:48:43.031Z" }, + { url = "https://files.pythonhosted.org/packages/7b/62/8fc355ffcc9fd8a3ca0438f007307c130dfb93949d3138cd23c8c9f434e8/grpcio-1.80.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:43168871f170d1e4ed16ae03d10cd21efa29f190e710a624cee7e5ae07da6f4f", size = 6602175, upload-time = "2026-03-30T08:48:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/12/cb/3efd0b505090804dfe88bf258ed26a6fb19ccbb31889a05b9edb3ae035fe/grpcio-1.80.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1b97cd29a8eda100b559b455331c487a80915b6ea6bd91cf3e89836c4ee8d957", size = 7299777, upload-time = "2026-03-30T08:48:48.848Z" }, + { url = "https://files.pythonhosted.org/packages/54/b1/50fdb826acafd5ac661e10df25b089721172530f2eb4aa1f36bd3c3d4254/grpcio-1.80.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bac1d573dfa84ce59a5547073e28fa7326d53352adda6912e362da0b917fcef4", size = 6808790, upload-time = "2026-03-30T08:48:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/41e9ed0bb5544836bb2685097beea972b0cabc8970aeaace0f152bfc5441/grpcio-1.80.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4560cf0e86514595dbbd330cd65b7afad4b5c4b8c4905c041cfffa138d45e6fd", size = 7410605, upload-time = "2026-03-30T08:48:54.466Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/889f0dfbc8a08050db6e23c3180dbe712b03af490352a4d7df649db26bc8/grpcio-1.80.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec0a592e926071b4abad50c1495cd0d0d513324b3ff5e7267067c33ba27506e4", size = 8423134, upload-time = "2026-03-30T08:48:57.71Z" }, + { url = "https://files.pythonhosted.org/packages/3d/76/f44d853f38165d26a309565da31a312587dda668e9e7b5323179b87bcab4/grpcio-1.80.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:deb10a1528473c11f72a0939eed36d83e847d7cbb63e8cc5611fb7a912d38614", size = 7846917, upload-time = "2026-03-30T08:49:00.969Z" }, + { url = "https://files.pythonhosted.org/packages/74/fe/99c56d12b48f8c8b0d28c42edfb171642eb52dd90a0fe7bc74676909fa97/grpcio-1.80.0-cp39-cp39-win32.whl", hash = "sha256:627fb7312171cdc52828bd6fac8d7028ff2a64b89f1957b6f3416caa2218d141", size = 4157647, upload-time = "2026-03-30T08:49:04.196Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ff/33f6a8823f06c6a1d1f530c1531e563b76c02091525e36255c08575ae775/grpcio-1.80.0-cp39-cp39-win_amd64.whl", hash = "sha256:05d55e1798756282cddd52d56c896b3e7d673e3a8798c2f1cd05ba249a3bb4de", size = 4892359, upload-time = "2026-03-30T08:49:06.902Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/ed/105f619bdd00cb47a49aa2feea6232ea2bbb04199d52a22cc6a7d603b5cb/grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd", size = 13901, upload-time = "2026-03-30T08:54:34.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/80/58cd2dfc19a07d022abe44bde7c365627f6c7cb6f692ada6c65ca437d09a/grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe", size = 14638, upload-time = "2026-03-30T08:54:01.569Z" }, +] + +[[package]] +name = "httplib2" +version = "0.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204, upload-time = "2026-03-26T22:18:57.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450, upload-time = "2026-03-26T22:13:42.927Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/0c/bd/88a687e9147329fc7e6c26a058fc52214c47190688a496bb283000a4d2a3/protobuf-6.33.6-cp39-cp39-win32.whl", hash = "sha256:bd56799fb262994b2c2faa1799693c95cc2e22c62f56fb43af311cae45d26f0e", size = 425861, upload-time = "2026-03-18T19:04:57.064Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/fab384eea064bfc3b273183e4e09bb3a3cf4ec83876b3828c09fcacbb651/protobuf-6.33.6-cp39-cp39-win_amd64.whl", hash = "sha256:f443a394af5ed23672bc6c486be138628fbe5c651ccbc536873d7da23d1868cf", size = 437109, upload-time = "2026-03-18T19:04:58.713Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "urllib3", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] diff --git a/plugins/ndf/skills/google-drive/SKILL.md b/plugins/ndf/skills/google-drive/SKILL.md new file mode 100644 index 0000000..48f287d --- /dev/null +++ b/plugins/ndf/skills/google-drive/SKILL.md @@ -0,0 +1,111 @@ +--- +name: google-drive +description: "Export, download, upload, and share Google Drive files." +when_to_use: "Google Drive / Docs のファイル操作が必要なとき。Triggers: 'Google Drive', 'Google Docs', 'drive.file', 'ファイルエクスポート', 'ダウンロード', 'アップロード', '公開共有リンク'" +allowed-tools: + - Read + - Bash(python *) + - Bash(uv *) +--- + +# Google Drive アクセス + +## 概要 + +Google Drive / Google Docs のファイルを CLI 環境から操作する。 +認証は `ndf:google-auth` スキルの共通モジュール (`get_credentials()`) を使用。 + +## 提供物 + +``` +google-drive/ +├── SKILL.md ← このファイル +├── pyproject.toml ← uv プロジェクト (Drive API 依存) +└── scripts/ + └── gdrive_fetch.py ← CLI: エクスポート / ダウンロード / アップロード +``` + +`gdrive_fetch.py` は実行時に `ndf:google-auth` スキルの `google_auth.py` を sys.path に追加して +`get_credentials()` を呼ぶ。`google-auth` 側で OAuth2 トークンを取得済みであれば追加の認証は不要。 + +## 前提条件 + +| 項目 | 値 | +|---|---| +| 認証 | `ndf:google-auth` スキル (共通 OAuth2 モジュール) | +| Python 実行 | `uv run --project ${CLAUDE_SKILL_DIR} python ...` または `uv run --with ...` | +| client_secret.json | `ndf:google-auth` の手順で配置済み | +| 既存トークン | `~/.config/gcloud/google_token.json` (`ndf:google-auth` で取得済み) | + +## クイックスタート + +```bash +SKILL_DIR=${CLAUDE_SKILL_DIR} +SCRIPT=$SKILL_DIR/scripts/gdrive_fetch.py + +# Google Doc をテキストでエクスポート +uv run --project $SKILL_DIR python $SCRIPT --id FILE_ID + +# HTML 形式 +uv run --project $SKILL_DIR python $SCRIPT --id FILE_ID --mime text/html -o /tmp/doc.html + +# PDF 形式 +uv run --project $SKILL_DIR python $SCRIPT --id FILE_ID --mime application/pdf -o /tmp/doc.pdf + +# バイナリファイル (画像、PDF 等) をダウンロード +uv run --project $SKILL_DIR python $SCRIPT --id FILE_ID --download -o /tmp/file.png + +# ファイルをアップロード (公開共有リンク付与) +uv run --project $SKILL_DIR python $SCRIPT --upload /path/to/file.png +``` + +`--port N` を渡すとローカルサーバ方式の OAuth で再認証 (初回かつスコープ追加時のみ必要)。 + +## Google Doc ID の取得 + +URL から ID を抽出する: + +``` +https://docs.google.com/document/d/【ここが Doc ID】/edit +``` + +例: `https://docs.google.com/document/d/1vwDAZJLlLjtjFFITB23qTF8ldWBgRZg8MPUO4kUt-Ys/edit` +→ ID: `1vwDAZJLlLjtjFFITB23qTF8ldWBgRZg8MPUO4kUt-Ys` + +## エクスポート形式 (`--mime`) + +| mimeType | 形式 | +|---|---| +| `text/plain` | プレーンテキスト | +| `text/html` | HTML | +| `application/pdf` | PDF | +| `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | DOCX | + +Sheets は `text/csv` / `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (xlsx) も対応。 +Slides は `application/pdf` / `application/vnd.openxmlformats-officedocument.presentationml.presentation` (pptx)。 + +## トラブルシューティング + +### 403 Insufficient scopes + +スコープ不足の場合は `ndf:google-auth` で必要なスコープ付きで再認証: + +```bash +python ${CLAUDE_SKILLS_DIR:-${CLAUDE_PROJECT_DIR}/.claude/skills}/google-auth/scripts/google_auth.py --clear +! python ${CLAUDE_SKILLS_DIR:-${CLAUDE_PROJECT_DIR}/.claude/skills}/google-auth/scripts/google_auth.py drive.file +``` + +### 404 File not found + +サービスアカウントにはユーザのファイルへのアクセス権がない。本スキルは **ユーザの OAuth 認証** (`ndf:google-auth` 経由) を使う前提。 + +### `GOOGLE_APPLICATION_CREDENTIALS` の干渉 + +```bash +GOOGLE_APPLICATION_CREDENTIALS="" uv run --project $SKILL_DIR python $SCRIPT --id FILE_ID +``` + +### gcloud CLI での認証 (非推奨) + +gcloud CLI の OAuth はインタラクティブ入力の制約で多くのエージェント環境で動かない。 +本スキルの依存である `ndf:google-auth` を使うこと。 diff --git a/plugins/ndf/skills/google-drive/pyproject.toml b/plugins/ndf/skills/google-drive/pyproject.toml new file mode 100644 index 0000000..48e9e77 --- /dev/null +++ b/plugins/ndf/skills/google-drive/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "google-drive-skill" +version = "0.1.0" +description = "Google Drive / Google Docs API アクセスの依存関係 (ndf:google-auth と組み合わせて使用)" +requires-python = ">=3.9" +dependencies = [ + "google-api-python-client>=2.0.0", + "google-auth>=2.0.0", + "google-auth-oauthlib>=1.0.0", +] diff --git a/plugins/ndf/skills/google-drive/scripts/gdrive_fetch.py b/plugins/ndf/skills/google-drive/scripts/gdrive_fetch.py new file mode 100644 index 0000000..6a52cac --- /dev/null +++ b/plugins/ndf/skills/google-drive/scripts/gdrive_fetch.py @@ -0,0 +1,135 @@ +"""Google Drive ファイル取得スクリプト + +Usage: + # Google Docをテキストでエクスポート + python3 gdrive_fetch.py --id 1vwDAZJLlLjtjFFITB23qTF8ldWBgRZg8MPUO4kUt-Ys + + # HTML形式でエクスポート + python3 gdrive_fetch.py --id FILE_ID --mime text/html --output /tmp/doc.html + + # PDF形式でエクスポート + python3 gdrive_fetch.py --id FILE_ID --mime application/pdf --output /tmp/doc.pdf + + # バイナリファイルをダウンロード(画像、PDF等) + python3 gdrive_fetch.py --id FILE_ID --download --output /tmp/file.png + + # ファイルをアップロード(公開共有リンク付き) + python3 gdrive_fetch.py --upload /path/to/file.png +""" + +import argparse +import io +import os +import sys +from pathlib import Path + +# google-auth スキルの get_credentials() を使う。 +# 環境変数 GOOGLE_AUTH_SCRIPTS で google-auth/scripts のパスを指定可能。 +# 未指定の場合は次の候補を順に探す: +# 1. ~/.claude/skills/google-auth/scripts (uttarov 互換) +# 2. ../../google-auth/scripts (ndf プラグイン内の隣接スキル) +_CANDIDATES = ( + os.environ.get("GOOGLE_AUTH_SCRIPTS"), + os.path.expanduser("~/.claude/skills/google-auth/scripts"), + str(Path(__file__).resolve().parent.parent.parent / "google-auth" / "scripts"), +) +for _p in _CANDIDATES: + if _p and os.path.isdir(_p): + if _p not in sys.path: + sys.path.insert(0, _p) + break +from google_auth import get_credentials # type: ignore # noqa: E402 + +from googleapiclient.discovery import build # noqa: E402 +from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload # noqa: E402 + + +SCOPES_READONLY = ['drive.readonly'] +SCOPES_FILE = ['drive.file'] + + +def export_doc(file_id, mime_type='text/plain', output=None, port=None): + """Google Docs/Sheets/Slidesをエクスポート""" + creds = get_credentials(SCOPES_READONLY, port=port) if port else get_credentials(SCOPES_READONLY) + service = build('drive', 'v3', credentials=creds) + content = service.files().export(fileId=file_id, mimeType=mime_type).execute() + + if output is None: + output = '/tmp/gdoc_export.txt' + + with open(output, 'wb') as f: + f.write(content) + print(f'OK: exported {len(content)} bytes to {output}') + + +def download_file(file_id, output, port=None): + """バイナリファイルをダウンロード""" + creds = get_credentials(SCOPES_READONLY, port=port) if port else get_credentials(SCOPES_READONLY) + service = build('drive', 'v3', credentials=creds) + request = service.files().get_media(fileId=file_id) + fh = io.BytesIO() + downloader = MediaIoBaseDownload(fh, request) + done = False + while not done: + status, done = downloader.next_chunk() + print(f' Download {int(status.progress() * 100)}%') + with open(output, 'wb') as f: + f.write(fh.getvalue()) + print(f'OK: downloaded {len(fh.getvalue())} bytes to {output}') + + +def upload_file(filepath, public=True, port=None): + """ファイルをGoogle Driveにアップロード""" + creds = get_credentials(SCOPES_FILE, port=port) if port else get_credentials(SCOPES_FILE) + service = build('drive', 'v3', credentials=creds) + + filename = os.path.basename(filepath) + file_metadata = {'name': filename} + media = MediaFileUpload(filepath) + file = service.files().create( + body=file_metadata, media_body=media, fields='id,webViewLink' + ).execute() + + file_id = file['id'] + if public: + service.permissions().create( + fileId=file_id, + body={'type': 'anyone', 'role': 'reader'} + ).execute() + + direct_url = f'https://drive.google.com/uc?export=view&id={file_id}' + print(f'File ID: {file_id}') + print(f'View: {file.get("webViewLink", "N/A")}') + print(f'Direct URL: {direct_url}') + + +def main(): + parser = argparse.ArgumentParser(description='Google Drive ファイル操作') + parser.add_argument('--id', help='Google Drive ファイルID') + parser.add_argument('--mime', default='text/plain', + help='エクスポート形式 (text/plain, text/html, application/pdf)') + parser.add_argument('--output', '-o', help='出力ファイルパス') + parser.add_argument('--download', action='store_true', + help='バイナリダウンロードモード') + parser.add_argument('--upload', metavar='FILE', help='アップロードするファイルパス') + parser.add_argument('--port', type=int, default=None, + help='OAuth認証ポート(初回認証時のローカルコールバック用)') + args = parser.parse_args() + + if args.upload: + upload_file(args.upload, port=args.port) + return + + if not args.id: + parser.print_help() + return + + if args.download: + output = args.output or '/tmp/gdrive_download' + download_file(args.id, output, port=args.port) + else: + export_doc(args.id, args.mime, args.output, port=args.port) + + +if __name__ == '__main__': + main() diff --git a/plugins/ndf/skills/google-drive/uv.lock b/plugins/ndf/skills/google-drive/uv.lock new file mode 100644 index 0000000..7a16ba3 --- /dev/null +++ b/plugins/ndf/skills/google-drive/uv.lock @@ -0,0 +1,611 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", + "python_full_version < '3.10'", +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" }, + { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" }, + { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" }, + { url = "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", size = 216498, upload-time = "2025-09-08T23:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", size = 203243, upload-time = "2025-09-08T23:23:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", size = 203158, upload-time = "2025-09-08T23:23:55.169Z" }, + { url = "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", size = 216548, upload-time = "2025-09-08T23:23:56.506Z" }, + { url = "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", size = 218897, upload-time = "2025-09-08T23:23:57.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", size = 211249, upload-time = "2025-09-08T23:23:59.139Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", size = 218041, upload-time = "2025-09-08T23:24:00.496Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", size = 172138, upload-time = "2025-09-08T23:24:01.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/ef725f8eb19b5a261b30f78efa9252ef9d017985cb499102f6f49834cd12/charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", size = 299121, upload-time = "2026-04-02T09:28:14.372Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/2f12878fbc680fbbb52386cd39a379801f62eaca74fc8b323381325f0f04/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", size = 200612, upload-time = "2026-04-02T09:28:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b6/10c84e789126ca97d4a7228863a30481e786980a8b8cfcbf4f30658ca63c/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", size = 221041, upload-time = "2026-04-02T09:28:17.554Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/c414866a138400b2e81973d006da7f694cfeaf895ef07d2cba9a8743841a/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", size = 216323, upload-time = "2026-04-02T09:28:18.863Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/bdcf94997e06b223d826df3abed45a5ad6e17f609b7df9d25cd23b5bde30/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", size = 208419, upload-time = "2026-04-02T09:28:20.332Z" }, + { url = "https://files.pythonhosted.org/packages/1a/64/3f9142293c88b1b10e199649ed1330f070c2a68e305335a5819fa7f25fa7/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", size = 195016, upload-time = "2026-04-02T09:28:21.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d1/d8a6b7dd5c5636b76ce0d080bc57d8e56c7bbd6bc2ac941529a35e41d84a/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", size = 206115, upload-time = "2026-04-02T09:28:23.259Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8c/60ebe912379627d023eb96995b40bc50308729f210f43d66109ca0a7bbd2/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", size = 204022, upload-time = "2026-04-02T09:28:24.779Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2a/41816ceda78a551cbfdfbeab6f3891152b0e3f758ce6580c2c18c829f774/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", size = 195914, upload-time = "2026-04-02T09:28:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9b/7c7f4b7f11525fcbdfba752455314ac60646bae91cdd671d531c1f7a97c6/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", size = 222159, upload-time = "2026-04-02T09:28:27.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/301682e7469bdbfa2ce219a804f0668b2266ab8520570d85d3b3ef483ea3/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", size = 206154, upload-time = "2026-04-02T09:28:28.848Z" }, + { url = "https://files.pythonhosted.org/packages/20/ec/90339ff5cdc598b265748c1f231c7d7fbd9123a92cee10f757e0b1448de4/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", size = 217423, upload-time = "2026-04-02T09:28:30.248Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e7/a7a6147f8e3375676309cf584b25c72a3bab784ea4085b0011fa07b23aeb/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", size = 210604, upload-time = "2026-04-02T09:28:31.736Z" }, + { url = "https://files.pythonhosted.org/packages/1a/62/d9340c7a79c393e57807d7fb6c57e82060687891f81b74d3201958b919c1/charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", size = 144631, upload-time = "2026-04-02T09:28:33.158Z" }, + { url = "https://files.pythonhosted.org/packages/21/e7/92901117e2ddc8facfe8235a3ecd4eb482185b2ad5d5b6606b37c1afea06/charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", size = 154710, upload-time = "2026-04-02T09:28:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4f/e1fb138201ad9a32499dd9a98aa4a5a5441fbf7f56b52b619a54b7ee8777/charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", size = 143716, upload-time = "2026-04-02T09:28:35.908Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "cryptography" +version = "47.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, + { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, + { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.30.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf", version = "6.33.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "protobuf", version = "7.34.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274, upload-time = "2026-04-09T22:57:16.198Z" }, +] + +[[package]] +name = "google-api-python-client" +version = "2.194.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "httplib2" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/ab/e83af0eb043e4ccc49571ca7a6a49984e9d00f4e9e6e6f1238d60bc84dce/google_api_python_client-2.194.0.tar.gz", hash = "sha256:db92647bd1a90f40b79c9618461553c2b20b6a43ce7395fa6de07132dc14f023", size = 14443469, upload-time = "2026-04-08T23:07:35.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/34/5a624e49f179aa5b0cb87b2ce8093960299030ff40423bfbde09360eb908/google_api_python_client-2.194.0-py3-none-any.whl", hash = "sha256:61eaaac3b8fc8fdf11c08af87abc3d1342d1b37319cc1b57405f86ef7697e717", size = 15016514, upload-time = "2026-04-08T23:07:33.093Z" }, +] + +[[package]] +name = "google-auth" +version = "2.49.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/fc/e925290a1ad95c975c459e2df070fac2b90954e13a0370ac505dff78cb99/google_auth-2.49.2.tar.gz", hash = "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", size = 333958, upload-time = "2026-04-10T00:41:21.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/76/d241a5c927433420507215df6cac1b1fa4ac0ba7a794df42a84326c68da8/google_auth-2.49.2-py3-none-any.whl", hash = "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5", size = 240638, upload-time = "2026-04-10T00:41:14.501Z" }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/99/107612bef8d24b298bb5a7c8466f908ecda791d43f9466f5c3978f5b24c1/google_auth_httplib2-0.3.1.tar.gz", hash = "sha256:0af542e815784cb64159b4469aa5d71dd41069ba93effa006e1916b1dcd88e55", size = 11152, upload-time = "2026-03-30T22:50:26.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/e9/93afb14d23a949acaa3f4e7cc51a0024671174e116e35f42850764b99634/google_auth_httplib2-0.3.1-py3-none-any.whl", hash = "sha256:682356a90ef4ba3d06548c37e9112eea6fc00395a11b0303a644c1a86abc275c", size = 9534, upload-time = "2026-03-30T22:49:03.384Z" }, +] + +[[package]] +name = "google-auth-oauthlib" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/82/62482931dcbe5266a2680d0da17096f2aab983ecb320277d9556700ce00e/google_auth_oauthlib-1.3.1.tar.gz", hash = "sha256:14c22c7b3dd3d06dbe44264144409039465effdd1eef94f7ce3710e486cc4bfa", size = 21663, upload-time = "2026-03-30T22:49:56.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl", hash = "sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8", size = 19247, upload-time = "2026-03-30T20:02:23.894Z" }, +] + +[[package]] +name = "google-drive-skill" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "google-api-python-client" }, + { name = "google-auth" }, + { name = "google-auth-oauthlib" }, +] + +[package.metadata] +requires-dist = [ + { name = "google-api-python-client", specifier = ">=2.0.0" }, + { name = "google-auth", specifier = ">=2.0.0" }, + { name = "google-auth-oauthlib", specifier = ">=1.0.0" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf", version = "6.33.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "protobuf", version = "7.34.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, +] + +[[package]] +name = "httplib2" +version = "0.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf", version = "6.33.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "protobuf", version = "7.34.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204, upload-time = "2026-03-26T22:18:57.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450, upload-time = "2026-03-26T22:13:42.927Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/0c/bd/88a687e9147329fc7e6c26a058fc52214c47190688a496bb283000a4d2a3/protobuf-6.33.6-cp39-cp39-win32.whl", hash = "sha256:bd56799fb262994b2c2faa1799693c95cc2e22c62f56fb43af311cae45d26f0e", size = 425861, upload-time = "2026-03-18T19:04:57.064Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/fab384eea064bfc3b273183e4e09bb3a3cf4ec83876b3828c09fcacbb651/protobuf-6.33.6-cp39-cp39-win_amd64.whl", hash = "sha256:f443a394af5ed23672bc6c486be138628fbe5c651ccbc536873d7da23d1868cf", size = 437109, upload-time = "2026-03-18T19:04:58.713Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "urllib3", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] diff --git a/plugins/ndf/skills/knowledge-reorg/SKILL.md b/plugins/ndf/skills/knowledge-reorg/SKILL.md new file mode 100644 index 0000000..1541f05 --- /dev/null +++ b/plugins/ndf/skills/knowledge-reorg/SKILL.md @@ -0,0 +1,269 @@ +--- +name: knowledge-reorg +description: "Reorganize AGENTS, docs, and Skills knowledge." +argument-hint: "[--target AGENTS.md|skills|docs|all] [--dry-run] [--migrate-memory]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Write + - Edit + - Glob + - Grep + - AskUserQuestion + - Task +--- + +# Knowledge Reorg Command + +AGENTS.md・Skills・docsを「AI Agent Knowledge Architecture Policy」に基づいて分析・整理する。 + +## 入力 + +$ARGUMENTS + +## ポリシー(AI Agent Knowledge Architecture) + +知識を以下の3層に分離する。**実際のパスはリポジトリやAIエージェントごとに異なる。** + +| 層 | 役割 | +|----|------| +| エントリポイント | ナビゲーション + ポリシー(軽量、300行以下推奨) | +| ドキュメント | リポジトリ知識(アーキテクチャ、モジュール説明、依存関係) | +| スキル | 実行可能なワークフロー(手順、コマンド、チェックリスト) | + +### マルチエージェント対応戦略 + +**推奨**: `AGENTS.md`を共通エントリポイントとし、各エージェント固有ファイルからインポートで参照する。 + +``` +AGENTS.md ← 共通エントリポイント(本体) +.claude/CLAUDE.md ← @../AGENTS.md でインポート +.gemini/GEMINI.md ← @../AGENTS.md でインポート +(Codex CLI) ← AGENTS.md を直接読込(設定不要) +(Kiro CLI) ← AGENTS.md を直接読込(設定不要) +``` + +#### エージェント固有ファイルの設定例 + +**Claude Code** (`.claude/CLAUDE.md`): +```markdown +@../AGENTS.md +``` + +**Gemini CLI** (`.gemini/GEMINI.md`): +```markdown +@../AGENTS.md +``` +または`.gemini/settings.json`で: +```json +{ "context": { "fileName": ["AGENTS.md", "GEMINI.md"] } } +``` + +**Codex CLI / Kiro CLI**: `AGENTS.md`をネイティブに読込。追加設定不要。 + +#### エージェント別ファイル配置リファレンス + +##### エントリポイント + +| エージェント | 固有ファイル | グローバル | AGENTS.md | +|-------------|------------|-----------|-----------| +| Claude Code | `.claude/CLAUDE.md` | `~/.claude/CLAUDE.md` | `@../AGENTS.md`でインポート | +| Kiro CLI | `.kiro/steering/*.md` | `~/.kiro/steering/*.md` | 直接読込 | +| Codex CLI | なし(AGENTS.md標準) | `~/.codex/AGENTS.md` | 直接読込 | +| Gemini CLI | `.gemini/GEMINI.md` | `~/.gemini/GEMINI.md` | `@../AGENTS.md`でインポート or settings.json | + +**補足**: +- Kiro CLIはsteering体系(`product.md`/`tech.md`/`structure.md`)で構造化指示を管理 +- Codex CLIは`AGENTS.override.md`による上書き機構あり +- Gemini CLIは`@`構文でネストインポート対応(循環検出あり、最大深度5) + +##### スキル配置 + +| エージェント | プロジェクト | ユーザー | +|-------------|------------|---------| +| Claude Code | `.claude/skills//SKILL.md` | `~/.claude/skills//SKILL.md` | +| Kiro CLI | `.kiro/skills//SKILL.md` | なし(エージェント経由) | +| Codex CLI | `.agents/skills//SKILL.md` | `~/.agents/skills//SKILL.md` | +| Gemini CLI | `.agents/skills//SKILL.md` or `.gemini/skills//SKILL.md` | `~/.agents/skills//SKILL.md` or `~/.gemini/skills//SKILL.md` | + +**共通**: 全エージェントが`SKILL.md`ファイルを採用(Agent Skillsオープンスタンダードに収束傾向)。 + +##### ドキュメント + +ドキュメントディレクトリはエージェント標準では規定されていない。`docs/`が一般的な慣例。 + +### 0) パス検出(実行時に最初に行う) + +整理を始める前に、対象リポジトリで以下を検出する: + +1. **エントリポイント**: 上表を参考に、存在するファイルを特定(複数エージェント対応の場合はすべて列挙) +2. **ドキュメントディレクトリ**: `docs/`が存在するか、なければ作成先を提案 +3. **スキルディレクトリ**: 上表を参考に、存在するディレクトリを特定 +4. **対象エージェント**: どのエージェント向けに整理するかを確認 + +検出結果を`AskUserQuestion`でユーザーに確認し、以降の作業で使用する。 + +### エントリポイントに含めるべきもの + +- リポジトリ概要 +- ドキュメントへのナビゲーション +- エージェント行動ルール +- スキルへの参照 + +### エントリポイントに含めてはいけないもの + +- 詳細なアーキテクチャ説明 +- データベーススキーマ +- 長い説明文 +- 運用手順(スキルへ) + +### ドキュメントに含めるべきもの + +- システムアーキテクチャ +- リポジトリ構造の説明 +- モジュール説明 +- 依存関係 +- インフラ概要 +- 設計思想 + +### スキルに含めるべきもの + +- ステップバイステップの手順 +- 実行コマンド +- バリデーションルール +- チェックリスト + +## 手順 + +### 1) 現状分析 + +まず「0) パス検出」でエントリポイント・ドキュメントディレクトリ・スキルディレクトリを特定した上で、以下を分析する: + +1. **エントリポイントの行数とトークン概算を計測** + - 300行以上なら「肥大化」と判定 + - 含まれている情報の種類を分類(ナビゲーション/知識/手順/ポリシー) + +2. **ドキュメントディレクトリの状態を確認** + - 存在するか + - どの程度の知識が格納されているか + +3. **スキルディレクトリの状態を確認** + - 各スキルの役割分類 + - 知識がスキルに混入していないか + +4. **Serena memoryの状態を確認**(`--migrate-memory`指定時) + - `.serena/memories/`の内容を一覧 + - 各メモリーの分類(知識→ドキュメント / 手順→スキル / 一時的→削除候補) + +### 2) 分析レポート作成 + +以下の形式でユーザーに報告する: + +```markdown +## 現状分析レポート + +### エントリポイント({検出したファイル名}) +- 行数: X行(目標: 300行以下) +- 状態: 適正 / 肥大化 +- 含まれる情報の内訳: + - ナビゲーション: X行 + - 知識(ドキュメント移動候補): X行 + - 手順(スキル移動候補): X行 + - ポリシー: X行 + +### ドキュメント({検出したパス}) +- 状態: 未作成 / 不足 / 適正 +- 不足している知識: [リスト] + +### スキル({検出したパス}) +- 状態: 適正 / 知識混入あり +- 問題のあるスキル: [リスト] + +### Serena Memory(該当時) +- 移行対象: X件 + - ドキュメント移動: X件 + - スキル移動: X件 + - 削除候補: X件 +``` + +### 3) 整理計画の提案 + +`AskUserQuestion`で以下を確認: + +1. **対象スコープ**: AGENTS.md / skills / docs / all +2. **実行モード**: dry-run(レポートのみ) / 実行(実際に変更) +3. **Serena memory移行**: する / しない + +### 4) 実行(dry-runでない場合) + +#### エントリポイント整理 +- 詳細な知識をドキュメントディレクトリに抽出 +- 手順をスキルディレクトリに抽出 +- エントリポイントをナビゲーション+ポリシーのみに圧縮 +- ドキュメントへのリンクを追加 + +#### ドキュメント整理 +- 必要なサブディレクトリを作成(architecture/, modules/など) +- エントリポイントから抽出した知識を配置 +- Serena memoryから移行(該当時) + +#### スキル整理 +- 知識が混入しているスキルを特定 +- 知識部分をドキュメントに抽出し、スキルからはリンク参照に変更 + +#### Serena memory移行(該当時) +以下のルールで再配置: + +| Memory種別 | 移行先 | +|-----------|--------| +| リポジトリ構造 | ドキュメント | +| アーキテクチャ説明 | ドキュメント | +| モジュール説明 | ドキュメント | +| 依存関係 | ドキュメント | +| エージェント手順 | スキル | +| 一時的な調査結果 | 削除 | +| タスク履歴 | 削除 | + +### 5) 検証 + +整理後に以下を検証: +- エントリポイントが300行以下か +- 3層構造(エントリポイント/ドキュメント/スキル)が守られているか +- リンク切れがないか +- マルチエージェント互換性(Claude Code, Codex CLI, Gemini CLI, Kiro) + +### 6) 完了報告 + +```markdown +## 整理完了レポート + +### 変更サマリー +- エントリポイント: X行 → Y行(Z行削減) +- ドキュメント: X件のファイルを追加/更新 +- スキル: X件のスキルを整理 +- Serena memory: X件を移行、Y件を削除候補 + +### 作成/変更したファイル +- [ファイル一覧] + +### 次のステップ +- [ ] 変更内容のレビュー +- [ ] 不要なSerena memoryの削除(手動確認推奨) +- [ ] コミット&PR作成(`/ndf:pr`) +``` + +## マルチエージェント互換性 + +整理時は以下を遵守: +- ツール固有のフォーマットを避ける +- Serena固有の知識ストレージに依存しない +- プレーンMarkdownを使用(図はmermaidで記述) +- 安定したディレクトリ構造を維持 + +## 注意事項 + +- `--dry-run`指定時はレポートのみ出力し、ファイルを変更しない +- 大規模な変更前は必ずユーザー確認を取る +- 既存のリンクやパス参照を壊さないよう注意する +- AGENTS.md内のClaude Code固有設定(CLAUDE.md参照など)は保持する diff --git a/plugins/ndf/skills/mcp-builder/LICENSE.txt b/plugins/ndf/skills/mcp-builder/LICENSE.txt new file mode 100644 index 0000000..4f881c5 --- /dev/null +++ b/plugins/ndf/skills/mcp-builder/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Anthropic, PBC. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/plugins/ndf/skills/mcp-builder/SKILL.md b/plugins/ndf/skills/mcp-builder/SKILL.md new file mode 100644 index 0000000..1290f77 --- /dev/null +++ b/plugins/ndf/skills/mcp-builder/SKILL.md @@ -0,0 +1,236 @@ +--- +name: mcp-builder +description: "Build high-quality MCP servers." +license: Complete terms in LICENSE.txt +--- + +# MCP Server Development Guide + +## Overview + +Create MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. The quality of an MCP server is measured by how well it enables LLMs to accomplish real-world tasks. + +--- + +# Process + +## 🚀 High-Level Workflow + +Creating a high-quality MCP server involves four main phases: + +### Phase 1: Deep Research and Planning + +#### 1.1 Understand Modern MCP Design + +**API Coverage vs. Workflow Tools:** +Balance comprehensive API endpoint coverage with specialized workflow tools. Workflow tools can be more convenient for specific tasks, while comprehensive coverage gives agents flexibility to compose operations. Performance varies by client—some clients benefit from code execution that combines basic tools, while others work better with higher-level workflows. When uncertain, prioritize comprehensive API coverage. + +**Tool Naming and Discoverability:** +Clear, descriptive tool names help agents find the right tools quickly. Use consistent prefixes (e.g., `github_create_issue`, `github_list_repos`) and action-oriented naming. + +**Context Management:** +Agents benefit from concise tool descriptions and the ability to filter/paginate results. Design tools that return focused, relevant data. Some clients support code execution which can help agents filter and process data efficiently. + +**Actionable Error Messages:** +Error messages should guide agents toward solutions with specific suggestions and next steps. + +#### 1.2 Study MCP Protocol Documentation + +**Navigate the MCP specification:** + +Start with the sitemap to find relevant pages: `https://modelcontextprotocol.io/sitemap.xml` + +Then fetch specific pages with `.md` suffix for markdown format (e.g., `https://modelcontextprotocol.io/specification/draft.md`). + +Key pages to review: +- Specification overview and architecture +- Transport mechanisms (streamable HTTP, stdio) +- Tool, resource, and prompt definitions + +#### 1.3 Study Framework Documentation + +**Recommended stack:** +- **Language**: TypeScript (high-quality SDK support and good compatibility in many execution environments e.g. MCPB. Plus AI models are good at generating TypeScript code, benefiting from its broad usage, static typing and good linting tools) +- **Transport**: Streamable HTTP for remote servers, using stateless JSON (simpler to scale and maintain, as opposed to stateful sessions and streaming responses). stdio for local servers. + +**Load framework documentation:** + +- **MCP Best Practices**: [📋 View Best Practices](./reference/mcp_best_practices.md) - Core guidelines + +**For TypeScript (recommended):** +- **TypeScript SDK**: Use WebFetch to load `https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/README.md` +- [⚡ TypeScript Guide](./reference/node_mcp_server.md) - TypeScript patterns and examples + +**For Python:** +- **Python SDK**: Use WebFetch to load `https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md` +- [🐍 Python Guide](./reference/python_mcp_server.md) - Python patterns and examples + +#### 1.4 Plan Your Implementation + +**Understand the API:** +Review the service's API documentation to identify key endpoints, authentication requirements, and data models. Use web search and WebFetch as needed. + +**Tool Selection:** +Prioritize comprehensive API coverage. List endpoints to implement, starting with the most common operations. + +--- + +### Phase 2: Implementation + +#### 2.1 Set Up Project Structure + +See language-specific guides for project setup: +- [⚡ TypeScript Guide](./reference/node_mcp_server.md) - Project structure, package.json, tsconfig.json +- [🐍 Python Guide](./reference/python_mcp_server.md) - Module organization, dependencies + +#### 2.2 Implement Core Infrastructure + +Create shared utilities: +- API client with authentication +- Error handling helpers +- Response formatting (JSON/Markdown) +- Pagination support + +#### 2.3 Implement Tools + +For each tool: + +**Input Schema:** +- Use Zod (TypeScript) or Pydantic (Python) +- Include constraints and clear descriptions +- Add examples in field descriptions + +**Output Schema:** +- Define `outputSchema` where possible for structured data +- Use `structuredContent` in tool responses (TypeScript SDK feature) +- Helps clients understand and process tool outputs + +**Tool Description:** +- Concise summary of functionality +- Parameter descriptions +- Return type schema + +**Implementation:** +- Async/await for I/O operations +- Proper error handling with actionable messages +- Support pagination where applicable +- Return both text content and structured data when using modern SDKs + +**Annotations:** +- `readOnlyHint`: true/false +- `destructiveHint`: true/false +- `idempotentHint`: true/false +- `openWorldHint`: true/false + +--- + +### Phase 3: Review and Test + +#### 3.1 Code Quality + +Review for: +- No duplicated code (DRY principle) +- Consistent error handling +- Full type coverage +- Clear tool descriptions + +#### 3.2 Build and Test + +**TypeScript:** +- Run `npm run build` to verify compilation +- Test with MCP Inspector: `npx @modelcontextprotocol/inspector` + +**Python:** +- Verify syntax: `python -m py_compile your_server.py` +- Test with MCP Inspector + +See language-specific guides for detailed testing approaches and quality checklists. + +--- + +### Phase 4: Create Evaluations + +After implementing your MCP server, create comprehensive evaluations to test its effectiveness. + +**Load [✅ Evaluation Guide](./reference/evaluation.md) for complete evaluation guidelines.** + +#### 4.1 Understand Evaluation Purpose + +Use evaluations to test whether LLMs can effectively use your MCP server to answer realistic, complex questions. + +#### 4.2 Create 10 Evaluation Questions + +To create effective evaluations, follow the process outlined in the evaluation guide: + +1. **Tool Inspection**: List available tools and understand their capabilities +2. **Content Exploration**: Use READ-ONLY operations to explore available data +3. **Question Generation**: Create 10 complex, realistic questions +4. **Answer Verification**: Solve each question yourself to verify answers + +#### 4.3 Evaluation Requirements + +Ensure each question is: +- **Independent**: Not dependent on other questions +- **Read-only**: Only non-destructive operations required +- **Complex**: Requiring multiple tool calls and deep exploration +- **Realistic**: Based on real use cases humans would care about +- **Verifiable**: Single, clear answer that can be verified by string comparison +- **Stable**: Answer won't change over time + +#### 4.4 Output Format + +Create an XML file with this structure: + +```xml + + + Find discussions about AI model launches with animal codenames. One model needed a specific safety designation that uses the format ASL-X. What number X was being determined for the model named after a spotted wild cat? + 3 + + + +``` + +--- + +# Reference Files + +## 📚 Documentation Library + +Load these resources as needed during development: + +### Core MCP Documentation (Load First) +- **MCP Protocol**: Start with sitemap at `https://modelcontextprotocol.io/sitemap.xml`, then fetch specific pages with `.md` suffix +- [📋 MCP Best Practices](./reference/mcp_best_practices.md) - Universal MCP guidelines including: + - Server and tool naming conventions + - Response format guidelines (JSON vs Markdown) + - Pagination best practices + - Transport selection (streamable HTTP vs stdio) + - Security and error handling standards + +### SDK Documentation (Load During Phase 1/2) +- **Python SDK**: Fetch from `https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md` +- **TypeScript SDK**: Fetch from `https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/README.md` + +### Language-Specific Implementation Guides (Load During Phase 2) +- [🐍 Python Implementation Guide](./reference/python_mcp_server.md) - Complete Python/FastMCP guide with: + - Server initialization patterns + - Pydantic model examples + - Tool registration with `@mcp.tool` + - Complete working examples + - Quality checklist + +- [⚡ TypeScript Implementation Guide](./reference/node_mcp_server.md) - Complete TypeScript guide with: + - Project structure + - Zod schema patterns + - Tool registration with `server.registerTool` + - Complete working examples + - Quality checklist + +### Evaluation Guide (Load During Phase 4) +- [✅ Evaluation Guide](./reference/evaluation.md) - Complete evaluation creation guide with: + - Question creation guidelines + - Answer verification strategies + - XML format specifications + - Example questions and answers + - Running an evaluation with the provided scripts diff --git a/plugins/ndf/skills/mcp-builder/reference/evaluation.md b/plugins/ndf/skills/mcp-builder/reference/evaluation.md new file mode 100644 index 0000000..87e9bb7 --- /dev/null +++ b/plugins/ndf/skills/mcp-builder/reference/evaluation.md @@ -0,0 +1,602 @@ +# MCP Server Evaluation Guide + +## Overview + +This document provides guidance on creating comprehensive evaluations for MCP servers. Evaluations test whether LLMs can effectively use your MCP server to answer realistic, complex questions using only the tools provided. + +--- + +## Quick Reference + +### Evaluation Requirements +- Create 10 human-readable questions +- Questions must be READ-ONLY, INDEPENDENT, NON-DESTRUCTIVE +- Each question requires multiple tool calls (potentially dozens) +- Answers must be single, verifiable values +- Answers must be STABLE (won't change over time) + +### Output Format +```xml + + + Your question here + Single verifiable answer + + +``` + +--- + +## Purpose of Evaluations + +The measure of quality of an MCP server is NOT how well or comprehensively the server implements tools, but how well these implementations (input/output schemas, docstrings/descriptions, functionality) enable LLMs with no other context and access ONLY to the MCP servers to answer realistic and difficult questions. + +## Evaluation Overview + +Create 10 human-readable questions requiring ONLY READ-ONLY, INDEPENDENT, NON-DESTRUCTIVE, and IDEMPOTENT operations to answer. Each question should be: +- Realistic +- Clear and concise +- Unambiguous +- Complex, requiring potentially dozens of tool calls or steps +- Answerable with a single, verifiable value that you identify in advance + +## Question Guidelines + +### Core Requirements + +1. **Questions MUST be independent** + - Each question should NOT depend on the answer to any other question + - Should not assume prior write operations from processing another question + +2. **Questions MUST require ONLY NON-DESTRUCTIVE AND IDEMPOTENT tool use** + - Should not instruct or require modifying state to arrive at the correct answer + +3. **Questions must be REALISTIC, CLEAR, CONCISE, and COMPLEX** + - Must require another LLM to use multiple (potentially dozens of) tools or steps to answer + +### Complexity and Depth + +4. **Questions must require deep exploration** + - Consider multi-hop questions requiring multiple sub-questions and sequential tool calls + - Each step should benefit from information found in previous questions + +5. **Questions may require extensive paging** + - May need paging through multiple pages of results + - May require querying old data (1-2 years out-of-date) to find niche information + - The questions must be DIFFICULT + +6. **Questions must require deep understanding** + - Rather than surface-level knowledge + - May pose complex ideas as True/False questions requiring evidence + - May use multiple-choice format where LLM must search different hypotheses + +7. **Questions must not be solvable with straightforward keyword search** + - Do not include specific keywords from the target content + - Use synonyms, related concepts, or paraphrases + - Require multiple searches, analyzing multiple related items, extracting context, then deriving the answer + +### Tool Testing + +8. **Questions should stress-test tool return values** + - May elicit tools returning large JSON objects or lists, overwhelming the LLM + - Should require understanding multiple modalities of data: + - IDs and names + - Timestamps and datetimes (months, days, years, seconds) + - File IDs, names, extensions, and mimetypes + - URLs, GIDs, etc. + - Should probe the tool's ability to return all useful forms of data + +9. **Questions should MOSTLY reflect real human use cases** + - The kinds of information retrieval tasks that HUMANS assisted by an LLM would care about + +10. **Questions may require dozens of tool calls** + - This challenges LLMs with limited context + - Encourages MCP server tools to reduce information returned + +11. **Include ambiguous questions** + - May be ambiguous OR require difficult decisions on which tools to call + - Force the LLM to potentially make mistakes or misinterpret + - Ensure that despite AMBIGUITY, there is STILL A SINGLE VERIFIABLE ANSWER + +### Stability + +12. **Questions must be designed so the answer DOES NOT CHANGE** + - Do not ask questions that rely on "current state" which is dynamic + - For example, do not count: + - Number of reactions to a post + - Number of replies to a thread + - Number of members in a channel + +13. **DO NOT let the MCP server RESTRICT the kinds of questions you create** + - Create challenging and complex questions + - Some may not be solvable with the available MCP server tools + - Questions may require specific output formats (datetime vs. epoch time, JSON vs. MARKDOWN) + - Questions may require dozens of tool calls to complete + +## Answer Guidelines + +### Verification + +1. **Answers must be VERIFIABLE via direct string comparison** + - If the answer can be re-written in many formats, clearly specify the output format in the QUESTION + - Examples: "Use YYYY/MM/DD.", "Respond True or False.", "Answer A, B, C, or D and nothing else." + - Answer should be a single VERIFIABLE value such as: + - User ID, user name, display name, first name, last name + - Channel ID, channel name + - Message ID, string + - URL, title + - Numerical quantity + - Timestamp, datetime + - Boolean (for True/False questions) + - Email address, phone number + - File ID, file name, file extension + - Multiple choice answer + - Answers must not require special formatting or complex, structured output + - Answer will be verified using DIRECT STRING COMPARISON + +### Readability + +2. **Answers should generally prefer HUMAN-READABLE formats** + - Examples: names, first name, last name, datetime, file name, message string, URL, yes/no, true/false, a/b/c/d + - Rather than opaque IDs (though IDs are acceptable) + - The VAST MAJORITY of answers should be human-readable + +### Stability + +3. **Answers must be STABLE/STATIONARY** + - Look at old content (e.g., conversations that have ended, projects that have launched, questions answered) + - Create QUESTIONS based on "closed" concepts that will always return the same answer + - Questions may ask to consider a fixed time window to insulate from non-stationary answers + - Rely on context UNLIKELY to change + - Example: if finding a paper name, be SPECIFIC enough so answer is not confused with papers published later + +4. **Answers must be CLEAR and UNAMBIGUOUS** + - Questions must be designed so there is a single, clear answer + - Answer can be derived from using the MCP server tools + +### Diversity + +5. **Answers must be DIVERSE** + - Answer should be a single VERIFIABLE value in diverse modalities and formats + - User concept: user ID, user name, display name, first name, last name, email address, phone number + - Channel concept: channel ID, channel name, channel topic + - Message concept: message ID, message string, timestamp, month, day, year + +6. **Answers must NOT be complex structures** + - Not a list of values + - Not a complex object + - Not a list of IDs or strings + - Not natural language text + - UNLESS the answer can be straightforwardly verified using DIRECT STRING COMPARISON + - And can be realistically reproduced + - It should be unlikely that an LLM would return the same list in any other order or format + +## Evaluation Process + +### Step 1: Documentation Inspection + +Read the documentation of the target API to understand: +- Available endpoints and functionality +- If ambiguity exists, fetch additional information from the web +- Parallelize this step AS MUCH AS POSSIBLE +- Ensure each subagent is ONLY examining documentation from the file system or on the web + +### Step 2: Tool Inspection + +List the tools available in the MCP server: +- Inspect the MCP server directly +- Understand input/output schemas, docstrings, and descriptions +- WITHOUT calling the tools themselves at this stage + +### Step 3: Developing Understanding + +Repeat steps 1 & 2 until you have a good understanding: +- Iterate multiple times +- Think about the kinds of tasks you want to create +- Refine your understanding +- At NO stage should you READ the code of the MCP server implementation itself +- Use your intuition and understanding to create reasonable, realistic, but VERY challenging tasks + +### Step 4: Read-Only Content Inspection + +After understanding the API and tools, USE the MCP server tools: +- Inspect content using READ-ONLY and NON-DESTRUCTIVE operations ONLY +- Goal: identify specific content (e.g., users, channels, messages, projects, tasks) for creating realistic questions +- Should NOT call any tools that modify state +- Will NOT read the code of the MCP server implementation itself +- Parallelize this step with individual sub-agents pursuing independent explorations +- Ensure each subagent is only performing READ-ONLY, NON-DESTRUCTIVE, and IDEMPOTENT operations +- BE CAREFUL: SOME TOOLS may return LOTS OF DATA which would cause you to run out of CONTEXT +- Make INCREMENTAL, SMALL, AND TARGETED tool calls for exploration +- In all tool call requests, use the `limit` parameter to limit results (<10) +- Use pagination + +### Step 5: Task Generation + +After inspecting the content, create 10 human-readable questions: +- An LLM should be able to answer these with the MCP server +- Follow all question and answer guidelines above + +## Output Format + +Each QA pair consists of a question and an answer. The output should be an XML file with this structure: + +```xml + + + Find the project created in Q2 2024 with the highest number of completed tasks. What is the project name? + Website Redesign + + + Search for issues labeled as "bug" that were closed in March 2024. Which user closed the most issues? Provide their username. + sarah_dev + + + Look for pull requests that modified files in the /api directory and were merged between January 1 and January 31, 2024. How many different contributors worked on these PRs? + 7 + + + Find the repository with the most stars that was created before 2023. What is the repository name? + data-pipeline + + +``` + +## Evaluation Examples + +### Good Questions + +**Example 1: Multi-hop question requiring deep exploration (GitHub MCP)** +```xml + + Find the repository that was archived in Q3 2023 and had previously been the most forked project in the organization. What was the primary programming language used in that repository? + Python + +``` + +This question is good because: +- Requires multiple searches to find archived repositories +- Needs to identify which had the most forks before archival +- Requires examining repository details for the language +- Answer is a simple, verifiable value +- Based on historical (closed) data that won't change + +**Example 2: Requires understanding context without keyword matching (Project Management MCP)** +```xml + + Locate the initiative focused on improving customer onboarding that was completed in late 2023. The project lead created a retrospective document after completion. What was the lead's role title at that time? + Product Manager + +``` + +This question is good because: +- Doesn't use specific project name ("initiative focused on improving customer onboarding") +- Requires finding completed projects from specific timeframe +- Needs to identify the project lead and their role +- Requires understanding context from retrospective documents +- Answer is human-readable and stable +- Based on completed work (won't change) + +**Example 3: Complex aggregation requiring multiple steps (Issue Tracker MCP)** +```xml + + Among all bugs reported in January 2024 that were marked as critical priority, which assignee resolved the highest percentage of their assigned bugs within 48 hours? Provide the assignee's username. + alex_eng + +``` + +This question is good because: +- Requires filtering bugs by date, priority, and status +- Needs to group by assignee and calculate resolution rates +- Requires understanding timestamps to determine 48-hour windows +- Tests pagination (potentially many bugs to process) +- Answer is a single username +- Based on historical data from specific time period + +**Example 4: Requires synthesis across multiple data types (CRM MCP)** +```xml + + Find the account that upgraded from the Starter to Enterprise plan in Q4 2023 and had the highest annual contract value. What industry does this account operate in? + Healthcare + +``` + +This question is good because: +- Requires understanding subscription tier changes +- Needs to identify upgrade events in specific timeframe +- Requires comparing contract values +- Must access account industry information +- Answer is simple and verifiable +- Based on completed historical transactions + +### Poor Questions + +**Example 1: Answer changes over time** +```xml + + How many open issues are currently assigned to the engineering team? + 47 + +``` + +This question is poor because: +- The answer will change as issues are created, closed, or reassigned +- Not based on stable/stationary data +- Relies on "current state" which is dynamic + +**Example 2: Too easy with keyword search** +```xml + + Find the pull request with title "Add authentication feature" and tell me who created it. + developer123 + +``` + +This question is poor because: +- Can be solved with a straightforward keyword search for exact title +- Doesn't require deep exploration or understanding +- No synthesis or analysis needed + +**Example 3: Ambiguous answer format** +```xml + + List all the repositories that have Python as their primary language. + repo1, repo2, repo3, data-pipeline, ml-tools + +``` + +This question is poor because: +- Answer is a list that could be returned in any order +- Difficult to verify with direct string comparison +- LLM might format differently (JSON array, comma-separated, newline-separated) +- Better to ask for a specific aggregate (count) or superlative (most stars) + +## Verification Process + +After creating evaluations: + +1. **Examine the XML file** to understand the schema +2. **Load each task instruction** and in parallel using the MCP server and tools, identify the correct answer by attempting to solve the task YOURSELF +3. **Flag any operations** that require WRITE or DESTRUCTIVE operations +4. **Accumulate all CORRECT answers** and replace any incorrect answers in the document +5. **Remove any ``** that require WRITE or DESTRUCTIVE operations + +Remember to parallelize solving tasks to avoid running out of context, then accumulate all answers and make changes to the file at the end. + +## Tips for Creating Quality Evaluations + +1. **Think Hard and Plan Ahead** before generating tasks +2. **Parallelize Where Opportunity Arises** to speed up the process and manage context +3. **Focus on Realistic Use Cases** that humans would actually want to accomplish +4. **Create Challenging Questions** that test the limits of the MCP server's capabilities +5. **Ensure Stability** by using historical data and closed concepts +6. **Verify Answers** by solving the questions yourself using the MCP server tools +7. **Iterate and Refine** based on what you learn during the process + +--- + +# Running Evaluations + +After creating your evaluation file, you can use the provided evaluation harness to test your MCP server. + +## Setup + +1. **Install Dependencies** + + ```bash + pip install -r scripts/requirements.txt + ``` + + Or install manually: + ```bash + pip install anthropic mcp + ``` + +2. **Set API Key** + + ```bash + export ANTHROPIC_API_KEY=your_api_key_here + ``` + +## Evaluation File Format + +Evaluation files use XML format with `` elements: + +```xml + + + Find the project created in Q2 2024 with the highest number of completed tasks. What is the project name? + Website Redesign + + + Search for issues labeled as "bug" that were closed in March 2024. Which user closed the most issues? Provide their username. + sarah_dev + + +``` + +## Running Evaluations + +The evaluation script (`scripts/evaluation.py`) supports three transport types: + +**Important:** +- **stdio transport**: The evaluation script automatically launches and manages the MCP server process for you. Do not run the server manually. +- **sse/http transports**: You must start the MCP server separately before running the evaluation. The script connects to the already-running server at the specified URL. + +### 1. Local STDIO Server + +For locally-run MCP servers (script launches the server automatically): + +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a my_mcp_server.py \ + evaluation.xml +``` + +With environment variables: +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a my_mcp_server.py \ + -e API_KEY=abc123 \ + -e DEBUG=true \ + evaluation.xml +``` + +### 2. Server-Sent Events (SSE) + +For SSE-based MCP servers (you must start the server first): + +```bash +python scripts/evaluation.py \ + -t sse \ + -u https://example.com/mcp \ + -H "Authorization: Bearer token123" \ + -H "X-Custom-Header: value" \ + evaluation.xml +``` + +### 3. HTTP (Streamable HTTP) + +For HTTP-based MCP servers (you must start the server first): + +```bash +python scripts/evaluation.py \ + -t http \ + -u https://example.com/mcp \ + -H "Authorization: Bearer token123" \ + evaluation.xml +``` + +## Command-Line Options + +``` +usage: evaluation.py [-h] [-t {stdio,sse,http}] [-m MODEL] [-c COMMAND] + [-a ARGS [ARGS ...]] [-e ENV [ENV ...]] [-u URL] + [-H HEADERS [HEADERS ...]] [-o OUTPUT] + eval_file + +positional arguments: + eval_file Path to evaluation XML file + +optional arguments: + -h, --help Show help message + -t, --transport Transport type: stdio, sse, or http (default: stdio) + -m, --model Claude model to use (default: claude-3-7-sonnet-20250219) + -o, --output Output file for report (default: print to stdout) + +stdio options: + -c, --command Command to run MCP server (e.g., python, node) + -a, --args Arguments for the command (e.g., server.py) + -e, --env Environment variables in KEY=VALUE format + +sse/http options: + -u, --url MCP server URL + -H, --header HTTP headers in 'Key: Value' format +``` + +## Output + +The evaluation script generates a detailed report including: + +- **Summary Statistics**: + - Accuracy (correct/total) + - Average task duration + - Average tool calls per task + - Total tool calls + +- **Per-Task Results**: + - Prompt and expected response + - Actual response from the agent + - Whether the answer was correct (✅/❌) + - Duration and tool call details + - Agent's summary of its approach + - Agent's feedback on the tools + +### Save Report to File + +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a my_server.py \ + -o evaluation_report.md \ + evaluation.xml +``` + +## Complete Example Workflow + +Here's a complete example of creating and running an evaluation: + +1. **Create your evaluation file** (`my_evaluation.xml`): + +```xml + + + Find the user who created the most issues in January 2024. What is their username? + alice_developer + + + Among all pull requests merged in Q1 2024, which repository had the highest number? Provide the repository name. + backend-api + + + Find the project that was completed in December 2023 and had the longest duration from start to finish. How many days did it take? + 127 + + +``` + +2. **Install dependencies**: + +```bash +pip install -r scripts/requirements.txt +export ANTHROPIC_API_KEY=your_api_key +``` + +3. **Run evaluation**: + +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a github_mcp_server.py \ + -e GITHUB_TOKEN=ghp_xxx \ + -o github_eval_report.md \ + my_evaluation.xml +``` + +4. **Review the report** in `github_eval_report.md` to: + - See which questions passed/failed + - Read the agent's feedback on your tools + - Identify areas for improvement + - Iterate on your MCP server design + +## Troubleshooting + +### Connection Errors + +If you get connection errors: +- **STDIO**: Verify the command and arguments are correct +- **SSE/HTTP**: Check the URL is accessible and headers are correct +- Ensure any required API keys are set in environment variables or headers + +### Low Accuracy + +If many evaluations fail: +- Review the agent's feedback for each task +- Check if tool descriptions are clear and comprehensive +- Verify input parameters are well-documented +- Consider whether tools return too much or too little data +- Ensure error messages are actionable + +### Timeout Issues + +If tasks are timing out: +- Use a more capable model (e.g., `claude-3-7-sonnet-20250219`) +- Check if tools are returning too much data +- Verify pagination is working correctly +- Consider simplifying complex questions \ No newline at end of file diff --git a/plugins/ndf/skills/mcp-builder/reference/mcp_best_practices.md b/plugins/ndf/skills/mcp-builder/reference/mcp_best_practices.md new file mode 100644 index 0000000..b9d343c --- /dev/null +++ b/plugins/ndf/skills/mcp-builder/reference/mcp_best_practices.md @@ -0,0 +1,249 @@ +# MCP Server Best Practices + +## Quick Reference + +### Server Naming +- **Python**: `{service}_mcp` (e.g., `slack_mcp`) +- **Node/TypeScript**: `{service}-mcp-server` (e.g., `slack-mcp-server`) + +### Tool Naming +- Use snake_case with service prefix +- Format: `{service}_{action}_{resource}` +- Example: `slack_send_message`, `github_create_issue` + +### Response Formats +- Support both JSON and Markdown formats +- JSON for programmatic processing +- Markdown for human readability + +### Pagination +- Always respect `limit` parameter +- Return `has_more`, `next_offset`, `total_count` +- Default to 20-50 items + +### Transport +- **Streamable HTTP**: For remote servers, multi-client scenarios +- **stdio**: For local integrations, command-line tools +- Avoid SSE (deprecated in favor of streamable HTTP) + +--- + +## Server Naming Conventions + +Follow these standardized naming patterns: + +**Python**: Use format `{service}_mcp` (lowercase with underscores) +- Examples: `slack_mcp`, `github_mcp`, `jira_mcp` + +**Node/TypeScript**: Use format `{service}-mcp-server` (lowercase with hyphens) +- Examples: `slack-mcp-server`, `github-mcp-server`, `jira-mcp-server` + +The name should be general, descriptive of the service being integrated, easy to infer from the task description, and without version numbers. + +--- + +## Tool Naming and Design + +### Tool Naming + +1. **Use snake_case**: `search_users`, `create_project`, `get_channel_info` +2. **Include service prefix**: Anticipate that your MCP server may be used alongside other MCP servers + - Use `slack_send_message` instead of just `send_message` + - Use `github_create_issue` instead of just `create_issue` +3. **Be action-oriented**: Start with verbs (get, list, search, create, etc.) +4. **Be specific**: Avoid generic names that could conflict with other servers + +### Tool Design + +- Tool descriptions must narrowly and unambiguously describe functionality +- Descriptions must precisely match actual functionality +- Provide tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) +- Keep tool operations focused and atomic + +--- + +## Response Formats + +All tools that return data should support multiple formats: + +### JSON Format (`response_format="json"`) +- Machine-readable structured data +- Include all available fields and metadata +- Consistent field names and types +- Use for programmatic processing + +### Markdown Format (`response_format="markdown"`, typically default) +- Human-readable formatted text +- Use headers, lists, and formatting for clarity +- Convert timestamps to human-readable format +- Show display names with IDs in parentheses +- Omit verbose metadata + +--- + +## Pagination + +For tools that list resources: + +- **Always respect the `limit` parameter** +- **Implement pagination**: Use `offset` or cursor-based pagination +- **Return pagination metadata**: Include `has_more`, `next_offset`/`next_cursor`, `total_count` +- **Never load all results into memory**: Especially important for large datasets +- **Default to reasonable limits**: 20-50 items is typical + +Example pagination response: +```json +{ + "total": 150, + "count": 20, + "offset": 0, + "items": [...], + "has_more": true, + "next_offset": 20 +} +``` + +--- + +## Transport Options + +### Streamable HTTP + +**Best for**: Remote servers, web services, multi-client scenarios + +**Characteristics**: +- Bidirectional communication over HTTP +- Supports multiple simultaneous clients +- Can be deployed as a web service +- Enables server-to-client notifications + +**Use when**: +- Serving multiple clients simultaneously +- Deploying as a cloud service +- Integration with web applications + +### stdio + +**Best for**: Local integrations, command-line tools + +**Characteristics**: +- Standard input/output stream communication +- Simple setup, no network configuration needed +- Runs as a subprocess of the client + +**Use when**: +- Building tools for local development environments +- Integrating with desktop applications +- Single-user, single-session scenarios + +**Note**: stdio servers should NOT log to stdout (use stderr for logging) + +### Transport Selection + +| Criterion | stdio | Streamable HTTP | +|-----------|-------|-----------------| +| **Deployment** | Local | Remote | +| **Clients** | Single | Multiple | +| **Complexity** | Low | Medium | +| **Real-time** | No | Yes | + +--- + +## Security Best Practices + +### Authentication and Authorization + +**OAuth 2.1**: +- Use secure OAuth 2.1 with certificates from recognized authorities +- Validate access tokens before processing requests +- Only accept tokens specifically intended for your server + +**API Keys**: +- Store API keys in environment variables, never in code +- Validate keys on server startup +- Provide clear error messages when authentication fails + +### Input Validation + +- Sanitize file paths to prevent directory traversal +- Validate URLs and external identifiers +- Check parameter sizes and ranges +- Prevent command injection in system calls +- Use schema validation (Pydantic/Zod) for all inputs + +### Error Handling + +- Don't expose internal errors to clients +- Log security-relevant errors server-side +- Provide helpful but not revealing error messages +- Clean up resources after errors + +### DNS Rebinding Protection + +For streamable HTTP servers running locally: +- Enable DNS rebinding protection +- Validate the `Origin` header on all incoming connections +- Bind to `127.0.0.1` rather than `0.0.0.0` + +--- + +## Tool Annotations + +Provide annotations to help clients understand tool behavior: + +| Annotation | Type | Default | Description | +|-----------|------|---------|-------------| +| `readOnlyHint` | boolean | false | Tool does not modify its environment | +| `destructiveHint` | boolean | true | Tool may perform destructive updates | +| `idempotentHint` | boolean | false | Repeated calls with same args have no additional effect | +| `openWorldHint` | boolean | true | Tool interacts with external entities | + +**Important**: Annotations are hints, not security guarantees. Clients should not make security-critical decisions based solely on annotations. + +--- + +## Error Handling + +- Use standard JSON-RPC error codes +- Report tool errors within result objects (not protocol-level errors) +- Provide helpful, specific error messages with suggested next steps +- Don't expose internal implementation details +- Clean up resources properly on errors + +Example error handling: +```typescript +try { + const result = performOperation(); + return { content: [{ type: "text", text: result }] }; +} catch (error) { + return { + isError: true, + content: [{ + type: "text", + text: `Error: ${error.message}. Try using filter='active_only' to reduce results.` + }] + }; +} +``` + +--- + +## Testing Requirements + +Comprehensive testing should cover: + +- **Functional testing**: Verify correct execution with valid/invalid inputs +- **Integration testing**: Test interaction with external systems +- **Security testing**: Validate auth, input sanitization, rate limiting +- **Performance testing**: Check behavior under load, timeouts +- **Error handling**: Ensure proper error reporting and cleanup + +--- + +## Documentation Requirements + +- Provide clear documentation of all tools and capabilities +- Include working examples (at least 3 per major feature) +- Document security considerations +- Specify required permissions and access levels +- Document rate limits and performance characteristics diff --git a/plugins/ndf/skills/mcp-builder/reference/node_mcp_server.md b/plugins/ndf/skills/mcp-builder/reference/node_mcp_server.md new file mode 100644 index 0000000..f6e5df9 --- /dev/null +++ b/plugins/ndf/skills/mcp-builder/reference/node_mcp_server.md @@ -0,0 +1,970 @@ +# Node/TypeScript MCP Server Implementation Guide + +## Overview + +This document provides Node/TypeScript-specific best practices and examples for implementing MCP servers using the MCP TypeScript SDK. It covers project structure, server setup, tool registration patterns, input validation with Zod, error handling, and complete working examples. + +--- + +## Quick Reference + +### Key Imports +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import express from "express"; +import { z } from "zod"; +``` + +### Server Initialization +```typescript +const server = new McpServer({ + name: "service-mcp-server", + version: "1.0.0" +}); +``` + +### Tool Registration Pattern +```typescript +server.registerTool( + "tool_name", + { + title: "Tool Display Name", + description: "What the tool does", + inputSchema: { param: z.string() }, + outputSchema: { result: z.string() } + }, + async ({ param }) => { + const output = { result: `Processed: ${param}` }; + return { + content: [{ type: "text", text: JSON.stringify(output) }], + structuredContent: output // Modern pattern for structured data + }; + } +); +``` + +--- + +## MCP TypeScript SDK + +The official MCP TypeScript SDK provides: +- `McpServer` class for server initialization +- `registerTool` method for tool registration +- Zod schema integration for runtime input validation +- Type-safe tool handler implementations + +**IMPORTANT - Use Modern APIs Only:** +- **DO use**: `server.registerTool()`, `server.registerResource()`, `server.registerPrompt()` +- **DO NOT use**: Old deprecated APIs such as `server.tool()`, `server.setRequestHandler(ListToolsRequestSchema, ...)`, or manual handler registration +- The `register*` methods provide better type safety, automatic schema handling, and are the recommended approach + +See the MCP SDK documentation in the references for complete details. + +## Server Naming Convention + +Node/TypeScript MCP servers must follow this naming pattern: +- **Format**: `{service}-mcp-server` (lowercase with hyphens) +- **Examples**: `github-mcp-server`, `jira-mcp-server`, `stripe-mcp-server` + +The name should be: +- General (not tied to specific features) +- Descriptive of the service/API being integrated +- Easy to infer from the task description +- Without version numbers or dates + +## Project Structure + +Create the following structure for Node/TypeScript MCP servers: + +``` +{service}-mcp-server/ +├── package.json +├── tsconfig.json +├── README.md +├── src/ +│ ├── index.ts # Main entry point with McpServer initialization +│ ├── types.ts # TypeScript type definitions and interfaces +│ ├── tools/ # Tool implementations (one file per domain) +│ ├── services/ # API clients and shared utilities +│ ├── schemas/ # Zod validation schemas +│ └── constants.ts # Shared constants (API_URL, CHARACTER_LIMIT, etc.) +└── dist/ # Built JavaScript files (entry point: dist/index.js) +``` + +## Tool Implementation + +### Tool Naming + +Use snake_case for tool names (e.g., "search_users", "create_project", "get_channel_info") with clear, action-oriented names. + +**Avoid Naming Conflicts**: Include the service context to prevent overlaps: +- Use "slack_send_message" instead of just "send_message" +- Use "github_create_issue" instead of just "create_issue" +- Use "asana_list_tasks" instead of just "list_tasks" + +### Tool Structure + +Tools are registered using the `registerTool` method with the following requirements: +- Use Zod schemas for runtime input validation and type safety +- The `description` field must be explicitly provided - JSDoc comments are NOT automatically extracted +- Explicitly provide `title`, `description`, `inputSchema`, and `annotations` +- The `inputSchema` must be a Zod schema object (not a JSON schema) +- Type all parameters and return values explicitly + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +const server = new McpServer({ + name: "example-mcp", + version: "1.0.0" +}); + +// Zod schema for input validation +const UserSearchInputSchema = z.object({ + query: z.string() + .min(2, "Query must be at least 2 characters") + .max(200, "Query must not exceed 200 characters") + .describe("Search string to match against names/emails"), + limit: z.number() + .int() + .min(1) + .max(100) + .default(20) + .describe("Maximum results to return"), + offset: z.number() + .int() + .min(0) + .default(0) + .describe("Number of results to skip for pagination"), + response_format: z.nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format: 'markdown' for human-readable or 'json' for machine-readable") +}).strict(); + +// Type definition from Zod schema +type UserSearchInput = z.infer; + +server.registerTool( + "example_search_users", + { + title: "Search Example Users", + description: `Search for users in the Example system by name, email, or team. + +This tool searches across all user profiles in the Example platform, supporting partial matches and various search filters. It does NOT create or modify users, only searches existing ones. + +Args: + - query (string): Search string to match against names/emails + - limit (number): Maximum results to return, between 1-100 (default: 20) + - offset (number): Number of results to skip for pagination (default: 0) + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + For JSON format: Structured data with schema: + { + "total": number, // Total number of matches found + "count": number, // Number of results in this response + "offset": number, // Current pagination offset + "users": [ + { + "id": string, // User ID (e.g., "U123456789") + "name": string, // Full name (e.g., "John Doe") + "email": string, // Email address + "team": string, // Team name (optional) + "active": boolean // Whether user is active + } + ], + "has_more": boolean, // Whether more results are available + "next_offset": number // Offset for next page (if has_more is true) + } + +Examples: + - Use when: "Find all marketing team members" -> params with query="team:marketing" + - Use when: "Search for John's account" -> params with query="john" + - Don't use when: You need to create a user (use example_create_user instead) + +Error Handling: + - Returns "Error: Rate limit exceeded" if too many requests (429 status) + - Returns "No users found matching ''" if search returns empty`, + inputSchema: UserSearchInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + } + }, + async (params: UserSearchInput) => { + try { + // Input validation is handled by Zod schema + // Make API request using validated parameters + const data = await makeApiRequest( + "users/search", + "GET", + undefined, + { + q: params.query, + limit: params.limit, + offset: params.offset + } + ); + + const users = data.users || []; + const total = data.total || 0; + + if (!users.length) { + return { + content: [{ + type: "text", + text: `No users found matching '${params.query}'` + }] + }; + } + + // Prepare structured output + const output = { + total, + count: users.length, + offset: params.offset, + users: users.map((user: any) => ({ + id: user.id, + name: user.name, + email: user.email, + ...(user.team ? { team: user.team } : {}), + active: user.active ?? true + })), + has_more: total > params.offset + users.length, + ...(total > params.offset + users.length ? { + next_offset: params.offset + users.length + } : {}) + }; + + // Format text representation based on requested format + let textContent: string; + if (params.response_format === ResponseFormat.MARKDOWN) { + const lines = [`# User Search Results: '${params.query}'`, "", + `Found ${total} users (showing ${users.length})`, ""]; + for (const user of users) { + lines.push(`## ${user.name} (${user.id})`); + lines.push(`- **Email**: ${user.email}`); + if (user.team) lines.push(`- **Team**: ${user.team}`); + lines.push(""); + } + textContent = lines.join("\n"); + } else { + textContent = JSON.stringify(output, null, 2); + } + + return { + content: [{ type: "text", text: textContent }], + structuredContent: output // Modern pattern for structured data + }; + } catch (error) { + return { + content: [{ + type: "text", + text: handleApiError(error) + }] + }; + } + } +); +``` + +## Zod Schemas for Input Validation + +Zod provides runtime type validation: + +```typescript +import { z } from "zod"; + +// Basic schema with validation +const CreateUserSchema = z.object({ + name: z.string() + .min(1, "Name is required") + .max(100, "Name must not exceed 100 characters"), + email: z.string() + .email("Invalid email format"), + age: z.number() + .int("Age must be a whole number") + .min(0, "Age cannot be negative") + .max(150, "Age cannot be greater than 150") +}).strict(); // Use .strict() to forbid extra fields + +// Enums +enum ResponseFormat { + MARKDOWN = "markdown", + JSON = "json" +} + +const SearchSchema = z.object({ + response_format: z.nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format") +}); + +// Optional fields with defaults +const PaginationSchema = z.object({ + limit: z.number() + .int() + .min(1) + .max(100) + .default(20) + .describe("Maximum results to return"), + offset: z.number() + .int() + .min(0) + .default(0) + .describe("Number of results to skip") +}); +``` + +## Response Format Options + +Support multiple output formats for flexibility: + +```typescript +enum ResponseFormat { + MARKDOWN = "markdown", + JSON = "json" +} + +const inputSchema = z.object({ + query: z.string(), + response_format: z.nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format: 'markdown' for human-readable or 'json' for machine-readable") +}); +``` + +**Markdown format**: +- Use headers, lists, and formatting for clarity +- Convert timestamps to human-readable format +- Show display names with IDs in parentheses +- Omit verbose metadata +- Group related information logically + +**JSON format**: +- Return complete, structured data suitable for programmatic processing +- Include all available fields and metadata +- Use consistent field names and types + +## Pagination Implementation + +For tools that list resources: + +```typescript +const ListSchema = z.object({ + limit: z.number().int().min(1).max(100).default(20), + offset: z.number().int().min(0).default(0) +}); + +async function listItems(params: z.infer) { + const data = await apiRequest(params.limit, params.offset); + + const response = { + total: data.total, + count: data.items.length, + offset: params.offset, + items: data.items, + has_more: data.total > params.offset + data.items.length, + next_offset: data.total > params.offset + data.items.length + ? params.offset + data.items.length + : undefined + }; + + return JSON.stringify(response, null, 2); +} +``` + +## Character Limits and Truncation + +Add a CHARACTER_LIMIT constant to prevent overwhelming responses: + +```typescript +// At module level in constants.ts +export const CHARACTER_LIMIT = 25000; // Maximum response size in characters + +async function searchTool(params: SearchInput) { + let result = generateResponse(data); + + // Check character limit and truncate if needed + if (result.length > CHARACTER_LIMIT) { + const truncatedData = data.slice(0, Math.max(1, data.length / 2)); + response.data = truncatedData; + response.truncated = true; + response.truncation_message = + `Response truncated from ${data.length} to ${truncatedData.length} items. ` + + `Use 'offset' parameter or add filters to see more results.`; + result = JSON.stringify(response, null, 2); + } + + return result; +} +``` + +## Error Handling + +Provide clear, actionable error messages: + +```typescript +import axios, { AxiosError } from "axios"; + +function handleApiError(error: unknown): string { + if (error instanceof AxiosError) { + if (error.response) { + switch (error.response.status) { + case 404: + return "Error: Resource not found. Please check the ID is correct."; + case 403: + return "Error: Permission denied. You don't have access to this resource."; + case 429: + return "Error: Rate limit exceeded. Please wait before making more requests."; + default: + return `Error: API request failed with status ${error.response.status}`; + } + } else if (error.code === "ECONNABORTED") { + return "Error: Request timed out. Please try again."; + } + } + return `Error: Unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`; +} +``` + +## Shared Utilities + +Extract common functionality into reusable functions: + +```typescript +// Shared API request function +async function makeApiRequest( + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + data?: any, + params?: any +): Promise { + try { + const response = await axios({ + method, + url: `${API_BASE_URL}/${endpoint}`, + data, + params, + timeout: 30000, + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + } + }); + return response.data; + } catch (error) { + throw error; + } +} +``` + +## Async/Await Best Practices + +Always use async/await for network requests and I/O operations: + +```typescript +// Good: Async network request +async function fetchData(resourceId: string): Promise { + const response = await axios.get(`${API_URL}/resource/${resourceId}`); + return response.data; +} + +// Bad: Promise chains +function fetchData(resourceId: string): Promise { + return axios.get(`${API_URL}/resource/${resourceId}`) + .then(response => response.data); // Harder to read and maintain +} +``` + +## TypeScript Best Practices + +1. **Use Strict TypeScript**: Enable strict mode in tsconfig.json +2. **Define Interfaces**: Create clear interface definitions for all data structures +3. **Avoid `any`**: Use proper types or `unknown` instead of `any` +4. **Zod for Runtime Validation**: Use Zod schemas to validate external data +5. **Type Guards**: Create type guard functions for complex type checking +6. **Error Handling**: Always use try-catch with proper error type checking +7. **Null Safety**: Use optional chaining (`?.`) and nullish coalescing (`??`) + +```typescript +// Good: Type-safe with Zod and interfaces +interface UserResponse { + id: string; + name: string; + email: string; + team?: string; + active: boolean; +} + +const UserSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + team: z.string().optional(), + active: z.boolean() +}); + +type User = z.infer; + +async function getUser(id: string): Promise { + const data = await apiCall(`/users/${id}`); + return UserSchema.parse(data); // Runtime validation +} + +// Bad: Using any +async function getUser(id: string): Promise { + return await apiCall(`/users/${id}`); // No type safety +} +``` + +## Package Configuration + +### package.json + +```json +{ + "name": "{service}-mcp-server", + "version": "1.0.0", + "description": "MCP server for {Service} API integration", + "type": "module", + "main": "dist/index.js", + "scripts": { + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts", + "build": "tsc", + "clean": "rm -rf dist" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.6.1", + "axios": "^1.7.9", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} +``` + +### tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +## Complete Example + +```typescript +#!/usr/bin/env node +/** + * MCP Server for Example Service. + * + * This server provides tools to interact with Example API, including user search, + * project management, and data export capabilities. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import axios, { AxiosError } from "axios"; + +// Constants +const API_BASE_URL = "https://api.example.com/v1"; +const CHARACTER_LIMIT = 25000; + +// Enums +enum ResponseFormat { + MARKDOWN = "markdown", + JSON = "json" +} + +// Zod schemas +const UserSearchInputSchema = z.object({ + query: z.string() + .min(2, "Query must be at least 2 characters") + .max(200, "Query must not exceed 200 characters") + .describe("Search string to match against names/emails"), + limit: z.number() + .int() + .min(1) + .max(100) + .default(20) + .describe("Maximum results to return"), + offset: z.number() + .int() + .min(0) + .default(0) + .describe("Number of results to skip for pagination"), + response_format: z.nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format: 'markdown' for human-readable or 'json' for machine-readable") +}).strict(); + +type UserSearchInput = z.infer; + +// Shared utility functions +async function makeApiRequest( + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + data?: any, + params?: any +): Promise { + try { + const response = await axios({ + method, + url: `${API_BASE_URL}/${endpoint}`, + data, + params, + timeout: 30000, + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + } + }); + return response.data; + } catch (error) { + throw error; + } +} + +function handleApiError(error: unknown): string { + if (error instanceof AxiosError) { + if (error.response) { + switch (error.response.status) { + case 404: + return "Error: Resource not found. Please check the ID is correct."; + case 403: + return "Error: Permission denied. You don't have access to this resource."; + case 429: + return "Error: Rate limit exceeded. Please wait before making more requests."; + default: + return `Error: API request failed with status ${error.response.status}`; + } + } else if (error.code === "ECONNABORTED") { + return "Error: Request timed out. Please try again."; + } + } + return `Error: Unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`; +} + +// Create MCP server instance +const server = new McpServer({ + name: "example-mcp", + version: "1.0.0" +}); + +// Register tools +server.registerTool( + "example_search_users", + { + title: "Search Example Users", + description: `[Full description as shown above]`, + inputSchema: UserSearchInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + } + }, + async (params: UserSearchInput) => { + // Implementation as shown above + } +); + +// Main function +// For stdio (local): +async function runStdio() { + if (!process.env.EXAMPLE_API_KEY) { + console.error("ERROR: EXAMPLE_API_KEY environment variable is required"); + process.exit(1); + } + + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("MCP server running via stdio"); +} + +// For streamable HTTP (remote): +async function runHTTP() { + if (!process.env.EXAMPLE_API_KEY) { + console.error("ERROR: EXAMPLE_API_KEY environment variable is required"); + process.exit(1); + } + + const app = express(); + app.use(express.json()); + + app.post('/mcp', async (req, res) => { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + res.on('close', () => transport.close()); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + }); + + const port = parseInt(process.env.PORT || '3000'); + app.listen(port, () => { + console.error(`MCP server running on http://localhost:${port}/mcp`); + }); +} + +// Choose transport based on environment +const transport = process.env.TRANSPORT || 'stdio'; +if (transport === 'http') { + runHTTP().catch(error => { + console.error("Server error:", error); + process.exit(1); + }); +} else { + runStdio().catch(error => { + console.error("Server error:", error); + process.exit(1); + }); +} +``` + +--- + +## Advanced MCP Features + +### Resource Registration + +Expose data as resources for efficient, URI-based access: + +```typescript +import { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; + +// Register a resource with URI template +server.registerResource( + { + uri: "file://documents/{name}", + name: "Document Resource", + description: "Access documents by name", + mimeType: "text/plain" + }, + async (uri: string) => { + // Extract parameter from URI + const match = uri.match(/^file:\/\/documents\/(.+)$/); + if (!match) { + throw new Error("Invalid URI format"); + } + + const documentName = match[1]; + const content = await loadDocument(documentName); + + return { + contents: [{ + uri, + mimeType: "text/plain", + text: content + }] + }; + } +); + +// List available resources dynamically +server.registerResourceList(async () => { + const documents = await getAvailableDocuments(); + return { + resources: documents.map(doc => ({ + uri: `file://documents/${doc.name}`, + name: doc.name, + mimeType: "text/plain", + description: doc.description + })) + }; +}); +``` + +**When to use Resources vs Tools:** +- **Resources**: For data access with simple URI-based parameters +- **Tools**: For complex operations requiring validation and business logic +- **Resources**: When data is relatively static or template-based +- **Tools**: When operations have side effects or complex workflows + +### Transport Options + +The TypeScript SDK supports two main transport mechanisms: + +#### Streamable HTTP (Recommended for Remote Servers) + +```typescript +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import express from "express"; + +const app = express(); +app.use(express.json()); + +app.post('/mcp', async (req, res) => { + // Create new transport for each request (stateless, prevents request ID collisions) + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + + res.on('close', () => transport.close()); + + await server.connect(transport); + await transport.handleRequest(req, res, req.body); +}); + +app.listen(3000); +``` + +#### stdio (For Local Integrations) + +```typescript +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +**Transport selection:** +- **Streamable HTTP**: Web services, remote access, multiple clients +- **stdio**: Command-line tools, local development, subprocess integration + +### Notification Support + +Notify clients when server state changes: + +```typescript +// Notify when tools list changes +server.notification({ + method: "notifications/tools/list_changed" +}); + +// Notify when resources change +server.notification({ + method: "notifications/resources/list_changed" +}); +``` + +Use notifications sparingly - only when server capabilities genuinely change. + +--- + +## Code Best Practices + +### Code Composability and Reusability + +Your implementation MUST prioritize composability and code reuse: + +1. **Extract Common Functionality**: + - Create reusable helper functions for operations used across multiple tools + - Build shared API clients for HTTP requests instead of duplicating code + - Centralize error handling logic in utility functions + - Extract business logic into dedicated functions that can be composed + - Extract shared markdown or JSON field selection & formatting functionality + +2. **Avoid Duplication**: + - NEVER copy-paste similar code between tools + - If you find yourself writing similar logic twice, extract it into a function + - Common operations like pagination, filtering, field selection, and formatting should be shared + - Authentication/authorization logic should be centralized + +## Building and Running + +Always build your TypeScript code before running: + +```bash +# Build the project +npm run build + +# Run the server +npm start + +# Development with auto-reload +npm run dev +``` + +Always ensure `npm run build` completes successfully before considering the implementation complete. + +## Quality Checklist + +Before finalizing your Node/TypeScript MCP server implementation, ensure: + +### Strategic Design +- [ ] Tools enable complete workflows, not just API endpoint wrappers +- [ ] Tool names reflect natural task subdivisions +- [ ] Response formats optimize for agent context efficiency +- [ ] Human-readable identifiers used where appropriate +- [ ] Error messages guide agents toward correct usage + +### Implementation Quality +- [ ] FOCUSED IMPLEMENTATION: Most important and valuable tools implemented +- [ ] All tools registered using `registerTool` with complete configuration +- [ ] All tools include `title`, `description`, `inputSchema`, and `annotations` +- [ ] Annotations correctly set (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) +- [ ] All tools use Zod schemas for runtime input validation with `.strict()` enforcement +- [ ] All Zod schemas have proper constraints and descriptive error messages +- [ ] All tools have comprehensive descriptions with explicit input/output types +- [ ] Descriptions include return value examples and complete schema documentation +- [ ] Error messages are clear, actionable, and educational + +### TypeScript Quality +- [ ] TypeScript interfaces are defined for all data structures +- [ ] Strict TypeScript is enabled in tsconfig.json +- [ ] No use of `any` type - use `unknown` or proper types instead +- [ ] All async functions have explicit Promise return types +- [ ] Error handling uses proper type guards (e.g., `axios.isAxiosError`, `z.ZodError`) + +### Advanced Features (where applicable) +- [ ] Resources registered for appropriate data endpoints +- [ ] Appropriate transport configured (stdio or streamable HTTP) +- [ ] Notifications implemented for dynamic server capabilities +- [ ] Type-safe with SDK interfaces + +### Project Configuration +- [ ] Package.json includes all necessary dependencies +- [ ] Build script produces working JavaScript in dist/ directory +- [ ] Main entry point is properly configured as dist/index.js +- [ ] Server name follows format: `{service}-mcp-server` +- [ ] tsconfig.json properly configured with strict mode + +### Code Quality +- [ ] Pagination is properly implemented where applicable +- [ ] Large responses check CHARACTER_LIMIT constant and truncate with clear messages +- [ ] Filtering options are provided for potentially large result sets +- [ ] All network operations handle timeouts and connection errors gracefully +- [ ] Common functionality is extracted into reusable functions +- [ ] Return types are consistent across similar operations + +### Testing and Build +- [ ] `npm run build` completes successfully without errors +- [ ] dist/index.js created and executable +- [ ] Server runs: `node dist/index.js --help` +- [ ] All imports resolve correctly +- [ ] Sample tool calls work as expected \ No newline at end of file diff --git a/plugins/ndf/skills/mcp-builder/reference/python_mcp_server.md b/plugins/ndf/skills/mcp-builder/reference/python_mcp_server.md new file mode 100644 index 0000000..cf7ec99 --- /dev/null +++ b/plugins/ndf/skills/mcp-builder/reference/python_mcp_server.md @@ -0,0 +1,719 @@ +# Python MCP Server Implementation Guide + +## Overview + +This document provides Python-specific best practices and examples for implementing MCP servers using the MCP Python SDK. It covers server setup, tool registration patterns, input validation with Pydantic, error handling, and complete working examples. + +--- + +## Quick Reference + +### Key Imports +```python +from mcp.server.fastmcp import FastMCP +from pydantic import BaseModel, Field, field_validator, ConfigDict +from typing import Optional, List, Dict, Any +from enum import Enum +import httpx +``` + +### Server Initialization +```python +mcp = FastMCP("service_mcp") +``` + +### Tool Registration Pattern +```python +@mcp.tool(name="tool_name", annotations={...}) +async def tool_function(params: InputModel) -> str: + # Implementation + pass +``` + +--- + +## MCP Python SDK and FastMCP + +The official MCP Python SDK provides FastMCP, a high-level framework for building MCP servers. It provides: +- Automatic description and inputSchema generation from function signatures and docstrings +- Pydantic model integration for input validation +- Decorator-based tool registration with `@mcp.tool` + +**For complete SDK documentation, use WebFetch to load:** +`https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md` + +## Server Naming Convention + +Python MCP servers must follow this naming pattern: +- **Format**: `{service}_mcp` (lowercase with underscores) +- **Examples**: `github_mcp`, `jira_mcp`, `stripe_mcp` + +The name should be: +- General (not tied to specific features) +- Descriptive of the service/API being integrated +- Easy to infer from the task description +- Without version numbers or dates + +## Tool Implementation + +### Tool Naming + +Use snake_case for tool names (e.g., "search_users", "create_project", "get_channel_info") with clear, action-oriented names. + +**Avoid Naming Conflicts**: Include the service context to prevent overlaps: +- Use "slack_send_message" instead of just "send_message" +- Use "github_create_issue" instead of just "create_issue" +- Use "asana_list_tasks" instead of just "list_tasks" + +### Tool Structure with FastMCP + +Tools are defined using the `@mcp.tool` decorator with Pydantic models for input validation: + +```python +from pydantic import BaseModel, Field, ConfigDict +from mcp.server.fastmcp import FastMCP + +# Initialize the MCP server +mcp = FastMCP("example_mcp") + +# Define Pydantic model for input validation +class ServiceToolInput(BaseModel): + '''Input model for service tool operation.''' + model_config = ConfigDict( + str_strip_whitespace=True, # Auto-strip whitespace from strings + validate_assignment=True, # Validate on assignment + extra='forbid' # Forbid extra fields + ) + + param1: str = Field(..., description="First parameter description (e.g., 'user123', 'project-abc')", min_length=1, max_length=100) + param2: Optional[int] = Field(default=None, description="Optional integer parameter with constraints", ge=0, le=1000) + tags: Optional[List[str]] = Field(default_factory=list, description="List of tags to apply", max_items=10) + +@mcp.tool( + name="service_tool_name", + annotations={ + "title": "Human-Readable Tool Title", + "readOnlyHint": True, # Tool does not modify environment + "destructiveHint": False, # Tool does not perform destructive operations + "idempotentHint": True, # Repeated calls have no additional effect + "openWorldHint": False # Tool does not interact with external entities + } +) +async def service_tool_name(params: ServiceToolInput) -> str: + '''Tool description automatically becomes the 'description' field. + + This tool performs a specific operation on the service. It validates all inputs + using the ServiceToolInput Pydantic model before processing. + + Args: + params (ServiceToolInput): Validated input parameters containing: + - param1 (str): First parameter description + - param2 (Optional[int]): Optional parameter with default + - tags (Optional[List[str]]): List of tags + + Returns: + str: JSON-formatted response containing operation results + ''' + # Implementation here + pass +``` + +## Pydantic v2 Key Features + +- Use `model_config` instead of nested `Config` class +- Use `field_validator` instead of deprecated `validator` +- Use `model_dump()` instead of deprecated `dict()` +- Validators require `@classmethod` decorator +- Type hints are required for validator methods + +```python +from pydantic import BaseModel, Field, field_validator, ConfigDict + +class CreateUserInput(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True + ) + + name: str = Field(..., description="User's full name", min_length=1, max_length=100) + email: str = Field(..., description="User's email address", pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$') + age: int = Field(..., description="User's age", ge=0, le=150) + + @field_validator('email') + @classmethod + def validate_email(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Email cannot be empty") + return v.lower() +``` + +## Response Format Options + +Support multiple output formats for flexibility: + +```python +from enum import Enum + +class ResponseFormat(str, Enum): + '''Output format for tool responses.''' + MARKDOWN = "markdown" + JSON = "json" + +class UserSearchInput(BaseModel): + query: str = Field(..., description="Search query") + response_format: ResponseFormat = Field( + default=ResponseFormat.MARKDOWN, + description="Output format: 'markdown' for human-readable or 'json' for machine-readable" + ) +``` + +**Markdown format**: +- Use headers, lists, and formatting for clarity +- Convert timestamps to human-readable format (e.g., "2024-01-15 10:30:00 UTC" instead of epoch) +- Show display names with IDs in parentheses (e.g., "@john.doe (U123456)") +- Omit verbose metadata (e.g., show only one profile image URL, not all sizes) +- Group related information logically + +**JSON format**: +- Return complete, structured data suitable for programmatic processing +- Include all available fields and metadata +- Use consistent field names and types + +## Pagination Implementation + +For tools that list resources: + +```python +class ListInput(BaseModel): + limit: Optional[int] = Field(default=20, description="Maximum results to return", ge=1, le=100) + offset: Optional[int] = Field(default=0, description="Number of results to skip for pagination", ge=0) + +async def list_items(params: ListInput) -> str: + # Make API request with pagination + data = await api_request(limit=params.limit, offset=params.offset) + + # Return pagination info + response = { + "total": data["total"], + "count": len(data["items"]), + "offset": params.offset, + "items": data["items"], + "has_more": data["total"] > params.offset + len(data["items"]), + "next_offset": params.offset + len(data["items"]) if data["total"] > params.offset + len(data["items"]) else None + } + return json.dumps(response, indent=2) +``` + +## Error Handling + +Provide clear, actionable error messages: + +```python +def _handle_api_error(e: Exception) -> str: + '''Consistent error formatting across all tools.''' + if isinstance(e, httpx.HTTPStatusError): + if e.response.status_code == 404: + return "Error: Resource not found. Please check the ID is correct." + elif e.response.status_code == 403: + return "Error: Permission denied. You don't have access to this resource." + elif e.response.status_code == 429: + return "Error: Rate limit exceeded. Please wait before making more requests." + return f"Error: API request failed with status {e.response.status_code}" + elif isinstance(e, httpx.TimeoutException): + return "Error: Request timed out. Please try again." + return f"Error: Unexpected error occurred: {type(e).__name__}" +``` + +## Shared Utilities + +Extract common functionality into reusable functions: + +```python +# Shared API request function +async def _make_api_request(endpoint: str, method: str = "GET", **kwargs) -> dict: + '''Reusable function for all API calls.''' + async with httpx.AsyncClient() as client: + response = await client.request( + method, + f"{API_BASE_URL}/{endpoint}", + timeout=30.0, + **kwargs + ) + response.raise_for_status() + return response.json() +``` + +## Async/Await Best Practices + +Always use async/await for network requests and I/O operations: + +```python +# Good: Async network request +async def fetch_data(resource_id: str) -> dict: + async with httpx.AsyncClient() as client: + response = await client.get(f"{API_URL}/resource/{resource_id}") + response.raise_for_status() + return response.json() + +# Bad: Synchronous request +def fetch_data(resource_id: str) -> dict: + response = requests.get(f"{API_URL}/resource/{resource_id}") # Blocks + return response.json() +``` + +## Type Hints + +Use type hints throughout: + +```python +from typing import Optional, List, Dict, Any + +async def get_user(user_id: str) -> Dict[str, Any]: + data = await fetch_user(user_id) + return {"id": data["id"], "name": data["name"]} +``` + +## Tool Docstrings + +Every tool must have comprehensive docstrings with explicit type information: + +```python +async def search_users(params: UserSearchInput) -> str: + ''' + Search for users in the Example system by name, email, or team. + + This tool searches across all user profiles in the Example platform, + supporting partial matches and various search filters. It does NOT + create or modify users, only searches existing ones. + + Args: + params (UserSearchInput): Validated input parameters containing: + - query (str): Search string to match against names/emails (e.g., "john", "@example.com", "team:marketing") + - limit (Optional[int]): Maximum results to return, between 1-100 (default: 20) + - offset (Optional[int]): Number of results to skip for pagination (default: 0) + + Returns: + str: JSON-formatted string containing search results with the following schema: + + Success response: + { + "total": int, # Total number of matches found + "count": int, # Number of results in this response + "offset": int, # Current pagination offset + "users": [ + { + "id": str, # User ID (e.g., "U123456789") + "name": str, # Full name (e.g., "John Doe") + "email": str, # Email address (e.g., "john@example.com") + "team": str # Team name (e.g., "Marketing") - optional + } + ] + } + + Error response: + "Error: " or "No users found matching ''" + + Examples: + - Use when: "Find all marketing team members" -> params with query="team:marketing" + - Use when: "Search for John's account" -> params with query="john" + - Don't use when: You need to create a user (use example_create_user instead) + - Don't use when: You have a user ID and need full details (use example_get_user instead) + + Error Handling: + - Input validation errors are handled by Pydantic model + - Returns "Error: Rate limit exceeded" if too many requests (429 status) + - Returns "Error: Invalid API authentication" if API key is invalid (401 status) + - Returns formatted list of results or "No users found matching 'query'" + ''' +``` + +## Complete Example + +See below for a complete Python MCP server example: + +```python +#!/usr/bin/env python3 +''' +MCP Server for Example Service. + +This server provides tools to interact with Example API, including user search, +project management, and data export capabilities. +''' + +from typing import Optional, List, Dict, Any +from enum import Enum +import httpx +from pydantic import BaseModel, Field, field_validator, ConfigDict +from mcp.server.fastmcp import FastMCP + +# Initialize the MCP server +mcp = FastMCP("example_mcp") + +# Constants +API_BASE_URL = "https://api.example.com/v1" + +# Enums +class ResponseFormat(str, Enum): + '''Output format for tool responses.''' + MARKDOWN = "markdown" + JSON = "json" + +# Pydantic Models for Input Validation +class UserSearchInput(BaseModel): + '''Input model for user search operations.''' + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True + ) + + query: str = Field(..., description="Search string to match against names/emails", min_length=2, max_length=200) + limit: Optional[int] = Field(default=20, description="Maximum results to return", ge=1, le=100) + offset: Optional[int] = Field(default=0, description="Number of results to skip for pagination", ge=0) + response_format: ResponseFormat = Field(default=ResponseFormat.MARKDOWN, description="Output format") + + @field_validator('query') + @classmethod + def validate_query(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Query cannot be empty or whitespace only") + return v.strip() + +# Shared utility functions +async def _make_api_request(endpoint: str, method: str = "GET", **kwargs) -> dict: + '''Reusable function for all API calls.''' + async with httpx.AsyncClient() as client: + response = await client.request( + method, + f"{API_BASE_URL}/{endpoint}", + timeout=30.0, + **kwargs + ) + response.raise_for_status() + return response.json() + +def _handle_api_error(e: Exception) -> str: + '''Consistent error formatting across all tools.''' + if isinstance(e, httpx.HTTPStatusError): + if e.response.status_code == 404: + return "Error: Resource not found. Please check the ID is correct." + elif e.response.status_code == 403: + return "Error: Permission denied. You don't have access to this resource." + elif e.response.status_code == 429: + return "Error: Rate limit exceeded. Please wait before making more requests." + return f"Error: API request failed with status {e.response.status_code}" + elif isinstance(e, httpx.TimeoutException): + return "Error: Request timed out. Please try again." + return f"Error: Unexpected error occurred: {type(e).__name__}" + +# Tool definitions +@mcp.tool( + name="example_search_users", + annotations={ + "title": "Search Example Users", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def example_search_users(params: UserSearchInput) -> str: + '''Search for users in the Example system by name, email, or team. + + [Full docstring as shown above] + ''' + try: + # Make API request using validated parameters + data = await _make_api_request( + "users/search", + params={ + "q": params.query, + "limit": params.limit, + "offset": params.offset + } + ) + + users = data.get("users", []) + total = data.get("total", 0) + + if not users: + return f"No users found matching '{params.query}'" + + # Format response based on requested format + if params.response_format == ResponseFormat.MARKDOWN: + lines = [f"# User Search Results: '{params.query}'", ""] + lines.append(f"Found {total} users (showing {len(users)})") + lines.append("") + + for user in users: + lines.append(f"## {user['name']} ({user['id']})") + lines.append(f"- **Email**: {user['email']}") + if user.get('team'): + lines.append(f"- **Team**: {user['team']}") + lines.append("") + + return "\n".join(lines) + + else: + # Machine-readable JSON format + import json + response = { + "total": total, + "count": len(users), + "offset": params.offset, + "users": users + } + return json.dumps(response, indent=2) + + except Exception as e: + return _handle_api_error(e) + +if __name__ == "__main__": + mcp.run() +``` + +--- + +## Advanced FastMCP Features + +### Context Parameter Injection + +FastMCP can automatically inject a `Context` parameter into tools for advanced capabilities like logging, progress reporting, resource reading, and user interaction: + +```python +from mcp.server.fastmcp import FastMCP, Context + +mcp = FastMCP("example_mcp") + +@mcp.tool() +async def advanced_search(query: str, ctx: Context) -> str: + '''Advanced tool with context access for logging and progress.''' + + # Report progress for long operations + await ctx.report_progress(0.25, "Starting search...") + + # Log information for debugging + await ctx.log_info("Processing query", {"query": query, "timestamp": datetime.now()}) + + # Perform search + results = await search_api(query) + await ctx.report_progress(0.75, "Formatting results...") + + # Access server configuration + server_name = ctx.fastmcp.name + + return format_results(results) + +@mcp.tool() +async def interactive_tool(resource_id: str, ctx: Context) -> str: + '''Tool that can request additional input from users.''' + + # Request sensitive information when needed + api_key = await ctx.elicit( + prompt="Please provide your API key:", + input_type="password" + ) + + # Use the provided key + return await api_call(resource_id, api_key) +``` + +**Context capabilities:** +- `ctx.report_progress(progress, message)` - Report progress for long operations +- `ctx.log_info(message, data)` / `ctx.log_error()` / `ctx.log_debug()` - Logging +- `ctx.elicit(prompt, input_type)` - Request input from users +- `ctx.fastmcp.name` - Access server configuration +- `ctx.read_resource(uri)` - Read MCP resources + +### Resource Registration + +Expose data as resources for efficient, template-based access: + +```python +@mcp.resource("file://documents/{name}") +async def get_document(name: str) -> str: + '''Expose documents as MCP resources. + + Resources are useful for static or semi-static data that doesn't + require complex parameters. They use URI templates for flexible access. + ''' + document_path = f"./docs/{name}" + with open(document_path, "r") as f: + return f.read() + +@mcp.resource("config://settings/{key}") +async def get_setting(key: str, ctx: Context) -> str: + '''Expose configuration as resources with context.''' + settings = await load_settings() + return json.dumps(settings.get(key, {})) +``` + +**When to use Resources vs Tools:** +- **Resources**: For data access with simple parameters (URI templates) +- **Tools**: For complex operations with validation and business logic + +### Structured Output Types + +FastMCP supports multiple return types beyond strings: + +```python +from typing import TypedDict +from dataclasses import dataclass +from pydantic import BaseModel + +# TypedDict for structured returns +class UserData(TypedDict): + id: str + name: str + email: str + +@mcp.tool() +async def get_user_typed(user_id: str) -> UserData: + '''Returns structured data - FastMCP handles serialization.''' + return {"id": user_id, "name": "John Doe", "email": "john@example.com"} + +# Pydantic models for complex validation +class DetailedUser(BaseModel): + id: str + name: str + email: str + created_at: datetime + metadata: Dict[str, Any] + +@mcp.tool() +async def get_user_detailed(user_id: str) -> DetailedUser: + '''Returns Pydantic model - automatically generates schema.''' + user = await fetch_user(user_id) + return DetailedUser(**user) +``` + +### Lifespan Management + +Initialize resources that persist across requests: + +```python +from contextlib import asynccontextmanager + +@asynccontextmanager +async def app_lifespan(): + '''Manage resources that live for the server's lifetime.''' + # Initialize connections, load config, etc. + db = await connect_to_database() + config = load_configuration() + + # Make available to all tools + yield {"db": db, "config": config} + + # Cleanup on shutdown + await db.close() + +mcp = FastMCP("example_mcp", lifespan=app_lifespan) + +@mcp.tool() +async def query_data(query: str, ctx: Context) -> str: + '''Access lifespan resources through context.''' + db = ctx.request_context.lifespan_state["db"] + results = await db.query(query) + return format_results(results) +``` + +### Transport Options + +FastMCP supports two main transport mechanisms: + +```python +# stdio transport (for local tools) - default +if __name__ == "__main__": + mcp.run() + +# Streamable HTTP transport (for remote servers) +if __name__ == "__main__": + mcp.run(transport="streamable_http", port=8000) +``` + +**Transport selection:** +- **stdio**: Command-line tools, local integrations, subprocess execution +- **Streamable HTTP**: Web services, remote access, multiple clients + +--- + +## Code Best Practices + +### Code Composability and Reusability + +Your implementation MUST prioritize composability and code reuse: + +1. **Extract Common Functionality**: + - Create reusable helper functions for operations used across multiple tools + - Build shared API clients for HTTP requests instead of duplicating code + - Centralize error handling logic in utility functions + - Extract business logic into dedicated functions that can be composed + - Extract shared markdown or JSON field selection & formatting functionality + +2. **Avoid Duplication**: + - NEVER copy-paste similar code between tools + - If you find yourself writing similar logic twice, extract it into a function + - Common operations like pagination, filtering, field selection, and formatting should be shared + - Authentication/authorization logic should be centralized + +### Python-Specific Best Practices + +1. **Use Type Hints**: Always include type annotations for function parameters and return values +2. **Pydantic Models**: Define clear Pydantic models for all input validation +3. **Avoid Manual Validation**: Let Pydantic handle input validation with constraints +4. **Proper Imports**: Group imports (standard library, third-party, local) +5. **Error Handling**: Use specific exception types (httpx.HTTPStatusError, not generic Exception) +6. **Async Context Managers**: Use `async with` for resources that need cleanup +7. **Constants**: Define module-level constants in UPPER_CASE + +## Quality Checklist + +Before finalizing your Python MCP server implementation, ensure: + +### Strategic Design +- [ ] Tools enable complete workflows, not just API endpoint wrappers +- [ ] Tool names reflect natural task subdivisions +- [ ] Response formats optimize for agent context efficiency +- [ ] Human-readable identifiers used where appropriate +- [ ] Error messages guide agents toward correct usage + +### Implementation Quality +- [ ] FOCUSED IMPLEMENTATION: Most important and valuable tools implemented +- [ ] All tools have descriptive names and documentation +- [ ] Return types are consistent across similar operations +- [ ] Error handling is implemented for all external calls +- [ ] Server name follows format: `{service}_mcp` +- [ ] All network operations use async/await +- [ ] Common functionality is extracted into reusable functions +- [ ] Error messages are clear, actionable, and educational +- [ ] Outputs are properly validated and formatted + +### Tool Configuration +- [ ] All tools implement 'name' and 'annotations' in the decorator +- [ ] Annotations correctly set (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) +- [ ] All tools use Pydantic BaseModel for input validation with Field() definitions +- [ ] All Pydantic Fields have explicit types and descriptions with constraints +- [ ] All tools have comprehensive docstrings with explicit input/output types +- [ ] Docstrings include complete schema structure for dict/JSON returns +- [ ] Pydantic models handle input validation (no manual validation needed) + +### Advanced Features (where applicable) +- [ ] Context injection used for logging, progress, or elicitation +- [ ] Resources registered for appropriate data endpoints +- [ ] Lifespan management implemented for persistent connections +- [ ] Structured output types used (TypedDict, Pydantic models) +- [ ] Appropriate transport configured (stdio or streamable HTTP) + +### Code Quality +- [ ] File includes proper imports including Pydantic imports +- [ ] Pagination is properly implemented where applicable +- [ ] Filtering options are provided for potentially large result sets +- [ ] All async functions are properly defined with `async def` +- [ ] HTTP client usage follows async patterns with proper context managers +- [ ] Type hints are used throughout the code +- [ ] Constants are defined at module level in UPPER_CASE + +### Testing +- [ ] Server runs successfully: `python your_server.py --help` +- [ ] All imports resolve correctly +- [ ] Sample tool calls work as expected +- [ ] Error scenarios handled gracefully \ No newline at end of file diff --git a/plugins/ndf/skills/mcp-builder/scripts/connections.py b/plugins/ndf/skills/mcp-builder/scripts/connections.py new file mode 100644 index 0000000..ffcd0da --- /dev/null +++ b/plugins/ndf/skills/mcp-builder/scripts/connections.py @@ -0,0 +1,151 @@ +"""Lightweight connection handling for MCP servers.""" + +from abc import ABC, abstractmethod +from contextlib import AsyncExitStack +from typing import Any + +from mcp import ClientSession, StdioServerParameters +from mcp.client.sse import sse_client +from mcp.client.stdio import stdio_client +from mcp.client.streamable_http import streamablehttp_client + + +class MCPConnection(ABC): + """Base class for MCP server connections.""" + + def __init__(self): + self.session = None + self._stack = None + + @abstractmethod + def _create_context(self): + """Create the connection context based on connection type.""" + + async def __aenter__(self): + """Initialize MCP server connection.""" + self._stack = AsyncExitStack() + await self._stack.__aenter__() + + try: + ctx = self._create_context() + result = await self._stack.enter_async_context(ctx) + + if len(result) == 2: + read, write = result + elif len(result) == 3: + read, write, _ = result + else: + raise ValueError(f"Unexpected context result: {result}") + + session_ctx = ClientSession(read, write) + self.session = await self._stack.enter_async_context(session_ctx) + await self.session.initialize() + return self + except BaseException: + await self._stack.__aexit__(None, None, None) + raise + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Clean up MCP server connection resources.""" + if self._stack: + await self._stack.__aexit__(exc_type, exc_val, exc_tb) + self.session = None + self._stack = None + + async def list_tools(self) -> list[dict[str, Any]]: + """Retrieve available tools from the MCP server.""" + response = await self.session.list_tools() + return [ + { + "name": tool.name, + "description": tool.description, + "input_schema": tool.inputSchema, + } + for tool in response.tools + ] + + async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: + """Call a tool on the MCP server with provided arguments.""" + result = await self.session.call_tool(tool_name, arguments=arguments) + return result.content + + +class MCPConnectionStdio(MCPConnection): + """MCP connection using standard input/output.""" + + def __init__(self, command: str, args: list[str] = None, env: dict[str, str] = None): + super().__init__() + self.command = command + self.args = args or [] + self.env = env + + def _create_context(self): + return stdio_client( + StdioServerParameters(command=self.command, args=self.args, env=self.env) + ) + + +class MCPConnectionSSE(MCPConnection): + """MCP connection using Server-Sent Events.""" + + def __init__(self, url: str, headers: dict[str, str] = None): + super().__init__() + self.url = url + self.headers = headers or {} + + def _create_context(self): + return sse_client(url=self.url, headers=self.headers) + + +class MCPConnectionHTTP(MCPConnection): + """MCP connection using Streamable HTTP.""" + + def __init__(self, url: str, headers: dict[str, str] = None): + super().__init__() + self.url = url + self.headers = headers or {} + + def _create_context(self): + return streamablehttp_client(url=self.url, headers=self.headers) + + +def create_connection( + transport: str, + command: str = None, + args: list[str] = None, + env: dict[str, str] = None, + url: str = None, + headers: dict[str, str] = None, +) -> MCPConnection: + """Factory function to create the appropriate MCP connection. + + Args: + transport: Connection type ("stdio", "sse", or "http") + command: Command to run (stdio only) + args: Command arguments (stdio only) + env: Environment variables (stdio only) + url: Server URL (sse and http only) + headers: HTTP headers (sse and http only) + + Returns: + MCPConnection instance + """ + transport = transport.lower() + + if transport == "stdio": + if not command: + raise ValueError("Command is required for stdio transport") + return MCPConnectionStdio(command=command, args=args, env=env) + + elif transport == "sse": + if not url: + raise ValueError("URL is required for sse transport") + return MCPConnectionSSE(url=url, headers=headers) + + elif transport in ["http", "streamable_http", "streamable-http"]: + if not url: + raise ValueError("URL is required for http transport") + return MCPConnectionHTTP(url=url, headers=headers) + + else: + raise ValueError(f"Unsupported transport type: {transport}. Use 'stdio', 'sse', or 'http'") diff --git a/plugins/ndf/skills/mcp-builder/scripts/evaluation.py b/plugins/ndf/skills/mcp-builder/scripts/evaluation.py new file mode 100644 index 0000000..4177856 --- /dev/null +++ b/plugins/ndf/skills/mcp-builder/scripts/evaluation.py @@ -0,0 +1,373 @@ +"""MCP Server Evaluation Harness + +This script evaluates MCP servers by running test questions against them using Claude. +""" + +import argparse +import asyncio +import json +import re +import sys +import time +import traceback +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any + +from anthropic import Anthropic + +from connections import create_connection + +EVALUATION_PROMPT = """You are an AI assistant with access to tools. + +When given a task, you MUST: +1. Use the available tools to complete the task +2. Provide summary of each step in your approach, wrapped in tags +3. Provide feedback on the tools provided, wrapped in tags +4. Provide your final response, wrapped in tags + +Summary Requirements: +- In your tags, you must explain: + - The steps you took to complete the task + - Which tools you used, in what order, and why + - The inputs you provided to each tool + - The outputs you received from each tool + - A summary for how you arrived at the response + +Feedback Requirements: +- In your tags, provide constructive feedback on the tools: + - Comment on tool names: Are they clear and descriptive? + - Comment on input parameters: Are they well-documented? Are required vs optional parameters clear? + - Comment on descriptions: Do they accurately describe what the tool does? + - Comment on any errors encountered during tool usage: Did the tool fail to execute? Did the tool return too many tokens? + - Identify specific areas for improvement and explain WHY they would help + - Be specific and actionable in your suggestions + +Response Requirements: +- Your response should be concise and directly address what was asked +- Always wrap your final response in tags +- If you cannot solve the task return NOT_FOUND +- For numeric responses, provide just the number +- For IDs, provide just the ID +- For names or text, provide the exact text requested +- Your response should go last""" + + +def parse_evaluation_file(file_path: Path) -> list[dict[str, Any]]: + """Parse XML evaluation file with qa_pair elements.""" + try: + tree = ET.parse(file_path) + root = tree.getroot() + evaluations = [] + + for qa_pair in root.findall(".//qa_pair"): + question_elem = qa_pair.find("question") + answer_elem = qa_pair.find("answer") + + if question_elem is not None and answer_elem is not None: + evaluations.append({ + "question": (question_elem.text or "").strip(), + "answer": (answer_elem.text or "").strip(), + }) + + return evaluations + except Exception as e: + print(f"Error parsing evaluation file {file_path}: {e}") + return [] + + +def extract_xml_content(text: str, tag: str) -> str | None: + """Extract content from XML tags.""" + pattern = rf"<{tag}>(.*?)" + matches = re.findall(pattern, text, re.DOTALL) + return matches[-1].strip() if matches else None + + +async def agent_loop( + client: Anthropic, + model: str, + question: str, + tools: list[dict[str, Any]], + connection: Any, +) -> tuple[str, dict[str, Any]]: + """Run the agent loop with MCP tools.""" + messages = [{"role": "user", "content": question}] + + response = await asyncio.to_thread( + client.messages.create, + model=model, + max_tokens=4096, + system=EVALUATION_PROMPT, + messages=messages, + tools=tools, + ) + + messages.append({"role": "assistant", "content": response.content}) + + tool_metrics = {} + + while response.stop_reason == "tool_use": + tool_use = next(block for block in response.content if block.type == "tool_use") + tool_name = tool_use.name + tool_input = tool_use.input + + tool_start_ts = time.time() + try: + tool_result = await connection.call_tool(tool_name, tool_input) + tool_response = json.dumps(tool_result) if isinstance(tool_result, (dict, list)) else str(tool_result) + except Exception as e: + tool_response = f"Error executing tool {tool_name}: {str(e)}\n" + tool_response += traceback.format_exc() + tool_duration = time.time() - tool_start_ts + + if tool_name not in tool_metrics: + tool_metrics[tool_name] = {"count": 0, "durations": []} + tool_metrics[tool_name]["count"] += 1 + tool_metrics[tool_name]["durations"].append(tool_duration) + + messages.append({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": tool_use.id, + "content": tool_response, + }] + }) + + response = await asyncio.to_thread( + client.messages.create, + model=model, + max_tokens=4096, + system=EVALUATION_PROMPT, + messages=messages, + tools=tools, + ) + messages.append({"role": "assistant", "content": response.content}) + + response_text = next( + (block.text for block in response.content if hasattr(block, "text")), + None, + ) + return response_text, tool_metrics + + +async def evaluate_single_task( + client: Anthropic, + model: str, + qa_pair: dict[str, Any], + tools: list[dict[str, Any]], + connection: Any, + task_index: int, +) -> dict[str, Any]: + """Evaluate a single QA pair with the given tools.""" + start_time = time.time() + + print(f"Task {task_index + 1}: Running task with question: {qa_pair['question']}") + response, tool_metrics = await agent_loop(client, model, qa_pair["question"], tools, connection) + + response_value = extract_xml_content(response, "response") + summary = extract_xml_content(response, "summary") + feedback = extract_xml_content(response, "feedback") + + duration_seconds = time.time() - start_time + + return { + "question": qa_pair["question"], + "expected": qa_pair["answer"], + "actual": response_value, + "score": int(response_value == qa_pair["answer"]) if response_value else 0, + "total_duration": duration_seconds, + "tool_calls": tool_metrics, + "num_tool_calls": sum(len(metrics["durations"]) for metrics in tool_metrics.values()), + "summary": summary, + "feedback": feedback, + } + + +REPORT_HEADER = """ +# Evaluation Report + +## Summary + +- **Accuracy**: {correct}/{total} ({accuracy:.1f}%) +- **Average Task Duration**: {average_duration_s:.2f}s +- **Average Tool Calls per Task**: {average_tool_calls:.2f} +- **Total Tool Calls**: {total_tool_calls} + +--- +""" + +TASK_TEMPLATE = """ +### Task {task_num} + +**Question**: {question} +**Ground Truth Answer**: `{expected_answer}` +**Actual Answer**: `{actual_answer}` +**Correct**: {correct_indicator} +**Duration**: {total_duration:.2f}s +**Tool Calls**: {tool_calls} + +**Summary** +{summary} + +**Feedback** +{feedback} + +--- +""" + + +async def run_evaluation( + eval_path: Path, + connection: Any, + model: str = "claude-3-7-sonnet-20250219", +) -> str: + """Run evaluation with MCP server tools.""" + print("🚀 Starting Evaluation") + + client = Anthropic() + + tools = await connection.list_tools() + print(f"📋 Loaded {len(tools)} tools from MCP server") + + qa_pairs = parse_evaluation_file(eval_path) + print(f"📋 Loaded {len(qa_pairs)} evaluation tasks") + + results = [] + for i, qa_pair in enumerate(qa_pairs): + print(f"Processing task {i + 1}/{len(qa_pairs)}") + result = await evaluate_single_task(client, model, qa_pair, tools, connection, i) + results.append(result) + + correct = sum(r["score"] for r in results) + accuracy = (correct / len(results)) * 100 if results else 0 + average_duration_s = sum(r["total_duration"] for r in results) / len(results) if results else 0 + average_tool_calls = sum(r["num_tool_calls"] for r in results) / len(results) if results else 0 + total_tool_calls = sum(r["num_tool_calls"] for r in results) + + report = REPORT_HEADER.format( + correct=correct, + total=len(results), + accuracy=accuracy, + average_duration_s=average_duration_s, + average_tool_calls=average_tool_calls, + total_tool_calls=total_tool_calls, + ) + + report += "".join([ + TASK_TEMPLATE.format( + task_num=i + 1, + question=qa_pair["question"], + expected_answer=qa_pair["answer"], + actual_answer=result["actual"] or "N/A", + correct_indicator="✅" if result["score"] else "❌", + total_duration=result["total_duration"], + tool_calls=json.dumps(result["tool_calls"], indent=2), + summary=result["summary"] or "N/A", + feedback=result["feedback"] or "N/A", + ) + for i, (qa_pair, result) in enumerate(zip(qa_pairs, results)) + ]) + + return report + + +def parse_headers(header_list: list[str]) -> dict[str, str]: + """Parse header strings in format 'Key: Value' into a dictionary.""" + headers = {} + if not header_list: + return headers + + for header in header_list: + if ":" in header: + key, value = header.split(":", 1) + headers[key.strip()] = value.strip() + else: + print(f"Warning: Ignoring malformed header: {header}") + return headers + + +def parse_env_vars(env_list: list[str]) -> dict[str, str]: + """Parse environment variable strings in format 'KEY=VALUE' into a dictionary.""" + env = {} + if not env_list: + return env + + for env_var in env_list: + if "=" in env_var: + key, value = env_var.split("=", 1) + env[key.strip()] = value.strip() + else: + print(f"Warning: Ignoring malformed environment variable: {env_var}") + return env + + +async def main(): + parser = argparse.ArgumentParser( + description="Evaluate MCP servers using test questions", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Evaluate a local stdio MCP server + python evaluation.py -t stdio -c python -a my_server.py eval.xml + + # Evaluate an SSE MCP server + python evaluation.py -t sse -u https://example.com/mcp -H "Authorization: Bearer token" eval.xml + + # Evaluate an HTTP MCP server with custom model + python evaluation.py -t http -u https://example.com/mcp -m claude-3-5-sonnet-20241022 eval.xml + """, + ) + + parser.add_argument("eval_file", type=Path, help="Path to evaluation XML file") + parser.add_argument("-t", "--transport", choices=["stdio", "sse", "http"], default="stdio", help="Transport type (default: stdio)") + parser.add_argument("-m", "--model", default="claude-3-7-sonnet-20250219", help="Claude model to use (default: claude-3-7-sonnet-20250219)") + + stdio_group = parser.add_argument_group("stdio options") + stdio_group.add_argument("-c", "--command", help="Command to run MCP server (stdio only)") + stdio_group.add_argument("-a", "--args", nargs="+", help="Arguments for the command (stdio only)") + stdio_group.add_argument("-e", "--env", nargs="+", help="Environment variables in KEY=VALUE format (stdio only)") + + remote_group = parser.add_argument_group("sse/http options") + remote_group.add_argument("-u", "--url", help="MCP server URL (sse/http only)") + remote_group.add_argument("-H", "--header", nargs="+", dest="headers", help="HTTP headers in 'Key: Value' format (sse/http only)") + + parser.add_argument("-o", "--output", type=Path, help="Output file for evaluation report (default: stdout)") + + args = parser.parse_args() + + if not args.eval_file.exists(): + print(f"Error: Evaluation file not found: {args.eval_file}") + sys.exit(1) + + headers = parse_headers(args.headers) if args.headers else None + env_vars = parse_env_vars(args.env) if args.env else None + + try: + connection = create_connection( + transport=args.transport, + command=args.command, + args=args.args, + env=env_vars, + url=args.url, + headers=headers, + ) + except ValueError as e: + print(f"Error: {e}") + sys.exit(1) + + print(f"🔗 Connecting to MCP server via {args.transport}...") + + async with connection: + print("✅ Connected successfully") + report = await run_evaluation(args.eval_file, connection, args.model) + + if args.output: + args.output.write_text(report) + print(f"\n✅ Report saved to {args.output}") + else: + print("\n" + report) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/plugins/ndf/skills/mcp-builder/scripts/example_evaluation.xml b/plugins/ndf/skills/mcp-builder/scripts/example_evaluation.xml new file mode 100644 index 0000000..41e4459 --- /dev/null +++ b/plugins/ndf/skills/mcp-builder/scripts/example_evaluation.xml @@ -0,0 +1,22 @@ + + + Calculate the compound interest on $10,000 invested at 5% annual interest rate, compounded monthly for 3 years. What is the final amount in dollars (rounded to 2 decimal places)? + 11614.72 + + + A projectile is launched at a 45-degree angle with an initial velocity of 50 m/s. Calculate the total distance (in meters) it has traveled from the launch point after 2 seconds, assuming g=9.8 m/s². Round to 2 decimal places. + 87.25 + + + A sphere has a volume of 500 cubic meters. Calculate its surface area in square meters. Round to 2 decimal places. + 304.65 + + + Calculate the population standard deviation of this dataset: [12, 15, 18, 22, 25, 30, 35]. Round to 2 decimal places. + 7.61 + + + Calculate the pH of a solution with a hydrogen ion concentration of 3.5 × 10^-5 M. Round to 2 decimal places. + 4.46 + + diff --git a/plugins/ndf/skills/mcp-builder/scripts/requirements.txt b/plugins/ndf/skills/mcp-builder/scripts/requirements.txt new file mode 100644 index 0000000..e73e5d1 --- /dev/null +++ b/plugins/ndf/skills/mcp-builder/scripts/requirements.txt @@ -0,0 +1,2 @@ +anthropic>=0.39.0 +mcp>=1.1.0 diff --git a/plugins/ndf/skills/ml-model-structure/SKILL.md b/plugins/ndf/skills/ml-model-structure/SKILL.md new file mode 100644 index 0000000..d013858 --- /dev/null +++ b/plugins/ndf/skills/ml-model-structure/SKILL.md @@ -0,0 +1,152 @@ +--- +name: ml-model-structure +description: "Structure ML training, inference, and versioned models." +when_to_use: "機械学習モデルの新規構築・再学習・推論API/コンテナ開発・モデルのバージョン管理/並行運用を行うとき。analysis/ 配下に学習スクリプトや推論コードを配置する設計判断が必要なとき。Triggers: 'モデル構築', 'モデル再学習', 'モデルのバージョン管理', '推論コンテナ', '推論API', 'SageMaker', 'feature SSoT', 'train/serve skew', 'analysis ディレクトリ', 'champion challenger', '並行運用'" +allowed-tools: + - Read + - Write + - Edit + - Bash +--- + +# ML モデル構築・API開発の標準構造 + +機械学習モデルの学習コードと推論API(コンテナ)を、**版ごとに自己完結する構造**で配置するための基準。 +連番スクリプト (`01_…` `10_…`) をプロジェクト直下にフラットに並べると「どれがデプロイ済みの正規コードか」 +「どれが旧版・ボツ実験か」が判別できなくなる。本構造はこれを **版 (version) 単位の実体ディレクトリ** で解決し、 +将来 v2/v3 を champion/challenger・shadow で**並行運用**しても破綻しないようにする。 + +## 設計の核 (なぜこの形か) + +- **版ごとに自己完結**: 各版 `vN/` は独自の `features.py` / `train/` / `inference/` を持つ。 + 並行運用では特徴量ロジック自体が版ごとに進化するため、全版が単一の共通ライブラリを共有する設計は破綻する。 + 版を跨いだ共有 (`shared/`) や `current -> vN` の symlink は**作らない**。 +- **版内 feature SSoT**: 同一版の学習 (`train/`)・推論 (`inference/`)・テストが **`vN/features.py` だけ** を参照する + (import / Docker COPY)。train/serve skew (学習時と推論時で特徴量計算がずれる事故) は **版内で** 保証する。 + 版間は独立でよい — v3 が別ロジックでも v2 に影響しない。 +- **「コードの並行」と「デプロイの並行」は別レイヤー**。デプロイ並行は推論基盤側 (SageMaker production/shadow + variants・multi-model endpoint 等) で解決する。本構造が担うのは**コード層の独立**のみ。 +- **本番版の所在は symlink でなく明示管理**。並行運用では「今の本番」が複数になり得るため、 + 本番版はエンドポイント名・環境変数・README の表で管理する。 + +## 目標ディレクトリ構造 + +モデルプロジェクト 1 つ (例: `analysis/_/`) を版で分割する。 + +``` +/ # 例: analysis/20260505_oac-prediction/ +├── v1/ # 退役版: read-only アーカイブ +│ ├── train/ # 旧学習コード (features.py 非依存・自己完結) +│ ├── *_report.md *.csv # 旧レポート・成果物 +│ └── README.md # 「read-only。再実行非想定」を明記 +├── v2/ # 現役版: 自己完結の実体 (symlink なし) +│ ├── features.py # ★版内 feature SSoT (学習・推論が共有) +│ ├── train/ +│ │ ├── 01_build_dataset.py # canonical: データ生成 (→ train/data/) +│ │ ├── 10_train_model.py # canonical: 学習 → train/results/ に成果物 +│ │ ├── experiments/ # 採用根拠系の実験 (+ README)。CI非対象・参照専用 +│ │ ├── data/ # 生成データ (gitignore 対象) +│ │ └── results/ # モデル成果物 (.cbm / *.json 等) +│ ├── inference/ # 推論コンテナ一式 (詳細は references) +│ │ ├── Dockerfile # build context = vN/、COPY features.py +│ │ ├── inference.py … # ハンドラ + tests/ +│ │ └── README.md +│ ├── .dockerignore # ★build context ルート (= vN/) に置く +│ ├── export_model_to_s3.py # 成果物を model.tar.gz 化して配布 +│ ├── TRAIN_SPEC.md # train↔serve 契約書 +│ └── README.md *_report.md executive_summary.md +└── lab/ # 版非依存・不採用の探索 (+ README)。「ボツにしたアイデア集」 +``` + +> 図表は ASCII ツリーのみ例外的に許可 (本スキルは `ndf:markdown-writing` 準拠)。フロー図は mermaid を使う。 + +## 版内 SSoT と train/serve 一致 + +```mermaid +flowchart LR + subgraph TR["学習 (vN/train/)"] + A["01_build_dataset.py"] --> B["train/data/dataset.csv"] + B --> C["features.base_features"] + C --> D["10_train_model.py 学習"] + D --> E["train/results/ 成果物"] + end + subgraph SV["推論 (vN/inference/)"] + P["生 payload"] --> F["features.to_model_input"] + F --> G["features.base_features"] + G --> H["predict_proba"] + end + C -. 同一関数を共有 .- G + E -. model.tar.gz 同梱 .-> H +``` + +- 特徴量化ロジックは **`vN/features.py` に閉じ込め、学習も推論も同じ関数を import** する。 + 呼び出し側 (APIゲートウェイ等) は生データを転送するだけにし、列名マッピングや前処理を二重実装しない。 +- 学習時のみ必要な統計 (中央値補完値・列順・dtype 等) は学習スクリプトが成果物 (JSON) に書き出し、 + 推論時に読み込んで適用する。1行推論で `df.median()` が壊れる等の事故を防ぐ。 +- **回帰テスト (主防衛線)**: 「学習データの1行」と「同じ素データを `to_model_input` に通した出力」が + 全学習列で一致することを検証する。列順・クラス順・補完値も版内テストで固定する。 + +## train/ の規約 + +- **canonical (本線)**: デプロイ済みモデルを再現する最小経路。`01_build_dataset.py` → `10_train_model.py` + → `export_model_to_s3.py`。版ディレクトリで版が自明なため、ファイル名から `_v2` 等の冗長な版接尾辞は外す + (`10_v2_model.py` → `10_train_model.py`)。 +- **train/experiments/ (採用根拠系)**: その版の意思決定・解釈に寄与した実験 (ハイパラ探索・不均衡対策比較・ + SHAP・リーク検証等) を版同梱する。連番は履歴安定のため維持。CI 非対象・参照専用を README に明記。 +- **train/data/・train/results/**: データは gitignore、成果物 (モデル本体・列順/dtype/統計 JSON) は追跡する。 +- **絶対パスと相対解決の使い分け**: + - 出力先 `OUTPUT_DIR` は `…//vN/train` の**絶対パス**で固定 (symlink を使わず曖昧さを消す)。 + - `features.py` の解決は `__file__` からの**相対計算**にする (例: train 直下は `parent.parent`、 + `train/experiments/` 配下は `parents[2]` が版ルート `vN/`)。階層を変えても import が壊れにくい。 + +## inference/ の規約 (要点) + +- SageMaker 標準ハンドラ (`model_fn` / `input_fn` / `predict_fn` / `output_fn`) を実装する。 +- **build context は版ルート `vN/`** に固定し、Dockerfile は `COPY features.py …` で版内 SSoT を取り込む + (`COPY ../…` は Docker 仕様上不可)。`inference/features.py` を**実体ファイルとして置かない** + (build 時に COPY するのみ)。 +- **`.dockerignore` は build context ルート (= `vN/.dockerignore`) に置く**。BuildKit が読むのは context ルートの + `.dockerignore` か `.dockerignore` のみで、`inference/.dockerignore` という名前では機能しない。 + `train/` 等を除外して context を軽くする。 +- `model.tar.gz` には**モデル成果物のみ**を入れ、`inference.py` は ECR イメージ側 (`/opt/ml/code`) に同梱する。 + +詳細 (ハンドラ雛形・Dockerfile・テスト・TRAIN_SPEC の章立て) は +[`references/inference-and-contract.md`](references/inference-and-contract.md) を参照。 + +## lab/ と v1/ の扱い + +- **`lab/`**: その版が**採用しなかった**探索 (派生特徴量・別アルゴリズム等)。特定の版に属さないため + 版ディレクトリの外 (プロジェクト直下) に置く。README に「不採用の結論」を残す。 +- **`v1/` (旧版)**: 退役版は read-only アーカイブとして集約する。当時の `OUTPUT_DIR` 絶対パスは + **書き換えない** (再現性の歴史的記録)。README に「read-only・再実行非想定・新版で全面置換済み」を明記。 + +## TRAIN_SPEC.md (train↔serve 契約書) + +版ルートに置き、「学習で何がどう作られ、推論がそれをどう使うか」を 1 ファイルに集約する。 +推論コンテナ/エンドポイント/API結線の実装者が、学習コードを読まずに契約を把握できることが目的。 +章立てテンプレは [`references/inference-and-contract.md`](references/inference-and-contract.md)。 + +## 新しい版を作る・既存を再編する手順 + +1. **新版 `vN/` を v(N-1) と対等な実体として作る** (既存版に触れない)。`features.py` / `train/` / `inference/` + を独自に持たせ、独立してビルド・配布・エンドポイント作成できる状態にする。旧版は並行稼働を続けられる。 +2. **既存をこの構造へ再編するときは全移動を `git mv`** で行い履歴を保つ。改名 (`10_v2_model`→`10_train_model`) も `git mv`。 +3. 生成データ/成果物の gitignore を新パスに追従させる。 +4. パス参照 (絶対 `OUTPUT_DIR`・Docker build context・ドキュメントの相対リンク) を新構造に更新する。 + ディレクトリ階層を変えたら**相対リンクの深さ**もずれるので grep で洗い出す。 + +## 受け入れ基準 (再編・新規ともに) + +- [ ] 版内テストが**全 pass** (特に train/serve 一致の回帰テスト)。 +- [ ] 全 `.py` が `py_compile` を通る。 +- [ ] 全 `.py` から `features.py` の **import が解決できる** (py_compile では検出されない相対解決の崩れを潰す。 + 例: `python -c "import inference.inference"` / pytest collection が通る)。 +- [ ] `cd vN && docker build -f inference/Dockerfile …` が**通る** (build smoke)。 + Docker 不可環境では Dockerfile の `COPY` パス実在チェック (`features.py` / `inference/*` の存在 grep) で代替する。 +- [ ] `export_model_to_s3.py --dry-run` 等で成果物が新パスから取得できる。 +- [ ] canonical 学習スクリプトが**単独完走可** (他の実験スクリプトを import しない)。 +- [ ] ドキュメントに **dead link なし** (リポジトリ全体 grep)。 +- [ ] 移動は `git mv` で**履歴保持**。 + +> これらは「構造を変えたら壊れていないことを機械的に確かめる」ための最小セット。 +> ファイルを動かした直後に必ず回す。 diff --git a/plugins/ndf/skills/ml-model-structure/references/inference-and-contract.md b/plugins/ndf/skills/ml-model-structure/references/inference-and-contract.md new file mode 100644 index 0000000..b433bec --- /dev/null +++ b/plugins/ndf/skills/ml-model-structure/references/inference-and-contract.md @@ -0,0 +1,117 @@ +# 推論コンテナ規約と train↔serve 契約書テンプレ + +`SKILL.md` の「inference/ の規約」「TRAIN_SPEC.md」の詳細。推論コンテナ (SageMaker 前提) を書く / +レビューするとき、および TRAIN_SPEC.md を起こすときに読む。 + +## 目次 + +1. SageMaker 推論ハンドラ +2. Dockerfile (build context = 版ルート) +3. .dockerignore の置き場所 +4. model.tar.gz の中身 +5. テストの path 解決 (conftest) +6. TRAIN_SPEC.md の章立て + +## 1. SageMaker 推論ハンドラ + +`vN/inference/inference.py` に標準 4 関数を実装する。 + +| 関数 | 役割 | +|---|---| +| `model_fn(model_dir)` | `model_dir` からモデル本体 + 学習時統計 (列順/dtype/中央値/版文字列) を読み込む。クラス順 (`classes_`) が期待通りかを fail-fast で検証する | +| `input_fn(body, content_type)` | 生 payload を dict にパースする (変換ロジックは持たない) | +| `predict_fn(data, model)` | `features.to_model_input(data)` → `features.base_features(df, …)` → `predict_proba`。**版が採用していない派生特徴量は呼ばない**。列順は学習時に保存した列順 JSON を唯一の正とする | +| `output_fn(pred, accept)` | クラス確率 + ラベル + 版文字列を返す | + +- 特徴量化は `features.to_model_input` → `base_features` に閉じる。呼び出し側 (APIゲートウェイ) は + 生データ (統計・レスポンス項目・申込属性) を**転送するだけ**にする。 +- 版文字列 (`model_version`) は学習成果物の JSON から**動的読込**し、欠落時のみ既定値にフォールバックする。 + 「学習スクリプトが版文字列を書き出していない」と再学習時に既定値へ化けるので、書き出しを受け入れ基準に含める。 + +## 2. Dockerfile (build context = 版ルート) + +```dockerfile +# build context は版ルート vN/ に固定する。 +# cd /vN && docker build -f inference/Dockerfile -t :vN . +FROM python:3.13-slim +# … 依存インストール (MMS/sagemaker-inference を使うなら JRE と setuptools<81 に注意) … +WORKDIR /opt/ml/code +COPY inference/requirements.txt /opt/ml/code/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt +# 版内 SSoT: context 直下の features.py を取り込む (../ は不可) +COPY features.py /opt/ml/code/features.py +COPY inference/inference.py /opt/ml/code/inference.py +# … handler_service.py / dockerd-entrypoint.py 等 … +``` + +- `COPY ../features.py` は Docker 仕様上**不可** (context 外参照)。context を版ルートにすることで + `features.py` も `inference/` 配下も同一 context 内で参照できる。 +- `inference/features.py` を実体として commit しない。誤コミット検知として CI に + `find inference/ -name features.py` のガードを置くとよい。 + +## 3. .dockerignore の置き場所 (重要) + +`.dockerignore` は **build context のルート (= `vN/.dockerignore`)** に置く。 +BuildKit は「Dockerfile 隣の `.dockerignore`」か「context ルートの `.dockerignore`」しか読まないため、 +`inference/.dockerignore` に置いても**機能しない** (context が肥大化するだけ)。 + +``` +# vN/.dockerignore — 取り込むのは features.py と inference/ の推論コード + requirements のみ +train/ +*.md +*.csv +*.parquet +*.cbm +__pycache__/ +inference/tests/ +export_model_to_s3.py +``` + +## 4. model.tar.gz の中身 + +SageMaker の `model_data` (tar.gz) には**モデル成果物のみ**を入れる: + +- モデル本体 (`*.cbm` 等) + 推論に必要な学習時統計 (列順 JSON / dtype JSON / 統計 JSON)。 +- `inference.py` は**含めない** — ECR イメージ側 (`/opt/ml/code`、`SAGEMAKER_PROGRAM` / + `SAGEMAKER_SUBMIT_DIRECTORY`) に同梱する。 +- パッケージングは専用スクリプト (`export_model_to_s3.py`) で行い、必要ファイルが揃わなければ fail-fast。 + 手動 `tar` は禁止 (同期ミスの温床)。`--dry-run` で中身検証だけできるようにしておく。 + +## 5. テストの path 解決 (conftest) + +ローカルとコンテナで import 文を共通化するため、`vN/inference/tests/conftest.py` で path を相対計算する: + +```python +_HERE = os.path.dirname(__file__) # vN/inference/tests +_INFER = os.path.dirname(_HERE) # vN/inference +_V = os.path.dirname(_INFER) # vN (features.py 同梱) +for p in (_V, _INFER, _HERE): + sys.path.insert(0, p) +RESULTS_DIR = os.path.join(_V, "train", "results") +DATASET_CSV = os.path.join(_V, "train", "data", "dataset.csv") +``` + +- コンテナでは `features.py` と `inference.py` が同じ `/opt/ml/code` に並ぶため、同じ + `from features import …` が成立する。 +- 学習データが存在するときだけ走る train/serve 一致テストを入れ、存在時に pass することを受け入れ基準にする。 + +## 6. TRAIN_SPEC.md の章立て + +版ルート `vN/TRAIN_SPEC.md` に置く。性能考察 (`*_report.md`) や経営層向け概要 (`executive_summary.md`) とは +目的を分け、**train↔serve 契約に限定**する。リンクで相互参照し実体を二重化しない。 + +``` +# vN TRAIN_SPEC — train↔serve 契約書 +0. train↔serve 全体像 … mermaid (学習→成果物→推論の流れ) +1. データソースと対象母集団 … 生成SQL/期間/母集団定義/リーク除外 +2. ターゲット定義 (クラス符号化) … 学習整数 ↔ 推論ラベルの対応、クラス順 +3. 特徴量契約 … to_model_input のマッピング、列順の唯一の正 +4. 欠損補完規約 … 0補完/NA補完/中央値補完の対象と実装位置 +5. 学習成果物 … tar.gz 同梱ファイルと生成元 +6. train/serve skew の防衛線 … 回帰テストの内容と「再学習で再生成するもの」 +7. 再学習手順 … コマンド列 + 変更時の同期チェックリスト +``` + +- 「特徴量列を変えたら何を再生成・再ビルドするか」をチェックリストで明示する + (マッピング辞書・成果物・テスト golden・コンテナ再ビルド)。 +- 行番号参照を使う場合、ファイル内容が不変ならパスのみ更新し行番号は維持する。 diff --git a/plugins/ndf/skills/official-skills-autoloader/SKILL.md b/plugins/ndf/skills/official-skills-autoloader/SKILL.md new file mode 100644 index 0000000..a184bbe --- /dev/null +++ b/plugins/ndf/skills/official-skills-autoloader/SKILL.md @@ -0,0 +1,121 @@ +--- +name: official-skills-autoloader +description: "Install and use official document Skills on demand." +when_to_use: "Use when user requests Word/Excel/PowerPoint/PDF creation/editing, frontend design, webapp testing, or other tasks handled by Anthropic's official skills collection. Triggers: 'Word作成', 'Excel出力', 'スライド生成', 'PDF作成', '.docx', '.pptx', '.xlsx', '.pdf', 'create docx', 'generate excel', 'make slides', 'create pdf'." +allowed-tools: + - Bash + - Read +--- + +# 公式Skill自動ローダー + +ユーザーの要求から必要なAnthropic公式Skillを特定し、未インストールなら自動でインストール→読込して作業を進めます。利用者は**インストール作業を意識する必要がありません**。 + +## 対応マッピング + +| ユーザー要求の例 | 使用するSkill | +|---|---| +| Word / .docx / 文書 / レポート | `docx` | +| PowerPoint / .pptx / スライド / プレゼン | `pptx` | +| Excel / .xlsx / スプレッドシート / 表計算 | `xlsx` | +| PDF 生成 / フォーム / .pdf 作成 | `pdf` | +| フロントエンド設計 / UI設計 | `frontend-design` | +| Playwright / E2Eテスト / Webアプリテスト | `webapp-testing` | +| HTML/Reactアプリ生成 / Artifacts | `web-artifacts-builder` | +| 新規Skill作成 | `skill-creator` | +| Claude API / SDK開発 | `claude-api` | + +## 動作手順 + +### ステップ1: 対象Skillを特定 + +ユーザーの発話から上記マッピングで対象Skill名を1つ決定。曖昧な場合はユーザーに確認。 + +### ステップ2: インストール状態を確認 + +以下のBashコマンドで確認: + +```bash +SKILL_NAME="<対象名>" +if [ -d "$HOME/.claude/skills/$SKILL_NAME" ] || [ -L "$HOME/.claude/skills/$SKILL_NAME" ]; then + echo "INSTALLED" +else + echo "MISSING" +fi +``` + +### ステップ3: 未インストールなら自動インストール + +```bash +SKILL_NAME="<対象名>" +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/anthropic-skills" +USER_SKILLS="$HOME/.claude/skills" + +# 初回のみ公式リポジトリをclone +if [ ! -d "$CACHE_DIR/.git" ]; then + echo "公式Skillリポジトリを取得中..." + mkdir -p "$(dirname "$CACHE_DIR")" + git clone --depth 1 https://github.com/anthropics/skills.git "$CACHE_DIR" +fi + +# 対象Skillの存在確認 +if [ ! -d "$CACHE_DIR/skills/$SKILL_NAME" ]; then + echo "ERROR: $SKILL_NAME は公式リポジトリに存在しません" + exit 1 +fi + +# シンボリックリンク作成 +mkdir -p "$USER_SKILLS" +ln -sfn "$CACHE_DIR/skills/$SKILL_NAME" "$USER_SKILLS/$SKILL_NAME" +echo "Installed: $USER_SKILLS/$SKILL_NAME" +``` + +ユーザーには「公式Skill `` を準備しています...」と一言伝える。 + +### ステップ4: SKILL.mdを読み込んで実行 + +``` +Read(file_path="$HOME/.claude/skills//SKILL.md") +``` + +読み込んだSKILL.mdの内容を**現在のコンテキストで実行**する。そのSkillが指定する `scripts/` ディレクトリや `reference/` ファイルも必要に応じて読込。 + +## 注意事項 + +### ライセンス + +- Apache-2.0(mcp-builder, frontend-design, webapp-testing, claude-api 等): 再配布可 +- プロプライエタリ(docx, pptx, xlsx, pdf): **個人環境での利用のみ**。リポジトリに含めない、社内共有しない + +このautoloaderが行うのは**利用者のローカル環境へのインストールのみ**で、再配布には該当しません。 + +### パス規約 + +- cache: `~/.cache/anthropic-skills/` (XDG準拠) +- リンク先: `~/.claude/skills//` (ユーザー領域) +- プロジェクト単位で配置したい場合は `plugins/ndf/scripts/install-official-skills.sh --scope project ` を直接実行 + +### 再読込 + +同一セッション内では Read したSKILL.mdの内容で作業を完結させます。次回セッション以降はClaude Codeが自動でそのSkillを認識するため、このautoloaderは介入しません。 + +### 手動管理したい場合 + +- 一覧表示: `bash plugins/ndf/scripts/install-official-skills.sh --list` +- 更新: `bash plugins/ndf/scripts/install-official-skills.sh --update` +- 明示的なインストール: `bash plugins/ndf/scripts/install-official-skills.sh ` + +## エラーハンドリング + +| 症状 | 対応 | +|---|---| +| git clone失敗 | ネットワーク・認証を確認。プロキシ環境では HTTP_PROXY 設定を確認 | +| 対象Skillが公式にない | --list で最新の公式一覧を確認、マッピングを更新 | +| 権限エラー | `~/.claude/skills/` の書込権限を確認 | +| 既に別物がある | ユーザーに確認してから上書き | + +## 対象外 + +- 自作Skillの生成(これは `skill-creator` に委譲) +- プロプライエタリSkillのCIへの組込(ライセンス違反) +- NDFプラグイン自体のスキル管理 diff --git a/plugins/ndf/skills/playwright-browser-connect/SKILL.md b/plugins/ndf/skills/playwright-browser-connect/SKILL.md new file mode 100644 index 0000000..0607b6c --- /dev/null +++ b/plugins/ndf/skills/playwright-browser-connect/SKILL.md @@ -0,0 +1,484 @@ +--- +name: playwright-browser-connect +description: "Configure Playwright browser and CDP connections." +when_to_use: "E2E テストのブラウザ接続先を設定・変更するとき / remote Chrome に CDP で接続したいとき / WSL2 Docker から Windows Chrome を操作したいとき / macOS ホストの Chrome を使いたいとき。Triggers: 'ブラウザ接続', 'remote chrome', 'CDP接続', 'connectOverCDP', 'リモートブラウザ', 'Windows Chrome', 'macOS Chrome', 'mac Chrome', 'browser connect', 'cdp endpoint', 'remote debugging', 'コンテナからホスト Chrome 起動', 'host.docker.internal'" +allowed-tools: + - Read + - Bash +--- + +# Playwright Browser Connect (ブラウザ接続構成) + +E2E テスト実行時のブラウザ接続先を構成する。 + +## 接続モード一覧 + +| モード | scenario.config.yaml | 接続先 | 用途 | +|---|---|---|---| +| `local` | `browser.mode: local` | コンテナ内 Chromium | CI / ヘッドレス実行 (デフォルト) | +| `cdp-remote` | `browser.mode: cdp-remote` | リモート Chrome (CDP) | GUI 操作・ログイン済み Session 再利用 | + +## 設定 (scenario.config.yaml) + +```yaml +# --- ブラウザ接続設定 -------------------------------------------------- +browser: + # local: playwright install chromium でインストールしたローカルブラウザ (デフォルト) + # cdp-remote: Chrome DevTools Protocol 経由でリモートブラウザに接続 + mode: local + + # cdp-remote 時のみ有効 + cdp_endpoint: ${CDP_ENDPOINT:-http://localhost:9222} +``` + +## パターン別セットアップ + +### パターン 1: ローカルコンテナ Chromium (デフォルト) + +設定不要。`run.sh` 初回実行時に `playwright install chromium` が自動実行される。 + +```yaml +browser: + mode: local +``` + +### パターン 2: Windows ホスト Chrome (WSL2 + Docker → CDP) + +WSL2 上の Docker コンテナから Windows 側の Chrome GUI を CDP 経由で操作する。 + +#### 構成図 + +``` +Docker container (playwright) + ↓ http://host.docker.internal:9222 +Docker Desktop (WSL2 backend) + ↓ host.docker.internal → Windows host IP +Windows host + ↓ localhost:9222 +Chrome (--remote-debugging-port=9222 --remote-allow-origins=*) +``` + +#### セットアップ手順 + +**Step 1: Windows Chrome をリモートデバッグモードで起動** + +```powershell +# Windows PowerShell +& "C:\Program Files\Google\Chrome\Application\chrome.exe" ` + --remote-debugging-port=9222 ` + --remote-allow-origins=* ` + --user-data-dir="C:\tmp\chrome-debug" +``` + +既存プロファイルのログイン済み Session を使う場合: + +```powershell +# 全 Chrome プロセスを閉じてから +& "C:\Program Files\Google\Chrome\Application\chrome.exe" ` + --remote-debugging-port=9222 ` + --remote-allow-origins=* +``` + +**Step 2: `--remote-allow-origins=*` で Host ヘッダ検証を無効化** + +Chrome CDP の WebSocket は Host ヘッダ検証があるため、リモートからの接続がデフォルトで拒否される。 +Chrome 起動時に `--remote-allow-origins=*` フラグを付けることで、任意の Origin からの接続を許可できる。 + +Step 1 のコマンドにフラグを追加: + +```powershell +# Windows PowerShell +& "C:\Program Files\Google\Chrome\Application\chrome.exe" ` + --remote-debugging-port=9222 ` + --remote-allow-origins=* ` + --user-data-dir="C:\tmp\chrome-debug" +``` + +これにより proxy を設置する必要がなくなり、Docker コンテナから直接 CDP エンドポイントに接続できる。 + +> **Note**: `--remote-allow-origins=*` は Chrome 106+ で利用可能。セキュリティ上、信頼できるネットワーク内での利用に限定すること。 + +> **Important**: Chrome はデフォルトで `127.0.0.1` にバインドするため、`--remote-allow-origins=*` だけでは WSL2/Docker から接続できない場合がある。対処方法は「ネットワーク別接続ガイド」セクションを参照。 + +**Step 3: WSL2 .wslconfig (NAT mode 確認)** + +```ini +# %USERPROFILE%\.wslconfig +[wsl2] +networkingMode=NAT +``` + +**Step 4: scenario.config.yaml** + +Docker Desktop (WSL2 backend) は `host.docker.internal` を標準サポートしている。 + +```yaml +browser: + mode: cdp-remote + cdp_endpoint: ${CDP_ENDPOINT:-http://host.docker.internal:9222} +``` + +環境変数で指定する場合: + +```bash +export CDP_ENDPOINT="http://host.docker.internal:9222" +``` + +> **Note (Docker Desktop を使わず WSL2 から直接実行する場合)**: `host.docker.internal` は Docker Desktop 固有の DNS 名のため利用できない。代わりに Windows ホストの IP アドレスを直接指定する: +> ```bash +> # WSL2 から Windows ホスト IP を取得 +> export CDP_ENDPOINT="http://$(cat /etc/resolv.conf | grep nameserver | awk '{print $2}'):9222" +> ``` + +### パターン 3: macOS ホスト Chrome (Docker → CDP) + +macOS 上の Docker コンテナから macOS 側の Chrome GUI を CDP 経由で操作する。 + +#### 構成図 + +``` +Docker container (playwright) + ↓ http://host.docker.internal:9222 +macOS host + ↓ localhost:9222 +Chrome (--remote-debugging-port=9222 --remote-allow-origins=*) +``` + +#### セットアップ手順 + +**Step 1: macOS Chrome をリモートデバッグモードで起動 (ホスト側で実行)** + +実績のある起動コマンド: + +```bash +"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + --remote-debugging-port=9222 \ + --user-data-dir=/tmp/chrome-debug \ + --remote-allow-origins=* \ + --disable-features=DialMediaRouteProvider +``` + +各フラグの意味: + +| フラグ | 役割 | +|---|---| +| `--remote-debugging-port=9222` | CDP エンドポイントを 9222 で公開 | +| `--user-data-dir=/tmp/chrome-debug` | 専用プロファイルで起動 (既存の通常 Chrome と共存可能)。任意のパスでよい | +| `--remote-allow-origins=*` | CDP WebSocket の Host ヘッダ検証を無効化し、リモート (コンテナ) からの接続を許可 (Chrome 106+) | +| `--disable-features=DialMediaRouteProvider` | DIAL (Cast) のメディアルート探索を無効化。CDP ログのノイズと不要なネットワーク探索を抑制 | + +> **Note**: 上記は macOS Docker Desktop で動作実績のあるコマンド。macOS Docker Desktop は `host.docker.internal` がホストの loopback (127.0.0.1) バインドのサービスに到達できるため、`--remote-debugging-address=0.0.0.0` は不要 (全ネットワークインターフェース公開によるセキュリティ低下も避けられる)。Linux / WSL2 ホストで loopback バインドが問題になる場合は `--remote-debugging-address=0.0.0.0` を付与する (`start-host-chrome.sh` では `CDP_BIND_ADDRESS=0.0.0.0` を指定)。詳細は後述の「ネットワーク別接続ガイド」(方法 1: `0.0.0.0` / 方法 2: socat / 方法 3: netsh portproxy) を参照。 + +既存プロファイルのログイン済み Session をそのまま使う場合は、全 Chrome プロセスを終了してから +`--user-data-dir` を外して起動する (デフォルトプロファイルを使用): + +```bash +# 全 Chrome プロセスを終了してから +"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + --remote-debugging-port=9222 \ + --remote-allow-origins=* \ + --disable-features=DialMediaRouteProvider +``` + +**Step 2: scenario.config.yaml** + +macOS Docker Desktop は `host.docker.internal` を標準サポートしており、 +`--remote-allow-origins=*` で Host ヘッダ検証を無効化しているため、WSL2 と異なり **proxy 不要**。 + +```yaml +browser: + mode: cdp-remote + cdp_endpoint: ${CDP_ENDPOINT:-http://host.docker.internal:9222} +``` + +**Step 3 (任意): コンテナからホスト Chrome を起動する** + +毎回ホスト側で手動起動するのを避けたい場合は、コンテナから SSH 経由でホストの Chrome を起動できる。 +詳細は後述の「コンテナからホスト Chrome を起動する (SSH 経由)」セクションを参照。 + +## コンテナからホスト Chrome を起動する (SSH 経由) + +### なぜ直接は起動できないのか + +Docker コンテナはホストとプロセス空間が分離されているため、**コンテナ内のプロセスがホスト上に直接プロセスを生成することはできない**。 +特に macOS / Windows の Docker Desktop はコンテナを LinuxKit VM 内で実行するため、`nsenter` やホスト PID namespace を使う Linux 系の回避策も VM 止まりで macOS ホストには届かない。 + +したがって「コンテナからホストの Chrome を起動する」には、**ホスト側に起動を受け付ける口** が必要になる。最も導入が容易でスクリプト化しやすいのは **SSH** (macOS の「リモートログイン」= sshd) を使う方法。 +コンテナは `host.docker.internal` でホストに到達できるため、SSH でホストにログインして起動コマンドを実行する。 + +``` +Docker container ──ssh──▶ host.docker.internal:22 (macOS sshd) + └─▶ Google Chrome --remote-debugging-port=9222 ... (バックグラウンド起動) +Docker container ──CDP──▶ host.docker.internal:9222 (起動後に接続) +``` + +### ホスト側の準備 (一度だけ) + +1. **リモートログインを有効化**: システム設定 > 一般 > 共有 > 「リモートログイン」を ON + (CLI: `sudo systemsetup -setremotelogin on`) +2. **SSH 鍵を登録** (パスワードレス実行のため): コンテナ側の公開鍵をホストの `~/.ssh/authorized_keys` に追加 +3. ログインユーザーは **コンソールにログイン中の本人** であること。 + macOS では GUI アプリ (Chrome) は WindowServer に接続するため、コンソールセッションの所有者として起動する必要がある。 + +### スクリプト + +`scripts/start-host-chrome.sh` をコンテナ内から実行する。 +冪等で、既に CDP が起動済みなら何もしない。 + +```bash +# コンテナ内 +HOST_SSH_USER= \ + ./scripts/start-host-chrome.sh +``` + +主な環境変数: + +| 変数 | デフォルト | 説明 | +|---|---|---| +| `HOST_SSH_USER` | (必須) | ホスト (mac) のログインユーザー名 | +| `HOST_SSH_HOST` | `host.docker.internal` | SSH 接続先ホスト | +| `CDP_PORT` | `9222` | リモートデバッグポート | +| `CDP_BIND_ADDRESS` | (空=loopback) | Chrome の listen address。空なら付与せず Chrome 既定の loopback bind (macOS Docker Desktop はこれで動作・実証済み)。Linux/WSL2 等で loopback bind だとコンテナから到達できない場合のみ `0.0.0.0` 等を指定 (全インターフェース公開のためセキュリティ注意) | +| `CHROME_USER_DATA_DIR` | `/tmp/chrome-debug` | 起動プロファイル。空にするとデフォルトプロファイル (ログイン済み Session) を使用 | +| `CHROME_BIN` | `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` | Chrome バイナリパス | +| `MANUAL_WAIT` | `120` | 手動フォールバック時の起動待ち秒。`0` で待たず即終了 | + +スクリプトの動作: + +1. `http://host.docker.internal:9222/json/version` に疎通すれば **起動済み** とみなし即終了 +2. 未起動なら SSH (`BatchMode=yes`) でホストに接続し、Chrome をバックグラウンド (`nohup ... &`) で起動 +3. CDP エンドポイントが応答するまで最大 30 秒ポーリングして待機 + +#### SSH が使えない場合のフォールバック + +以下のいずれかに該当すると、スクリプトは **自動で手動フォールバックに切り替わる**: + +- `HOST_SSH_USER` が未設定 +- コンテナに `ssh` クライアントが無い +- SSH 接続/実行に失敗 (鍵未登録・リモートログイン無効・到達不可など。`BatchMode=yes` によりパスワード待ちで固まらず即失敗) + +フォールバック時は、**ホスト側で実行すべき起動コマンドをそのまま画面に出力** し、 +`MANUAL_WAIT` 秒 (既定 120s) のあいだ CDP の起動をポーリングして待機する。 +利用者はその間にホストのターミナルへコマンドを貼り付けて実行すればよく、 +起動が検知されればスクリプトは成功終了する。 + +```text +────────────────────────────────────────────────────────────── +⚠ SSH 自動起動を利用できません (SSH 接続/実行に失敗)。 + ホスト (mac) 側のターミナルで以下を実行してください: + + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 --user-data-dir='/tmp/chrome-debug' --remote-allow-origins=* --disable-features=DialMediaRouteProvider + +────────────────────────────────────────────────────────────── +→ ホストでの起動を待機中 (最大 120s, Ctrl-C で中断)... +``` + +CI など人手が介在しない環境では `MANUAL_WAIT=0` を指定すれば、案内を出して即座に非ゼロ終了する。 + +> **Note (SSH を使わない代替手段)**: ホスト側に常駐ランチャ (launchd エージェントや FIFO 監視スクリプト、簡易 HTTP エンドポイント等) を置き、コンテナからネットワーク経由でトリガする方法もある。 +> ただし SSH 方式が最も追加実装が少なく確実。X11 forwarding (XQuartz + socat) は「コンテナ内 GUI をホスト画面に表示する」用途であり、本件 (ホストの既存 Chrome を起動する) には不要。 + +> **Note (Linux ホストの場合)**: ホストで sshd が動いていれば同じスクリプトが使える。`CHROME_BIN=google-chrome`、`HOST_SSH_HOST` をホスト IP (`172.17.0.1` 等) に設定する。GUI セッションへの接続には `DISPLAY` 等の追加考慮が必要。 + +## conftest.py への統合 + +`browser.mode: cdp-remote` の場合、pytest-playwright の通常のブラウザ起動をバイパスし、 +`connectOverCDP()` で既存 Chrome に接続する fixture を有効化する必要がある。 + +`scenario-test/conftest.py` に以下を追加: + +```python +import pytest +from playwright.sync_api import Browser, BrowserType + +@pytest.fixture(scope="session") +def browser( + browser_type: BrowserType, + browser_type_launch_args: dict, + pwk_config, +) -> Browser: + """browser.mode に応じてブラウザ接続を切り替える。 + + - local: pytest-playwright デフォルト (chromium.launch()) + - cdp-remote: chromium.connect_over_cdp(endpoint) + """ + browser_cfg = pwk_config.browser + # slow_mo: browser_type_launch_args が優先、なければ config の slow_mo_ms + _slow_mo = browser_type_launch_args.get( + "slow_mo", pwk_config.playwright.slow_mo_ms or None + ) + if browser_cfg.mode == "cdp-remote": + if browser_type.name != "chromium": + pytest.fail( + f"cdp-remote モードは Chromium 専用です (現在: {browser_type.name})。" + "--browser chromium を指定するか、browser.mode を local に変更してください。" + ) + browser = browser_type.connect_over_cdp( + browser_cfg.cdp_endpoint, + slow_mo=_slow_mo, + ) + yield browser + # CDP 接続の場合、close() は接続を切断 (disconnect) するだけで、 + # リモートブラウザ自体は終了しない。 + browser.close() + else: + launch_args = {**browser_type_launch_args} + launch_args.setdefault("headless", pwk_config.playwright.headless) + if _slow_mo is not None: + launch_args.setdefault("slow_mo", _slow_mo) + browser = browser_type.launch(**launch_args) + yield browser + browser.close() +``` + +### CDP モードでの既存セッション再利用 + +`cdp-remote` モードでは、テンプレートの `conftest.py` が `context` / `page` fixture を +自動的にオーバーライドし、CDP 接続先の既存コンテキスト (ログイン済み Session) を返す。 +標準のテストコードは何も変更せずに既存セッションを利用できる。 + +```python +@pytest.fixture(scope="session") +def context(browser, pwk_config, _cdp_default_context): + """cdp-remote: 既存コンテキスト / local: 新規コンテキスト""" + if pwk_config.browser.mode == "cdp-remote" and _cdp_default_context is not None: + yield _cdp_default_context + else: + ctx = browser.new_context() + yield ctx + ctx.close() + +@pytest.fixture(scope="session") +def page(context, pwk_config): + """cdp-remote: 既存ページ / local: 新規ページ""" + if pwk_config.browser.mode == "cdp-remote" and context.pages: + yield context.pages[0] + else: + pg = context.new_page() + yield pg + pg.close() +``` + +`browser.new_context()` は新規コンテキストを作成するため、既存のログイン Session は引き継がれない。 +`cdp-remote` モードでは `context` fixture が自動的に `browser.contexts[0]` を返すため、 +テスト側で特別な対応は不要。 + +## run.sh での利用 + +`cdp-remote` モード時は `playwright install chromium` が不要。 +`run.sh` は初回セットアップで `playwright install` を実行するが、 +接続先がリモートの場合はスキップして問題ない (ローカルブラウザは使わないため)。 + +CDP 接続テストを手動確認する場合: + +```bash +# エンドポイントの疎通確認 +curl -s http://host.docker.internal:9222/json/version | python3 -m json.tool +``` + +## ネットワーク別接続ガイド + +Chrome はデフォルトで `127.0.0.1` (ループバック) にバインドするため、同一ホストからしか CDP エンドポイントにアクセスできない。Docker コンテナや WSL2 からリモート接続する場合は、以下のいずれかの方法でネットワーク到達性を確保する必要がある。 + +### 方法 1: `--remote-debugging-address=0.0.0.0` (推奨) + +Chrome 起動時に全インターフェースでリッスンさせる。最もシンプルな方法。 + +```bash +# Linux / macOS +google-chrome \ + --remote-debugging-port=9222 \ + --remote-debugging-address=0.0.0.0 \ + --remote-allow-origins=* +``` + +```powershell +# Windows PowerShell +& "C:\Program Files\Google\Chrome\Application\chrome.exe" ` + --remote-debugging-port=9222 ` + --remote-debugging-address=0.0.0.0 ` + --remote-allow-origins=* +``` + +> **Security**: `0.0.0.0` はすべてのネットワークインターフェースに公開するため、信頼できるネットワーク内でのみ使用すること。ファイアウォールでポート 9222 へのアクセスを制限することを推奨。 + +### 方法 2: socat によるポートフォワード (Linux) + +Chrome を `127.0.0.1` バインドのまま維持し、socat でリモートからのアクセスを中継する。 + +```bash +# Chrome は通常どおり起動 (127.0.0.1 バインド) +google-chrome --remote-debugging-port=9222 --remote-allow-origins=* + +# 別ターミナルで socat を起動 +socat TCP-LISTEN:9222,bind=0.0.0.0,reuseaddr,fork TCP:127.0.0.1:9222 +``` + +### 方法 3: netsh portproxy (Windows → WSL2) + +Windows ホストの Chrome を WSL2 からアクセスする場合、Windows 側でポートフォワードを設定する。 + +```powershell +# 管理者権限の PowerShell で実行 +netsh interface portproxy add v4tov4 ` + listenaddress=0.0.0.0 listenport=9222 ` + connectaddress=127.0.0.1 connectport=9222 + +# 確認 +netsh interface portproxy show all + +# 削除する場合 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=9222 +``` + +### 接続先の早見表 + +| 実行環境 | Chrome の場所 | 推奨方法 | CDP エンドポイント | +|---|---|---|---| +| ローカル (同一ホスト) | 同一ホスト | 設定不要 | `http://localhost:9222` | +| Docker → ホスト (macOS) | macOS ホスト | 方法 1 | `http://host.docker.internal:9222` | +| Docker → ホスト (Linux) | Linux ホスト | 方法 1 or 2 | `http://host.docker.internal:9222` or `http://172.17.0.1:9222` | +| Docker (WSL2) → Windows | Windows ホスト | 方法 1 or 3 | `http://host.docker.internal:9222` | +| WSL2 → Windows | Windows ホスト | 方法 1 or 3 | `http://$(cat /etc/resolv.conf \| grep nameserver \| awk '{print $2}'):9222` | + +## トラブルシュート + +### 共通 + +| 症状 | 原因 | 対策 | +|---|---|---| +| `connect_over_cdp` で接続拒否 | Chrome が起動していない / ポートが違う | `curl http:///json/version` で確認 | +| WebSocket handshake 失敗 | Host ヘッダ不一致 | Chrome 起動時に `--remote-allow-origins=*` を付与 | +| ページ操作が異常に遅い | VPN / DNS 解決の遅延 | `extra_hosts` で IP 直指定 | + +### Windows (WSL2) 固有 + +| 症状 | 原因 | 対策 | +|---|---|---| +| `host.docker.internal` 解決不能 | Docker Desktop 未使用 or 古いバージョン | Docker Desktop を使用するか、WSL2 直接の場合は Windows ホスト IP を直接指定 | +| IPv6 でバインドされる | WSL2 が IPv6 優先 | proxy.js で `0.0.0.0` を明示 | +| `netsh portproxy` で接続ループ | portproxy の自己参照 | `--remote-allow-origins=*` を使い proxy を廃止 | +| mirrored mode で動かない | mirrored は localhost 共有だが CDP の WS 接続でポート競合 | NAT mode に戻す | + +### macOS 固有 + +| 症状 | 原因 | 対策 | +|---|---|---| +| `host.docker.internal` 解決不能 | Docker Desktop が古い / Linux Docker | `--add-host=host.docker.internal:host-gateway` を指定 | +| ファイアウォールでブロック | macOS のアプリファイアウォール | システム設定 > ネットワーク > ファイアウォール で Chrome を許可 | +| SSH 起動で Chrome が表示されない / WindowServer エラー | コンソール非ログインユーザーで SSH した | コンソールにログイン中の本人ユーザーで SSH する (`start-host-chrome.sh` 参照) | +| `start-host-chrome.sh` が SSH で認証失敗 | リモートログイン未有効 / 鍵未登録 | `sudo systemsetup -setremotelogin on` と `authorized_keys` 登録を確認 | + +## CDP 接続のメリット + +- **GUI Chrome をそのまま操作可能** — OBS 録画可、人間と AI の協調操作が可能 +- **ログイン済み Session の再利用** — Google / AWS / Slack 等の MFA 済み Session をそのまま使える +- **ブラウザ拡張機能が有効** — テスト時にも拡張機能の影響を確認可能 +- **AI Agent との相性** — Claude Code / Browser Use / OpenHands がリアルブラウザを操作 + +## 関連 Skill + +- `/ndf:playwright-execution` — テスト実行 + エビデンス収集 +- `/ndf:playwright-kit-ops` — プロジェクト初期化 / ツール群 +- `/ndf:playwright-scenario-test` — 全機能統括 +- `/ndf:docker-container-access` — Docker コンテナアクセス一般 diff --git a/plugins/ndf/skills/playwright-browser-connect/scripts/start-host-chrome.sh b/plugins/ndf/skills/playwright-browser-connect/scripts/start-host-chrome.sh new file mode 100755 index 0000000..8c7b1d0 --- /dev/null +++ b/plugins/ndf/skills/playwright-browser-connect/scripts/start-host-chrome.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# コンテナ内から SSH 経由でホスト (macOS / Linux) の Chrome を +# リモートデバッグモードで起動するスクリプト。 +# +# 背景: +# Docker コンテナはホストとプロセス空間が分離されているため、 +# コンテナから直接ホストのプロセスを起動できない。 +# ホストの sshd (macOS: システム設定 > 共有 > リモートログイン) に接続し、 +# 起動コマンドを実行することで間接的にホスト Chrome を起動する。 +# +# SSH が使えない場合 (鍵未登録・リモートログイン無効など) は、 +# ホスト側で手動実行する起動コマンドを案内し、起動されるまで待機する +# フォールバックに切り替わる。 +# +# 前提 (SSH 自動起動を使う場合のみ・ホスト側で一度だけ設定): +# 1) リモートログインを有効化 (macOS: sudo systemsetup -setremotelogin on) +# 2) コンテナの公開鍵を ~/.ssh/authorized_keys に登録 (パスワードレス実行) +# 3) コンソールにログイン中の本人ユーザーで SSH すること +# (GUI アプリは WindowServer 接続のためコンソールセッション所有者が必要) +# +# 使い方 (コンテナ内): +# # SSH 自動起動 +# HOST_SSH_USER= ./scripts/start-host-chrome.sh +# # SSH を使わず手動起動の案内のみ +# ./scripts/start-host-chrome.sh +# +# 環境変数: +# HOST_SSH_USER ホストのログインユーザー名。未設定なら手動フォールバック +# HOST_SSH_HOST SSH 接続先 (default: host.docker.internal) +# CDP_HOST CDP 疎通確認先ホスト (default: HOST_SSH_HOST) +# CDP_PORT リモートデバッグポート (default: 9222) +# CDP_BIND_ADDRESS Chrome の listen address。空 (default) なら付与せず +# Chrome 既定の loopback bind (macOS Docker Desktop は +# host.docker.internal がホスト loopback に到達するため +# これで動作する)。Linux/WSL2 等で loopback bind だと +# コンテナから到達できない場合のみ 0.0.0.0 等を指定する。 +# 0.0.0.0 は全インターフェース公開のためセキュリティ注意。 +# CHROME_USER_DATA_DIR 起動プロファイル (default: /tmp/chrome-debug) +# 空にするとデフォルトプロファイル (ログイン済み Session) を使用 +# CHROME_BIN Chrome バイナリパス +# (default: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome) +# STARTUP_TIMEOUT SSH 起動後の待機タイムアウト秒 (default: 30) +# MANUAL_WAIT 手動フォールバック時の待機秒。0 で待たず即終了 (default: 120) +set -euo pipefail + +HOST_SSH_USER="${HOST_SSH_USER:-}" +HOST_SSH_HOST="${HOST_SSH_HOST:-host.docker.internal}" +CDP_HOST="${CDP_HOST:-$HOST_SSH_HOST}" +CDP_PORT="${CDP_PORT:-9222}" +CDP_BIND_ADDRESS="${CDP_BIND_ADDRESS:-}" +CHROME_USER_DATA_DIR="${CHROME_USER_DATA_DIR-/tmp/chrome-debug}" +CHROME_BIN="${CHROME_BIN:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}" +STARTUP_TIMEOUT="${STARTUP_TIMEOUT:-30}" +MANUAL_WAIT="${MANUAL_WAIT:-120}" + +# curl は冪等チェック・起動待機・手動フォールバックの全経路で CDP 疎通確認に +# 使う必須コマンド。無いと終了コード 127 で常に未起動扱いとなり、起動成功時でも +# タイムアウトしてしまうため、ここでフェイルファストする。 +if ! command -v curl >/dev/null 2>&1; then + echo "✗ curl が見つかりません。CDP 疎通確認に必須です。" >&2 + echo " コンテナに curl をインストールしてから再実行してください" >&2 + echo " (例: apt-get install -y curl / apk add curl)。" >&2 + exit 1 +fi + +cdp_up() { + # --max-time でネットワークハング時も待機ループ周期が壊れないようにする + curl -sf --max-time 2 "http://${CDP_HOST}:${CDP_PORT}/json/version" >/dev/null 2>&1 +} + +# --user-data-dir は空文字なら付与しない (デフォルトプロファイル使用) +userdata_arg="" +if [ -n "${CHROME_USER_DATA_DIR}" ]; then + userdata_arg="--user-data-dir='${CHROME_USER_DATA_DIR}'" +fi + +# --remote-debugging-address は CDP_BIND_ADDRESS が空なら付与しない。 +# 既定 (空) では Chrome は loopback (127.0.0.1) のみ listen する。macOS +# Docker Desktop は host.docker.internal がホストの loopback に到達するため +# これで動作する (ユーザー実証済み・0.0.0.0 不要)。 +# Linux/WSL2 等で loopback bind だとコンテナから到達できない場合のみ +# CDP_BIND_ADDRESS=0.0.0.0 等を指定する。0.0.0.0 は CDP を全インターフェース +# へ公開し、CDP は認証なしでブラウザ操作できるためセキュリティに注意すること。 +bind_arg="" +if [ -n "${CDP_BIND_ADDRESS}" ]; then + bind_arg="--remote-debugging-address=${CDP_BIND_ADDRESS} " +fi + +# ホスト側で実行する Chrome 起動コマンド (人間がコピペできる体裁) +host_launch_cmd() { + printf '"%s" --remote-debugging-port=%s %s%s --remote-allow-origins=* --disable-features=DialMediaRouteProvider' \ + "${CHROME_BIN}" "${CDP_PORT}" "${bind_arg}" "${userdata_arg}" +} + +# 手動フォールバック: ホストで実行するコマンドを案内し、起動を待機する +manual_fallback() { + local reason="$1" + echo "" >&2 + echo "──────────────────────────────────────────────────────────────" >&2 + echo "⚠ SSH 自動起動を利用できません (${reason})。" >&2 + echo " ホスト (mac) 側のターミナルで以下を実行してください:" >&2 + echo "" >&2 + echo " $(host_launch_cmd)" >&2 + echo "" >&2 + echo "──────────────────────────────────────────────────────────────" >&2 + + if [ "${MANUAL_WAIT}" -le 0 ]; then + echo "✗ Chrome 未起動のまま終了します (MANUAL_WAIT=0)。" >&2 + exit 1 + fi + + echo "→ ホストでの起動を待機中 (最大 ${MANUAL_WAIT}s, Ctrl-C で中断)..." >&2 + # seq 外部依存も bash 専用 for ((...)) も避け、POSIX 互換 while で待機する + # (最小コンテナ / /bin/sh しかない環境でも動作) + i=0 + while [ "$i" -lt "$MANUAL_WAIT" ]; do + if cdp_up; then + echo "✓ Chrome 起動を検知しました: http://${CDP_HOST}:${CDP_PORT}" + exit 0 + fi + sleep 1 + i=$((i + 1)) + done + echo "✗ 待機タイムアウト。手動起動後に再実行してください。" >&2 + exit 1 +} + +# 1) 既に起動済みなら何もしない (冪等) +if cdp_up; then + echo "✓ Chrome は既に CDP http://${CDP_HOST}:${CDP_PORT} で起動済みです" + exit 0 +fi + +# 2) HOST_SSH_USER 未設定 → 手動フォールバック +if [ -z "${HOST_SSH_USER}" ]; then + manual_fallback "HOST_SSH_USER が未設定" +fi + +# 3) ssh コマンドが無い → 手動フォールバック +if ! command -v ssh >/dev/null 2>&1; then + manual_fallback "コンテナに ssh クライアントが無い" +fi + +# 4) SSH でホストに接続し、Chrome をバックグラウンド起動 +echo "→ ${HOST_SSH_USER}@${HOST_SSH_HOST} で Chrome を起動します..." +# &2 +exit 1 diff --git a/plugins/ndf/skills/playwright-evidence-drive/SKILL.md b/plugins/ndf/skills/playwright-evidence-drive/SKILL.md new file mode 100644 index 0000000..43a7d46 --- /dev/null +++ b/plugins/ndf/skills/playwright-evidence-drive/SKILL.md @@ -0,0 +1,190 @@ +--- +name: playwright-evidence-drive +description: "Upload Playwright evidence to Google Drive." +when_to_use: "テストエビデンスを Google Drive に保管・共有したいとき / テスト結果を Google Docs としてチームに配布したいとき / Drive 上のエビデンスリンクを report に埋め込みたいとき。Triggers: 'Drive にアップロード', 'Drive 共有', 'エビデンス保管', 'evidence drive', 'pwk-drive-folder', 'テスト結果共有', 'Google Drive エビデンス', 'trace アップロード', '動画アップロード', 'report を Docs に', 'エビデンス配布'" +allowed-tools: + - Read + - Bash(python *) + - Bash(uv *) +--- + +# Playwright Evidence → Google Drive 保管 + +テスト実行後のエビデンス一式を Google Drive に保管し、共有可能にする。 + +## 前提条件 + +- `/ndf:google-auth` で OAuth2 認証が完了していること (drive.file スコープ) +- テスト実行済みで `reports//` にエビデンスが存在すること + +## アップロード対象 + +| ファイル | 種別 | セキュリティ考慮 | +|---|---|---| +| `video.mp4` | テスト動画 | 画面に表示された情報が含まれる | +| `trace.zip` | Playwright Trace | DOM snapshot + 操作ログ + Cookie/localStorage | +| `request.har` | ネットワーク通信ログ | HTTP request/response body を含む場合あり | +| `report.md` | テスト結果サマリ | URL + テスト名程度 | +| `body_check.jsonl` | PHP/SSR エラー詳細 | ソースコード片を含む場合あり | +| `screenshot-*.png` | 失敗時スクリーンショット | 画面に表示された情報 | + +**セキュリティ注意**: trace.zip / HAR には認証情報 (Cookie, localStorage, Basic Auth) や +入力内容が含まれる可能性がある。アップロード先は **private folder** + **信頼できる共有相手** に限定すること。 + +## 方法 1: テスト実行時の自動アップロード + +`--pwk-drive-folder` を指定すると `pytest_sessionfinish` hook で自動アップロードされる。 + +```bash +./scenario-test/run.sh --pwk-drive-folder= +``` + +動作: +1. テスト終了後に `pytest_sessionfinish` が発火 +2. `report.md` をアップロード +3. 各テストの `trace.zip` / `*.har` / `*.mp4` / `body_check.jsonl` をアップロード +4. 全ファイルは非公開 (private) でアップロードされる + +## 方法 2: 手動アップロード (scripts) + +テスト実行後に個別にアップロードする場合。スクリプトは `playwright-kit-ops/scripts/` に配置。 + +### 単一ファイルアップロード + +```bash +cd scenario-test +uv run python scripts/upload_evidence.py reports//test_login/trace.zip \ + --kind trace \ + --parent-folder-id +``` + +オプション: +- `--kind {trace|har|video|any}` — ファイル種別 (拡張子から自動判定も可) +- `--parent-folder-id ` — Drive 上のアップロード先フォルダ ID +- `--public` — anyone/read 権限を付与 (trace viewer URL 生成に必要) + +### ディレクトリ一括アップロード + +```bash +uv run python scripts/gdrive_upload_dir.py \ + --local reports// \ + --parent +``` + +ディレクトリ構造を保ったまま Drive にミラーする。 +サブフォルダも再帰的に作成される。 + +### report.md → Google Docs 変換 + +```bash +uv run python scripts/upload_md_as_gdoc.py \ + --md reports//report.md \ + --parent \ + --name "E2E テスト報告書 2026-05-26" +``` + +Markdown を Google Docs 形式に変換してアップロード。 +テーブル・リスト・見出しが Docs のネイティブ形式に変換される。 + +### Google Docs にエビデンス Drive リンクを埋め込み + +```bash +uv run python scripts/build_gdoc_with_drive_links.py \ + --md reports//report.md \ + --folder \ + --run-id \ + --name "E2E テスト報告書 2026-05-26" +``` + +1. Drive 上の `` 配下の `` フォルダからファイル一覧を取得 +2. `report.md` 内の相対パスリンク (`./TC-XX/trace.zip`) を Drive URL に書き換え +3. 書き換え済み Markdown を Google Docs としてアップロード + +→ チームメンバーが Docs 上で report を読みながら、エビデンスへの Drive リンクをクリックして確認できる。 + +## 推奨ワークフロー + +``` +[テスト実行] + ./scenario-test/run.sh --pwk-overlay + ↓ +[ローカル確認] + reports//report.md で結果確認 + ↓ +[Drive 一括アップロード] + uv run python scripts/gdrive_upload_dir.py --local reports// --parent + ↓ +[Docs 変換 + リンク埋め込み] + uv run python scripts/build_gdoc_with_drive_links.py --md reports//report.md --folder --run-id --name "報告書" + ↓ +[共有] + Docs URL をチーム (Slack / Google Chat) に共有 +``` + +ワンコマンドで全てを行う場合: + +```bash +./scenario-test/run.sh --pwk-drive-folder= --pwk-overlay +``` + +## Drive フォルダ構成の推奨 + +``` +E2E テスト証跡/ ← 共有ドライブ or チームフォルダ +├── <プロジェクト名>/ +│ ├── 20260526-134500/ ← run-id (自動生成) +│ │ ├── report.md +│ │ ├── test_login/ +│ │ │ ├── video.mp4 +│ │ │ ├── trace.zip +│ │ │ └── request.har +│ │ └── test_admin_dashboard/ +│ │ ├── video.mp4 +│ │ └── trace.zip +│ └── report (Google Docs) ← build_gdoc_with_drive_links で生成 +``` + +## Trace Viewer 連携 + +`--public` でアップロードした trace.zip は Playwright Trace Viewer で直接開ける: + +```bash +uv run python scripts/upload_evidence.py reports/.../trace.zip \ + --kind trace --public --parent-folder-id +``` + +出力例: +``` +playwright_trace_viewer: https://trace.playwright.dev/?trace=https%3A%2F%2Fdrive.google.com%2Fuc%3Fexport%3Ddownload%26id%3D... +``` + +この URL を共有すると、インストール不要でブラウザ上から trace を再生できる。 + +**注意**: `--public` は anyone/read を付与するため、trace 内の機密情報 (Cookie, 入力値) が +公開される。社内限定の場合は private のまま `playwright show-trace` をローカルで使うこと。 + +## トラブルシュート + +| 症状 | 原因 | 対策 | +|---|---|---| +| `google_auth スキルが見つかりません` | google-auth スキル未インストール | `GOOGLE_AUTH_SCRIPTS` env を設定、または `/ndf:google-auth` で認証セットアップ | +| `HttpError 403: insufficient permissions` | drive.file スコープ不足 | `/ndf:google-auth` で再認証 (`drive.file` スコープ指定) | +| `HttpError 404: File not found` | FOLDER_ID が間違っている / アクセス権なし | Drive で共有フォルダ ID を確認 | +| `resumable upload failed` | ファイルサイズが大きい / ネットワーク不安定 | 再試行。動画は mp4 (H.264) で容量を抑える | +| pytest 後に自動アップロードされない | `--pwk-drive-folder` 未指定 | CLI 引数を確認 | + +## 環境変数 + +| 変数 | 用途 | 例 | +|---|---|---| +| `GOOGLE_AUTH_SCRIPTS` | google-auth スキルの scripts/ パス | `~/.claude/skills/google-auth/scripts` | +| `PWK_DRIVE_FOLDER` | デフォルトの Drive アップロード先 (将来対応予定) | `1ABCxyz...` | + +## 関連 Skill + +- `/ndf:playwright-execution` — テスト実行 + エビデンス収集 (Drive アップロードの前段) +- `/ndf:playwright-report` — Markdown レポート生成 +- `/ndf:playwright-kit-ops` — スクリプト実行 (upload_evidence 等のスクリプトはここに配置) +- `/ndf:google-auth` — Google API OAuth2 認証 +- `/ndf:google-drive` — Google Drive 汎用操作 +- `/ndf:playwright-scenario-test` — 全機能統括 diff --git a/plugins/ndf/skills/playwright-execution/SKILL.md b/plugins/ndf/skills/playwright-execution/SKILL.md new file mode 100644 index 0000000..f99970c --- /dev/null +++ b/plugins/ndf/skills/playwright-execution/SKILL.md @@ -0,0 +1,101 @@ +--- +name: playwright-execution +description: "Run Playwright E2E tests with evidence and metrics." +when_to_use: "E2E テストの実行 / エビデンス収集 / 動画エビデンス / accessibility チェック / Core Web Vitals 計測が必要なとき。テストスクリプト作成済みであることが前提。Triggers: 'E2E テスト実行', 'テスト実行', '動画エビデンス', 'エビデンス収集', 'テスト証跡', 'a11y テスト', 'accessibility テスト', 'axe-core', 'WCAG', 'Core Web Vitals', 'Web Vitals', 'LCP', 'CLS', 'body_check', 'overlay', '字幕', 'カーソル'" +allowed-tools: + - Read + - Bash(uv *) + - Bash(pytest *) + - Bash(npx *) + - Bash(playwright *) + - Bash(python *) +--- + +# Playwright Execution (テスト実行 + エビデンス収集) + +テストスクリプト作成済みの状態で E2E テストを実行し、エビデンスを収集する。 + +## 前提条件 + +- テストスクリプトが `tests/` に作成済みであること (`/ndf:playwright-script-creation` で作成) +- `scenario.config.yaml` が設定済みであること + +## 大原則 + +**エビデンス動画はデフォルト ON**。全テストで常に動画を取得する。 +明示的にスキップする場合のみ `--pwk-no-video` を指定する。 + +## 実行コマンド + +```bash +./scenario-test/run.sh # 全テスト (動画 ON) +./scenario-test/run.sh -k test_admin # フィルタ +./scenario-test/run.sh --pwk-overlay # 字幕 + カーソル付き動画 +./scenario-test/run.sh --pwk-no-video # 動画のみ OFF +./scenario-test/run.sh --pwk-no-evidence # 全エビデンス OFF (HAR/trace/動画) +``` + +## エビデンス種別 + +| 種別 | デフォルト | OFF フラグ | 説明 | +|---|---|---|---| +| video | **ON** | `--pwk-no-video` | 全テストの動画を取得 | +| trace | ON (retain-on-failure) | `--pwk-no-evidence` | Playwright Trace (DOM + 操作ログ) | +| HAR | ON (minimal) | `--pwk-har-mode none` | ネットワーク通信ログ | +| screenshot | ON (only-on-failure) | `--pwk-no-evidence` | 失敗時スクリーンショット | + +## overlay (赤丸カーソル + 字幕) + +`--pwk-overlay` フラグで全テストの動画にオーバーレイが適用される。 + +API 詳細・使用例は `playwright_kit/overlay.py` を参照。主要関数: `set_caption()`, `flash_click()`, `hide_cursor()`。 + +## 品質計測 + +### accessibility (axe-core) + +`@pytest.mark.page_role` marker が付いたテストで auto_roles にマッチする場合に自動実行。 +設定は `scenario.config.yaml` の `accessibility:` セクションで制御。→ 設定例は `templates/scenario.config.yaml` を参照。 + +### Core Web Vitals + +`@pytest.mark.page_role` marker + auto_roles マッチで LCP/CLS/TTFB/longest_task を自動計測。 +設定は `scenario.config.yaml` の `web_vitals:` セクションで制御。→ 設定例は `templates/scenario.config.yaml` を参照。 + +### body_check (PHP/SSR エラー検出) + +`page.on("response")` で全 HTML レスポンスを監視し、`Fatal error` 等を検出。デフォルト有効。 +`@pytest.mark.no_body_check` で個別 opt-out 可能。→ 設定例は `templates/scenario.config.yaml` の `body_check:` セクションを参照。 + +## 成果物 + +``` +reports// +├── report.md # テスト結果サマリ +├── / +│ ├── video.mp4 # テスト動画 (デフォルト ON) +│ ├── trace.zip # Playwright Trace +│ ├── request.har # ネットワーク通信ログ +│ ├── body_check.jsonl # body_check 違反詳細 +│ └── screenshot-*.png # スクリーンショット +``` + +## CLI options + +| option | 役割 | +|---|---| +| `--pwk-config ` | `scenario.config.yaml` のパス | +| `--pwk-out-dir ` | 成果物出力先 (default: `reports//`) | +| `--pwk-no-video` | 動画収集を OFF (デフォルトは ON) | +| `--pwk-no-evidence` | HAR / trace / video の収集を全て OFF | +| `--pwk-har-mode {minimal,full,none}` | HAR 録画モード (default: minimal) | +| `--pwk-overlay` | overlay (赤丸カーソル + 字幕) を ON | + +## 関連 Skill + +- `/ndf:playwright-script-creation` — テストスクリプト作成 (実行の前段) +- `/ndf:playwright-report` — Markdown レポート生成 +- `/ndf:playwright-kit-ops` — スクリプト実行 (init_project / スキャン) +- `/ndf:playwright-browser-connect` — ブラウザ接続構成 (local / CDP remote) +- `/ndf:playwright-evidence-drive` — エビデンス Google Drive 保管 +- `/ndf:playwright-scenario-test` — 全機能統括 diff --git a/plugins/ndf/skills/playwright-kit-ops/.gitignore b/plugins/ndf/skills/playwright-kit-ops/.gitignore new file mode 100644 index 0000000..f9cf1ad --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ + +# pytest 実行時に生成される NDF report 出力 (run-id 別) +reports/ +.pytest_cache/ + +# uv lock は共有可 +!uv.lock diff --git a/plugins/ndf/skills/playwright-kit-ops/SKILL.md b/plugins/ndf/skills/playwright-kit-ops/SKILL.md new file mode 100644 index 0000000..991e993 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/SKILL.md @@ -0,0 +1,119 @@ +--- +name: playwright-kit-ops +description: "Operate playwright_kit setup, scans, and evidence tools." +when_to_use: "playwright_kit のスクリプトを実行するとき / E2E テストプロジェクトの初期化 / page role 自動分類 / 単発 a11y・CWV スキャン / Google Drive エビデンスアップロードが必要なとき。Triggers: 'init_project', 'プロジェクト初期化', 'classify_page_role', 'run_a11y_scan', 'check_cwv', 'upload_evidence', 'record_scenario', 'playwright_kit 実行'" +allowed-tools: + - Read + - Bash(python *) + - Bash(uv *) + - Bash(pytest *) + - Bash(playwright *) + - Bash(./scripts/*) + - Bash(bash *) + - Bash(chmod *) +--- + +# playwright_kit 操作エージェント + +playwright_kit のスクリプト群を実行してテスト環境のセットアップ・テスト実行・エビデンス管理を行う。 + +## スクリプト一覧 + +| スクリプト | 用途 | カテゴリ | +|---|---|---| +| `scripts/init_project.sh` | 利用者プロジェクトに scenario-test ランタイムを埋め込む | セットアップ | +| `scripts/init_project.bat` | 同 (Windows) | セットアップ | +| `scripts/classify_page_role.py` | URL の a11y tree + パターンから page role を自動推定 | テスト計画 | +| `scripts/record_scenario.py` | Playwright codegen で操作を記録しテストコード化 | テスト計画 | +| `scripts/run_a11y_scan.py` | axe-core による単発 accessibility スキャン | 品質 | +| `scripts/check_cwv.py` | Core Web Vitals (LCP/CLS/TTFB) 単発計測 | 品質 | +| `scripts/upload_evidence.py` | エビデンスファイルを Google Drive にアップロード | レポート | +| `scripts/gdrive_upload_dir.py` | ディレクトリごと Drive にバッチアップロード | レポート | +| `scripts/upload_md_as_gdoc.py` | Markdown を Google Doc に変換・アップロード | レポート | +| `scripts/build_gdoc_with_drive_links.py` | Google Doc にエビデンスの Drive リンクを埋め込み | レポート | + +## セットアップ + +### プロジェクト初期化 + +```bash +# SKILL_DIR はこの skill のパス +./scripts/init_project.sh /path/to/your-app + +# ディレクトリ名をカスタマイズ +./scripts/init_project.sh /path/to/your-app --runtime-dir e2e + +# Windows +scripts\init_project.bat C:\path\to\your-app +``` + +→ `your-app/scenario-test/` に all-in-one ランタイムが作成され、Skill 非依存で動作する。 + +### テスト実行 + +```bash +cd /path/to/your-app +./scenario-test/run.sh # 全テスト +./scenario-test/run.sh -k test_admin # フィルタ +./scenario-test/run.sh --pwk-overlay # 字幕 + カーソル付き動画 +./scenario-test/run.sh --pwk-drive-folder= # Drive 自動アップロード +``` + +Drive 連携は optional dependency として扱う。Codex 公開セットには `google-auth` +skill を同梱しないため、Drive 系コマンドや `--pwk-drive-folder` を使う場合は +`GOOGLE_AUTH_SCRIPTS` を `google-auth/scripts` の実パスへ設定する。 + +```bash +export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts +cd scenario-test +uv sync --extra drive +``` + +## テスト計画ツール + +```bash +# page role を自動推定 +python scripts/classify_page_role.py --url https://example.com/products + +# Playwright codegen で操作を記録 +python scripts/record_scenario.py https://example.com/login +``` + +## 品質スキャンツール + +```bash +# axe-core 単発スキャン +python scripts/run_a11y_scan.py --url https://example.com + +# Core Web Vitals 単発計測 +python scripts/check_cwv.py --url https://example.com +``` + +## エビデンスアップロードツール + +```bash +# 単一ファイルを Drive にアップロード +python scripts/upload_evidence.py reports/run-001/test_login/trace.zip --kind trace + +# ディレクトリごとアップロード +python scripts/gdrive_upload_dir.py reports/run-001/ --folder-id + +# Markdown → Google Doc 変換 +python scripts/upload_md_as_gdoc.py reports/run-001/report.md + +# Google Doc にエビデンス Drive リンクを埋め込み +python scripts/build_gdoc_with_drive_links.py reports/run-001/ +``` + +## パッケージ参照 + +playwright_kit Python パッケージ本体・templates・tests はこの skill ディレクトリ内に配置されている。 + +## 関連 Skill + +- `/ndf:playwright-test-planning` — テスト計画 (方法論 + チェックリスト) +- `/ndf:playwright-script-creation` — テストスクリプト作成 +- `/ndf:playwright-execution` — テスト実行 + エビデンス収集 (video/trace/overlay/quality) +- `/ndf:playwright-browser-connect` — ブラウザ接続構成 (local / CDP remote) +- `/ndf:playwright-report` — レポート生成 +- `/ndf:playwright-scenario-test` — 全機能統括 diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/__init__.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/__init__.py new file mode 100644 index 0000000..bd275e6 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/__init__.py @@ -0,0 +1,11 @@ +"""pytest-playwright 上で動く Web E2E シナリオテストパッケージ。 + +利用方法: +- pytest plugin として ``--pwk-config=...`` で読み込む (entry-point 経由で auto-load) +- ``pwk_config`` / ``pwk_role_`` / ``pwk_evidence`` 等の fixture を test に注入 +- ``@pytest.mark.page_role(...)`` で accessibility / web vitals を autouse + +詳細は SKILL.md を参照。 +""" + +__version__ = "0.5.0" diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/accessibility.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/accessibility.py new file mode 100644 index 0000000..588e3cd --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/accessibility.py @@ -0,0 +1,90 @@ +"""runner 内蔵の Web アクセシビリティ (axe-core) スキャンモジュール。 + +Web アクセシビリティ (旧称 a11y) は WCAG 準拠の機械検査を指し、本モジュールは +axe-core (axe-playwright-python) で WCAG 2.0/2.1/2.2 AA レベルの違反を検出する。 + +`scripts/run_a11y_scan.py` (CLI) はこのモジュールの薄いラッパで、 +runner からは `scan_page(page, ...)` を直接呼び出して `EvidenceCollectors` +の `axe_violations` に格納する。 + +page_role が `lp / list / form / dashboard / cart / checkout / settings / auth` +のとき runner が自動実行する (config.accessibility.auto_roles で上書き可能)。 +""" + +from __future__ import annotations + +from typing import Any + +from playwright.sync_api import Page + + +# WCAG 2.0/2.1/2.2 AA 準拠を最低基準として走査する。 +# WCAG 2.0 AAA は適合義務がない (一般的に過剰) ため除外。 +DEFAULT_TAGS: tuple[str, ...] = ("wcag2a", "wcag2aa", "wcag21aa", "wcag22aa") + +# page_role × a11y 自動実行のデフォルト対象。フォーム / 商取引 / 認証系は a11y 影響大。 +DEFAULT_AUTO_ROLES: frozenset[str] = frozenset({ + "lp", "list", "form", "dashboard", "cart", "checkout", "settings", "auth", +}) + + +def is_available() -> bool: + """axe-playwright-python がインストール済かを確認する (Maj-9: silent fail 対策)。""" + try: + import axe_playwright_python.sync_playwright # noqa: F401 + except ImportError: + return False + return True + + +def scan_page( + page: Page, + *, + tags: tuple[str, ...] | list[str] = DEFAULT_TAGS, +) -> list[dict[str, Any]]: + """既にロード済みの Page に対し axe-core を実行し violations の list を返す。 + + axe-playwright-python が未インストールなら空 list を返す。呼出側は事前に + `is_available()` で判定し、未インストールならスキップを明示すること。 + """ + try: + from axe_playwright_python.sync_playwright import Axe + except ImportError: + return [] + + axe = Axe() + try: + results = axe.run( + page, options={"runOnly": {"type": "tag", "values": list(tags)}}, + ) + except TypeError: + # axe-playwright-python の旧版は options 非対応 + results = axe.run(page) + + violations: list[dict[str, Any]] = [] + for v in results.response.get("violations", []): + violations.append({ + "id": v.get("id"), + "impact": v.get("impact"), + "tags": v.get("tags", []), + "help": v.get("help"), + "helpUrl": v.get("helpUrl"), + "nodes": [ + { + "html": n.get("html", "")[:200], + "target": n.get("target", []), + "failureSummary": n.get("failureSummary", "")[:300], + } + for n in v.get("nodes", []) + ], + }) + return violations + + +def should_auto_scan( + page_roles: list[str], + *, + auto_roles: frozenset[str] = DEFAULT_AUTO_ROLES, +) -> bool: + """testcase の page_role に基づき axe-core を自動実行すべきか判定する。""" + return any(r in auto_roles for r in page_roles) diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/body_check.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/body_check.py new file mode 100644 index 0000000..37a8c4c --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/body_check.py @@ -0,0 +1,118 @@ +"""ページ本文の文字列マッチ検出 (PHP / SSR エラー検出, v0.4.0)。 + +JavaScript ランタイム由来の console.error / pageerror では拾えない、 +サーバ側で HTML 本文に直接出力された "Fatal error" / "Warning:" 等の +エラー文字列を、Playwright の ``page.on("response", ...)`` で拾った +HTML 本文に対して substring match で検出する純粋関数群。 + +旧 v0.2.x の自前 YAML runner にあった ``body_check`` 機能の再実装で、 +PHP プロジェクトのフロント漏れ ``Fatal error`` / ``STRICT:`` 等を +テスト失敗として捕捉する。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable + + +@dataclass(frozen=True) +class BodyViolation: + """1 件の body_check ヒット。""" + + url: str + category: str # "fatal" / "warning" / "not_found" + pattern: str + snippet: str + + def to_dict(self) -> dict[str, Any]: + return { + "url": self.url, + "category": self.category, + "pattern": self.pattern, + "snippet": self.snippet, + } + + +def _snippet(body: str, pattern: str, *, around: int = 60) -> str: + """body 中の pattern 周辺 ``around`` 文字を取り出して表示用に整形する。""" + idx = body.find(pattern) + if idx < 0: + return pattern + start = max(0, idx - around) + end = min(len(body), idx + len(pattern) + around) + snippet = body[start:end].replace("\n", " ").replace("\r", " ").replace("\t", " ") + prefix = "..." if start > 0 else "" + suffix = "..." if end < len(body) else "" + return prefix + snippet + suffix + + +def scan_body( + body: str, + url: str, + *, + fatal_patterns: Iterable[str] = (), + warning_patterns: Iterable[str] = (), + warning_head_chars: int = 300, + not_found_patterns: Iterable[str] = (), +) -> list[BodyViolation]: + """``body`` に該当パターンが出現すれば ``BodyViolation`` のリストで返す。 + + - ``fatal_patterns`` / ``not_found_patterns`` は body 全体を substring で走査 + - ``warning_patterns`` は ``body[:warning_head_chars]`` (= 先頭 N 文字 / + code points) のみを走査。本文中の説明文や入力例の "Notice:" 等を許容する + ための head 検索。bytes ではなく code points で切るのは、日本語ページで + 300 bytes ≒ 100 字相当となり実用にならないため。 + + パターン文字列は **substring 比較** (正規表現ではない)。空文字列は無視する。 + """ + violations: list[BodyViolation] = [] + + if not body: + return violations + + head_size = max(0, int(warning_head_chars)) + head = body[:head_size] if head_size else "" + + for pat in fatal_patterns: + if pat and pat in body: + violations.append( + BodyViolation( + url=url, + category="fatal", + pattern=pat, + snippet=_snippet(body, pat), + ) + ) + + if head: + for pat in warning_patterns: + if pat and pat in head: + violations.append( + BodyViolation( + url=url, + category="warning", + pattern=pat, + snippet=_snippet(head, pat), + ) + ) + + for pat in not_found_patterns: + if pat and pat in body: + violations.append( + BodyViolation( + url=url, + category="not_found", + pattern=pat, + snippet=_snippet(body, pat), + ) + ) + + return violations + + +def is_html_response(content_type: str | None) -> bool: + """``Content-Type`` ヘッダから HTML レスポンスか判定する。""" + if not content_type: + return False + return "html" in content_type.lower() diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/config.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/config.py new file mode 100644 index 0000000..18436b4 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/config.py @@ -0,0 +1,461 @@ +"""共通設定 (config.yaml) のロードとデータクラス。 + +テストケース YAML ではなく、対象環境・ロール別ログイン・Playwright/Runner 設定、 +およびページ検査・スラッグ正規化・レポート生成のプロジェクト固有パラメータを保持する。 +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +import yaml + + +# --------------------------------------------------------------------------- +# 環境変数展開 (Codex Major 4) +# --------------------------------------------------------------------------- + +_ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}") + + +def _expand_env_in_str(s: str) -> str: + """文字列中の ${VAR} / ${VAR:-default} を環境変数で展開する。""" + def repl(m: re.Match) -> str: + name, default = m.group(1), m.group(2) + val = os.environ.get(name) + if val is None: + if default is None: + raise ValueError( + f"環境変数 ${{{name}}} が未定義です " + "(default 指定 ${VAR:-default} または env を設定してください)" + ) + return default + return val + return _ENV_RE.sub(repl, s) + + +def _expand_env(value: Any) -> Any: + """dict / list / str を再帰的に走査して ${VAR} を展開する。""" + if isinstance(value, str): + return _expand_env_in_str(value) + if isinstance(value, list): + return [_expand_env(v) for v in value] + if isinstance(value, dict): + return {k: _expand_env(v) for k, v in value.items()} + return value + + +# --- ブラウザ接続 --------------------------------------------------- + +BrowserMode = Literal["local", "cdp-remote"] +BROWSER_MODES: tuple[BrowserMode, ...] = ("local", "cdp-remote") + + +@dataclass +class BrowserConfig: + """ブラウザ接続設定。 + + cdp_endpoint が空文字列や空白のみの場合はデフォルト値 + ``http://localhost:9222`` にフォールバックする。 + """ + + mode: BrowserMode = "local" + cdp_endpoint: str = "http://localhost:9222" + + @classmethod + def from_raw(cls, raw: dict[str, Any]) -> "BrowserConfig": + base = cls() + mode_raw = str(raw.get("mode") or base.mode).lower() + if mode_raw not in BROWSER_MODES: + raise ValueError( + f"browser.mode は {BROWSER_MODES} のいずれかを指定してください " + f"(指定値: {mode_raw!r})" + ) + mode: BrowserMode = mode_raw # type: ignore[assignment] + # cdp_endpoint: 空文字列・空白のみの場合はデフォルト値にフォールバック + cdp_raw = raw.get("cdp_endpoint") + cdp_endpoint = str(cdp_raw).strip() if cdp_raw else "" + if not cdp_endpoint: + cdp_endpoint = base.cdp_endpoint + return cls( + mode=mode, + cdp_endpoint=cdp_endpoint, + ) + + +# --- 接続/認証 ------------------------------------------------------- + +@dataclass +class BasicAuth: + user: str + password: str + + +@dataclass +class Login: + path: str + requires_basic_auth: bool + fields: dict[str, str] + fail_if_url_contains: str + # ログイン送信ボタンを特定するためのプロジェクト固有セレクタ (CSS / role / text)。 + # auth fixture の _submit_login_form が「これ → role/type=submit フォールバック + # → Password で Enter」の順で試す。空のままでも汎用フォールバックで通常はログインできる。 + submit_selectors: list[str] = field(default_factory=list) + + +@dataclass +class Role: + id: str + label: str + login: Login + + +# --- レポート設定 --------------------------------------------------- + +@dataclass +class ReportConfig: + title: str = "シナリオ E2E テスト 実施報告書" + test_plan_link: str = "./test-plan.md" + phase_labels: dict[int, str] = field(default_factory=dict) + + +# --- Playwright / Runner ------------------------------------------- + +# Playwright ``record_har_mode`` に直接渡す値。"minimal" は request/response の +# メタデータのみ記録し、Basic 認証 + redirect が連続するページで navigation を +# abort させる race を回避する (Issue #62)。"full" は body も含めた完全な HAR、 +# "none" は HAR を出力しない (= ``record_har_path`` を inject しない)。 +HarMode = Literal["minimal", "full", "none"] +HAR_MODES: tuple[HarMode, ...] = ("minimal", "full", "none") + + +@dataclass +class PlaywrightConfig: + headless: bool = True + viewport_width: int = 1280 + viewport_height: int = 720 + slow_mo_ms: int = 0 + video_width: int = 1280 + video_height: int = 720 + navigation_timeout_ms: int = 30000 + # 各ステップ遷移後の表示維持時間 (動画でじっくり見せるため) + step_delay_ms: int = 1800 + # 動画にカーソル+字幕オーバーレイを焼き込む (true 推奨) + enable_overlay: bool = True + # Playwright Trace (trace.zip) を出力する。クリック箇所のハイライト・ + # DOM スナップショット・コンソール・ネットワークなどを `playwright show-trace` + # で対話的に確認できる。生成物が大きく (数MB〜) なるので必要時のみ。 + enable_trace: bool = True + # 録画後の動画フォーマット: "webm" (Playwright 既定) | "mp4" (H.264 変換) + # mp4 は Google Drive プレビュアで再生互換性が高い。 + video_format: str = "mp4" + # HAR 録画モード (Issue #62)。Playwright >= 1.30 で導入された + # ``record_har_mode`` に対応する。 + # - "minimal" (default): メタデータのみ記録。Basic 認証 + redirect が混在 + # するページで ``record_har_path`` 起因の ERR_ABORTED race を回避する。 + # - "full": Playwright 既定の full HAR (body + content)。 + # - "none": HAR を一切出力しない (= ``record_har_path`` を inject しない)。 + har_mode: HarMode = "minimal" + + @classmethod + def from_raw(cls, raw: dict[str, Any]) -> "PlaywrightConfig": + # dataclass の default を真実の源 (single source of truth) とする。 + # fallback 値を base = cls() から参照することで、dataclass default と + # from_raw() の fallback が乖離するバグを防ぐ (Codex Minor 6)。 + base = cls() + viewport = raw.get("viewport") or {} + video_size = raw.get("video_size") or {} + har_mode_raw = str(raw.get("har_mode", base.har_mode)).lower() + if har_mode_raw not in HAR_MODES: + raise ValueError( + f"playwright.har_mode は {HAR_MODES} のいずれかを指定してください " + f"(指定値: {har_mode_raw!r})" + ) + har_mode: HarMode = har_mode_raw # type: ignore[assignment] + return cls( + headless=bool(raw.get("headless", base.headless)), + viewport_width=int(viewport.get("width", base.viewport_width)), + viewport_height=int(viewport.get("height", base.viewport_height)), + slow_mo_ms=int(raw.get("slow_mo_ms", base.slow_mo_ms)), + video_width=int(video_size.get("width", base.video_width)), + video_height=int(video_size.get("height", base.video_height)), + navigation_timeout_ms=int(raw.get("navigation_timeout_ms", base.navigation_timeout_ms)), + step_delay_ms=int(raw.get("step_delay_ms", base.step_delay_ms)), + enable_overlay=bool(raw.get("enable_overlay", base.enable_overlay)), + enable_trace=bool(raw.get("enable_trace", base.enable_trace)), + video_format=str(raw.get("video_format", base.video_format)).lower(), + har_mode=har_mode, + ) + + @classmethod + def defaults(cls) -> "PlaywrightConfig": + """設定が完全に省略された場合の defaults。viewport=video_size=1280x720 で揃える。""" + return cls() + + +@dataclass +class RunnerConfig: + workers: int = 4 + testcases_dir: str = "./testcases" + + @classmethod + def from_raw(cls, raw: dict[str, Any]) -> "RunnerConfig": + return cls( + workers=int(raw.get("workers", 4)), + testcases_dir=str(raw.get("testcases_dir", "./testcases")), + ) + + +# --- accessibility / web vitals (v0.3.0) ----------------------------- + +@dataclass +class AccessibilityConfig: + """axe-core 自動スキャンの設定 (page_role に応じて runner が自動実行)。""" + enabled: bool = True + auto_roles: list[str] = field(default_factory=lambda: [ + "lp", "list", "form", "dashboard", "cart", "checkout", "settings", "auth", + ]) + tags: list[str] = field(default_factory=lambda: [ + "wcag2a", "wcag2aa", "wcag21aa", "wcag22aa", + ]) + # 検出した violations を testcase の FAIL 要因として扱うか (false なら情報出力のみ) + fail_on_violations: bool = True + + +@dataclass +class WebVitalsConfig: + """Core Web Vitals 自動計測の設定 (page_role に応じて runner が自動実行)。""" + enabled: bool = True + auto_roles: list[str] = field(default_factory=lambda: [ + "lp", "list", "dashboard", "search", + ]) + observe_ms: int = 5000 + # poor 判定が 1 件でもあれば testcase を FAIL とするか + fail_on_poor: bool = True + + +# --- body_check (PHP / SSR エラー検出, v0.4.0) ---------------------- + +@dataclass +class BodyCheckConfig: + """ページ本文の文字列マッチ検出 (PHP / SSR プロジェクト向け)。 + + JavaScript ランタイム由来の console.error / pageerror では拾えない、 + サーバ側で HTML 本文に直接出力された "Fatal error" / "Warning:" 等の + エラー文字列を、Playwright の ``page.on("response", ...)`` を介して + 検出する。 + + - ``fatal_patterns``: HTML 本文全体に対する substring match。1 つでも + 含まれれば violation。 + - ``warning_patterns``: 本文の **先頭 ``warning_head_chars`` 文字** に + 対する substring match。本文中の説明文に含まれる "Notice:" 等は許容し、 + ページ最上段への漏れだけを拾う。 + (PLAN18 の説明文は「先頭 300 文字」、code point ベース。日本語ページで + 300 bytes だと先頭 1〜2 行しか見えず実用にならないため、文字数を採用。) + - ``not_found_patterns``: 本文全体への substring match。 + - ``fail_on_match``: True なら violation 検出時に ``pytest.fail``。 + False なら情報収集のみ (report.md / body_check.jsonl には記録)。 + + default は ``enabled=True`` + PHP 系のフロント漏れ検出パターンを内蔵。 + config.yaml を書かなくてもまず PHP プロジェクトで素直に動く。 + """ + + enabled: bool = True + fatal_patterns: list[str] = field(default_factory=lambda: [ + "Fatal error", + "Uncaught", + "Parse error", + ]) + warning_patterns: list[str] = field(default_factory=lambda: [ + "STRICT:", + "Warning:", + "Notice:", + "Deprecated:", + ]) + # 文字数ベースの head 切り出し閾値 (code points)。PLAN18 のフィールド名は + # ``warning_head_bytes`` だったが、説明文は「先頭 300 文字」と書かれており + # 矛盾していた。実用上は文字数の方が日本語ページで安定するため採用。 + warning_head_chars: int = 300 + not_found_patterns: list[str] = field(default_factory=lambda: [ + "File not found", + ]) + fail_on_match: bool = True + + +# --- ルート --------------------------------------------------------- + +@dataclass +class Config: + base_url: str + basic_auth: BasicAuth + verify_tls: bool + roles: dict[str, Role] + playwright: PlaywrightConfig + runner: RunnerConfig + report: ReportConfig + config_path: Path # 設定ファイルの絶対パス(testcases_dir の解決基点) + browser: BrowserConfig = field(default_factory=BrowserConfig) + # docs/checklists/checklist-common.md C8/C9 の境界曖昧さに対応する「除外」設定。 + # console.error / pageerror の本文がいずれかの正規表現にマッチした場合は + # 集計から除外し FAIL を抑制する。3rd party の既知 warning などを許容するための + # 抜け穴。空 (デフォルト) なら従来どおり 1 件で FAIL。 + tolerated_console_errors: list[str] = field(default_factory=list) + tolerated_page_errors: list[str] = field(default_factory=list) + # accessibility / web_vitals 自動実行 (page_role に応じて runner が判定) + accessibility: AccessibilityConfig = field(default_factory=AccessibilityConfig) + web_vitals: WebVitalsConfig = field(default_factory=WebVitalsConfig) + # PHP / SSR ページ本文エラー検出 (v0.4.0, opt-in) + body_check: BodyCheckConfig = field(default_factory=BodyCheckConfig) + + @property + def testcases_dir(self) -> Path: + d = Path(self.runner.testcases_dir) + if not d.is_absolute(): + d = self.config_path.parent / d + return d.resolve() + + def role(self, role_id: str) -> Role: + if role_id not in self.roles: + raise KeyError(f"未定義のロール: {role_id}. roles 設定を確認してください。") + return self.roles[role_id] + + @classmethod + def load(cls, path: Path) -> "Config": + if not path.exists(): + raise FileNotFoundError( + f"設定ファイルが見つかりません: {path}\n" + "templates/scenario.config.yaml をコピーして作成してください。" + ) + with path.open("r", encoding="utf-8") as fp: + raw = yaml.safe_load(fp) + if not isinstance(raw, dict): + raise ValueError( + f"scenario.config.yaml の中身が空または辞書ではありません: {path}\n" + "templates/scenario.config.yaml をコピーして必要項目を埋めてください。" + ) + raw = _expand_env(raw) + return cls._from_dict(raw, config_path=path.resolve()) + + @classmethod + def _from_dict(cls, raw: dict[str, Any], *, config_path: Path) -> "Config": + target = raw["target"] + # basic_auth は省略可能 (サイトに Basic 認証が掛かっていない場合)。 + # 省略時は空 BasicAuth を使い、role 側で `requires_basic_auth: true` を + # 指定したテストケースだけが basic_auth ヘッダを要求する設計。 + ba_raw = target.get("basic_auth") or {} + basic_auth = BasicAuth( + user=str(ba_raw.get("user", "")), + password=str(ba_raw.get("password", "")), + ) + roles = {rid: _role_from_raw(rid, r) for rid, r in (raw.get("roles") or {}).items()} + + cfg = cls( + base_url=target["base_url"].rstrip("/"), + basic_auth=basic_auth, + verify_tls=bool(raw.get("verify_tls", False)), + roles=roles, + playwright=PlaywrightConfig.from_raw(raw.get("playwright") or {}), + runner=RunnerConfig.from_raw(raw.get("runner") or {}), + report=_report_from_raw(raw.get("report") or {}), + config_path=config_path, + browser=BrowserConfig.from_raw(raw.get("browser") or {}), + tolerated_console_errors=list(raw.get("tolerated_console_errors") or []), + tolerated_page_errors=list(raw.get("tolerated_page_errors") or []), + accessibility=_accessibility_from_raw(raw.get("accessibility") or {}), + web_vitals=_web_vitals_from_raw(raw.get("web_vitals") or {}), + body_check=_body_check_from_raw(raw.get("body_check") or {}), + ) + + # fail-fast: requires_basic_auth=True なロールが宣言されているのに + # basic_auth.user が空ならば実行時に HTTP 401 で必ず落ちる。先に検出して + # 設定不備として ValueError を投げる (Maj-4)。 + for role in cfg.roles.values(): + if role.login.requires_basic_auth and not basic_auth.user: + raise ValueError( + f"role '{role.id}' は requires_basic_auth=True だが、" + f"target.basic_auth.user が空 (config.yaml を確認してください)" + ) + + return cfg + + +def _role_from_raw(rid: str, raw: dict[str, Any]) -> Role: + login = raw["login"] + return Role( + id=rid, + label=str(raw.get("label", rid)), + login=Login( + path=login["path"], + requires_basic_auth=bool(login.get("requires_basic_auth", False)), + fields=dict(login["fields"]), + fail_if_url_contains=login["fail_if_url_contains"], + submit_selectors=list(login.get("submit_selectors") or []), + ), + ) + + +def _report_from_raw(raw: dict[str, Any]) -> ReportConfig: + labels_raw = raw.get("phase_labels") or {} + return ReportConfig( + title=str(raw.get("title", "シナリオ E2E テスト 実施報告書")), + test_plan_link=str(raw.get("test_plan_link", "./test-plan.md")), + phase_labels={int(k): str(v) for k, v in labels_raw.items()}, + ) + + +def _accessibility_from_raw(raw: dict[str, Any]) -> AccessibilityConfig: + base = AccessibilityConfig() + return AccessibilityConfig( + enabled=bool(raw.get("enabled", base.enabled)), + auto_roles=list(raw.get("auto_roles") or base.auto_roles), + tags=list(raw.get("tags") or base.tags), + fail_on_violations=bool(raw.get("fail_on_violations", base.fail_on_violations)), + ) + + +def _web_vitals_from_raw(raw: dict[str, Any]) -> WebVitalsConfig: + base = WebVitalsConfig() + return WebVitalsConfig( + enabled=bool(raw.get("enabled", base.enabled)), + auto_roles=list(raw.get("auto_roles") or base.auto_roles), + observe_ms=int(raw.get("observe_ms", base.observe_ms)), + fail_on_poor=bool(raw.get("fail_on_poor", base.fail_on_poor)), + ) + + +def _body_check_from_raw(raw: dict[str, Any]) -> BodyCheckConfig: + """``body_check`` セクションを ``BodyCheckConfig`` に変換する。 + + - キーが **省略** されている場合は dataclass の default 値を採用する + (config を書かなくても PHP 系のデフォルトパターンが効くようにするため)。 + - キーが **明示的に空リスト** で書かれている場合はそのまま空リストにする + (default を上書きしてカテゴリを無効化したい場合の挙動)。 + """ + base = BodyCheckConfig() + + def _patterns(key: str, default: list[str]) -> list[str]: + if key not in raw: + return list(default) + value = raw.get(key) + if value is None: + return list(default) + return [str(s) for s in value] + + # ``warning_head_chars`` を新フィールド名として採用。旧名 ``warning_head_bytes`` + # も alias として受理する (PLAN18 がフィールド名と説明文で矛盾していた経緯)。 + head_chars = raw.get("warning_head_chars") + if head_chars is None: + head_chars = raw.get("warning_head_bytes", base.warning_head_chars) + + return BodyCheckConfig( + enabled=bool(raw.get("enabled", base.enabled)), + fatal_patterns=_patterns("fatal_patterns", base.fatal_patterns), + warning_patterns=_patterns("warning_patterns", base.warning_patterns), + warning_head_chars=int(head_chars), + not_found_patterns=_patterns("not_found_patterns", base.not_found_patterns), + fail_on_match=bool(raw.get("fail_on_match", base.fail_on_match)), + ) diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py new file mode 100644 index 0000000..b9eb500 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py @@ -0,0 +1,19 @@ +"""playwright-scenario-test pytest fixtures。 + +利用者は通常の pytest テストを書き、`pwk_config` / `pwk_role_` 等の +fixture をパラメタ宣言するだけで NDF の機能 (config / 認証 / evidence / +accessibility / web vitals / overlay / Drive) を享受できる。 + +各 fixture の実体はサブモジュールに分離する: +- ``auth`` : ``pwk_config`` / ``pwk_role_`` (login 済 storage_state) +- ``evidence`` : ``pwk_evidence`` (HAR / trace / console listeners) +- ``accessibility`` : autouse hook で page_role marker に応じ axe-core を実行 +- ``web_vitals`` : autouse hook で page_role marker に応じ Core Web Vitals 計測 + +pytest plugin (``playwright_kit.pytest_plugin``) から ``pytest_plugins`` で +読み込まれる想定。利用者プロジェクトの ``conftest.py`` で個別 import する必要は無い。 +""" + +from __future__ import annotations + +__all__ = ["auth"] diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py new file mode 100644 index 0000000..eafc0a7 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py @@ -0,0 +1,143 @@ +"""accessibility fixture: ``page_role`` marker に応じた axe-core 自動スキャン。 + +Web アクセシビリティ (WCAG 準拠) を ``axe-core`` で機械検査する fixture。 +``@pytest.mark.page_role("form")`` 等が付与された test 関数の終了直前に +axe-core を自動実行する。 + +利用方法: +- ``@pytest.mark.page_role("form")`` を test に付与すれば autouse 経由で + axe-core が走る (config.accessibility.auto_roles に該当する場合のみ) +- 違反があれば ``config.accessibility.fail_on_violations`` (default True) に従い + ``pytest.fail`` する +- 明示的に scan したい場合は ``pwk_accessibility_scan`` fixture を直接呼ぶ +""" + +from __future__ import annotations + +from collections import Counter +from typing import Iterator + +import pytest + +from playwright_kit import accessibility as accessibility_mod +from playwright_kit.config import Config +from playwright_kit.fixtures.evidence import PwkEvidence + + +def _page_roles_from_marker(item) -> list[str]: + """test item から ``page_role`` marker の引数 (役割名 list) を集約する。""" + roles: list[str] = [] + for marker in item.iter_markers(name="page_role"): + for arg in marker.args: + if isinstance(arg, str): + roles.append(arg) + elif isinstance(arg, (list, tuple)): + roles.extend(str(a) for a in arg) + return roles + + +@pytest.fixture() +def pwk_accessibility_scan(page, pwk_evidence: PwkEvidence, _pwk_config_optional): + """明示呼び出し用: ``violations = pwk_accessibility_scan()`` で 1 度スキャン。""" + config: Config | None = _pwk_config_optional + + def _scan(*, tags: tuple[str, ...] | None = None) -> list[dict]: + if not accessibility_mod.is_available(): + pwk_evidence.log_lines.append( + "[accessibility] axe-playwright-python 未インストール — SKIP " + "(`uv sync --extra a11y` で有効化)" + ) + return [] + actual_tags = ( + tuple(tags) + if tags is not None + else ( + tuple(config.accessibility.tags) + if config is not None + else accessibility_mod.DEFAULT_TAGS + ) + ) + violations = accessibility_mod.scan_page(page, tags=actual_tags) + pwk_evidence.axe_violations.extend(violations) + return violations + + return _scan + + +@pytest.fixture(autouse=True) +def _pwk_accessibility_autouse(request) -> Iterator[None]: + """``page_role`` marker が付いた test の終了直前に axe-core を実行する。 + + ``page`` fixture を **要求している test のみ** 対象。autouse fixture が + 無条件に ``page`` を要求すると、pytest-playwright が全 test を browser + parametrize してしまうため、ここでは ``request.fixturenames`` を見て + 必要な test だけ取得する。 + + Issue #60 fix: 旧版の ``"pwk_evidence" not in request.fixturenames`` ガードを + 廃止。test 引数に ``pwk_evidence`` を書いていなくても ``getfixturevalue`` + 経由で lazy 取得し、accessibility autouse が走るようにする。 + + teardown order 対策 (Issue #61): pytest fixture の teardown は LIFO のため、 + ``yield`` 後に ``getfixturevalue("pwk_evidence")`` を呼ぶと「既に解放済」 + AssertionError が発生する。setup phase で ``ev`` / ``page`` を取得して + closure に保持し、teardown phase はその参照のみを使う。 + """ + + # ``page`` を要求していない (= browser を使わない) test では何もしない。 + # これにより pure pytest test の挙動に影響を与えない。 + if "page" not in request.fixturenames: + yield + return + + config: Config | None = request.getfixturevalue("_pwk_config_optional") + if config is None or not config.accessibility.enabled: + yield + return + page_roles = _page_roles_from_marker(request.node) + if not page_roles: + yield + return + if not accessibility_mod.should_auto_scan( + page_roles, auto_roles=frozenset(config.accessibility.auto_roles) + ): + yield + return + + # setup phase: closure に必要なオブジェクトを束ねる。 + pwk_evidence: PwkEvidence = request.getfixturevalue("pwk_evidence") + page = request.getfixturevalue("page") + + yield + + # teardown phase: closure に保持した ev / page のみを参照する。 + if not accessibility_mod.is_available(): + pwk_evidence.log_lines.append( + "[accessibility autouse] axe-playwright-python 未インストール — SKIP" + ) + return + + try: + if page.is_closed(): + return + except Exception: + return + + violations = accessibility_mod.scan_page(page, tags=tuple(config.accessibility.tags)) + pwk_evidence.axe_violations.extend(violations) + if not violations: + return + + impacts = Counter(v.get("impact") or "unknown" for v in violations) + impact_summary = ", ".join(f"{k}={n}" for k, n in impacts.most_common()) + pwk_evidence.log_lines.append( + f"[accessibility autouse] {len(violations)} violations: {impact_summary}" + ) + + if config.accessibility.fail_on_violations: + pytest.fail( + f"[accessibility] {len(violations)} 件の axe-core 違反 " + f"[{impact_summary}]: " + + ", ".join( + f"{v.get('id')}({v.get('impact', '?')})" for v in violations[:5] + ) + ) diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py new file mode 100644 index 0000000..964f1e1 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py @@ -0,0 +1,318 @@ +"""認証 fixture: ``pwk_config`` と動的に生成する ``pwk_role_``。 + +pytest-playwright が提供する ``page`` / ``context`` / ``browser_context_args`` +fixture と協調して動作する。 + +設計方針: +- ``pwk_config`` は session scope。``--pwk-config`` で指定された YAML を + 1 度だけ読み込む。利用者プロジェクトの ``conftest.py`` から override 可能。 +- 各 role に対し ``pwk_role_`` fixture を *動的* に生成する。 + 実体は ``_login_and_get_storage_state`` で session 内 1 回だけ login し、 + storage_state を session-scoped cache (`_StorageStateCache`) に保管。 + 以降の test では同じ role の cache を ``context.add_cookies`` 等で再利用する + ことで login の再実行を避ける。 +- function scope で ``page.context.storage_state(...)`` を inject し、 + ``page`` は既に該当 role でログイン済みの状態で test 関数に渡される。 + +fail_if_url_contains による失敗判定もここで行い、test 開始前に明示的に +``pytest.fail`` する。 +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlsplit + +import pytest + +from playwright_kit.config import Config, Login, Role + + +def _same_origin(origin_url: str, base_url: str) -> bool: + """``origin_url`` が ``base_url`` と同一 origin (scheme + host + port) かを返す。 + + storage_state には認証対象以外のサードパーティ origin (広告 / 計測タグ等) が + 含まれることがある。restore 時にそれらへ ``page.goto(url)`` するのは意図しない + 外部送信になるため、本関数で base_url の origin に厳格一致するもののみ許可する。 + """ + try: + a = urlsplit(origin_url) + b = urlsplit(base_url) + except ValueError: + return False + return ( + bool(a.scheme) + and bool(a.hostname) + and a.scheme == b.scheme + and a.hostname == b.hostname + and (a.port or None) == (b.port or None) + ) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +@dataclass +class _StorageStateCache: + """session 内で role ごとの storage_state を 1 回だけ作る簡易 cache。""" + + states: dict[str, dict[str, Any]] + + @classmethod + def empty(cls) -> "_StorageStateCache": + return cls(states={}) + + def get(self, role_id: str) -> dict[str, Any] | None: + return self.states.get(role_id) + + def put(self, role_id: str, state: dict[str, Any]) -> None: + self.states[role_id] = state + + +def _submit_login_form(page, login: Login) -> None: + """ログインフォームの submit を行う。 + + 優先順位は ``login.submit_selectors`` → role/type=submit → password Enter。 + """ + for sel in login.submit_selectors: + try: + page.locator(sel).first.click(timeout=2000) + return + except Exception: + continue + for fallback in ( + "role=button[name=/login|sign.?in|ログイン/i]", + 'button[type="submit"]', + 'input[type="submit"]', + ): + try: + page.locator(fallback).first.click(timeout=2000) + return + except Exception: + continue + pw_field = next( + (n for n in login.fields if "pass" in n.lower() or "pwd" in n.lower()), + None, + ) + if pw_field: + page.locator(f'input[name="{pw_field}"]').press("Enter") + return + raise RuntimeError( + "ログイン送信ボタンが見つかりません (submit_selectors を設定してください)" + ) + + +def _login_and_get_storage_state( + *, + playwright, + base_url: str, + role: Role, + basic_auth_user: str, + basic_auth_password: str, + verify_tls: bool, + nav_timeout_ms: int = 30_000, +) -> dict[str, Any]: + """role の login flow を実行し storage_state を返す。 + + 1 度だけ呼ばれることを想定。失敗時は ``pytest.fail`` を投げる。 + + AQ Critical-2 完遂: 関数全体を browser try/finally で囲み、 + page.goto() / fill() / expect_navigation() / fail_if_url_contains で + pytest.fail() が発生した場合も含め、全ての failure path で + browser.close() が必ず呼ばれることを保証する。 + pytest.fail() は内部的に例外を raise するため finally は確実に動く。 + """ + browser = playwright.chromium.launch(headless=True) + try: + ctx_kwargs: dict[str, Any] = { + "ignore_https_errors": not verify_tls, + } + if role.login.requires_basic_auth: + ctx_kwargs["http_credentials"] = { + "username": basic_auth_user, + "password": basic_auth_password, + } + context = browser.new_context(**ctx_kwargs) + context.set_default_navigation_timeout(nav_timeout_ms) + context.set_default_timeout(nav_timeout_ms) + + try: + page = context.new_page() + url = f"{base_url}{role.login.path}" + try: + page.goto(url, wait_until="domcontentloaded", timeout=nav_timeout_ms) + except Exception as exc: # pragma: no cover - depends on remote target + pytest.fail( + f"[pwk_role_{role.id}] login page open failed: {url} ({exc})" + ) + + for name, value in role.login.fields.items(): + try: + page.locator(f'input[name="{name}"]').fill( + value, timeout=nav_timeout_ms + ) + except Exception as exc: # pragma: no cover + pytest.fail( + f"[pwk_role_{role.id}] fill {name!r} failed: {exc}" + ) + + try: + with page.expect_navigation( + wait_until="domcontentloaded", timeout=nav_timeout_ms + ): + _submit_login_form(page, role.login) + except Exception as exc: # pragma: no cover + pytest.fail( + f"[pwk_role_{role.id}] navigation 失敗: " + f"{type(exc).__name__}: {exc}" + ) + + final_url = page.url + # Amazon Q Critical-1: fail_if_url_contains が空文字列の場合、空文字列は + # あらゆる文字列に含まれるため常に True になり全 login が失敗する。 + # 空文字列 (= 未設定) の場合はチェックをスキップする。 + if role.login.fail_if_url_contains and role.login.fail_if_url_contains in final_url: + pytest.fail( + f"[pwk_role_{role.id}] login 失敗: " + f"final_url={final_url} に '{role.login.fail_if_url_contains}' を含む" + ) + + state = context.storage_state() + return state + finally: + try: + context.close() + except Exception: + pass + finally: + try: + browser.close() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Public fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def pwk_config(pytestconfig) -> Config: + """``--pwk-config`` で指定された YAML をロードして ``Config`` を返す。 + + 指定が無い場合は CWD 直下の ``scenario.config.yaml`` を試し、 + それも無ければ ``pytest.skip`` する (NDF 機能が要らない test と共存可能にする)。 + """ + raw_path: str | None = pytestconfig.getoption("pwk_config", default=None) + if not raw_path: + env = os.environ.get("PWK_CONFIG") + if env: + raw_path = env + if not raw_path: + candidate = Path.cwd() / "scenario.config.yaml" + if candidate.exists(): + raw_path = str(candidate) + if not raw_path: + pytest.skip( + "pwk_config 未指定: --pwk-config もしくは PWK_CONFIG env、" + "または ./scenario.config.yaml を用意してください。" + ) + + path = Path(raw_path).resolve() + return Config.load(path) + + +@pytest.fixture(scope="session") +def _pwk_storage_state_cache() -> _StorageStateCache: + return _StorageStateCache.empty() + + +def _make_role_fixture(role_id: str) -> Callable: + """role_id ごとに ``pwk_role_`` fixture の実装関数を生成する。""" + + def _fixture( + pwk_config: Config, + playwright, + context, + _pwk_storage_state_cache: _StorageStateCache, + ) -> Role: + """login 済の storage_state を ``context`` に注入し、Role を返す。 + + - ``playwright`` / ``context`` は ``pytest-playwright`` 提供 + - 既に同 role の storage_state が cache 済なら login をスキップ + """ + role = pwk_config.role(role_id) + + state = _pwk_storage_state_cache.get(role_id) + if state is None: + state = _login_and_get_storage_state( + playwright=playwright, + base_url=pwk_config.base_url, + role=role, + basic_auth_user=pwk_config.basic_auth.user, + basic_auth_password=pwk_config.basic_auth.password, + verify_tls=pwk_config.verify_tls, + nav_timeout_ms=pwk_config.playwright.navigation_timeout_ms, + ) + _pwk_storage_state_cache.put(role_id, state) + + # cookies / origins (localStorage 等) を新しい context に注入する。 + cookies = state.get("cookies") or [] + if cookies: + context.add_cookies(cookies) + # storage_state に含まれる広告/計測タグ等の third-party origin に対して + # 不用意に goto するのを避けるため、base_url と同一 origin のみ復元する。 + for origin in state.get("origins") or []: + url = origin.get("origin") + items = origin.get("localStorage") or [] + if not url or not items: + continue + if not _same_origin(url, pwk_config.base_url): + continue + try: + page = context.new_page() + page.goto(url, wait_until="domcontentloaded") + for it in items: + page.evaluate( + "([k, v]) => window.localStorage.setItem(k, v)", + [it.get("name"), it.get("value")], + ) + page.close() + except Exception: + # localStorage 注入失敗は致命的ではない (cookie ベースの認証なら OK)。 + pass + + return role + + _fixture.__name__ = f"pwk_role_{role_id}" + _fixture.__doc__ = ( + f"role={role_id!r} で login 済の storage_state を context に注入する。" + ) + return _fixture + + +def register_role_fixtures(plugin_module, config: Config) -> list[str]: + """plugin module に ``pwk_role_`` fixture を動的登録する。 + + ``pytest_configure`` から呼ばれる。pytest は modules の attribute を + fixture として discover するため、setattr で十分。 + + Returns: + 登録した fixture 名のリスト。 + """ + registered: list[str] = [] + for role_id in config.roles: + name = f"pwk_role_{role_id}" + if hasattr(plugin_module, name): + continue + impl = _make_role_fixture(role_id) + # function scope (default) で wrap してから plugin module に attach + wrapped = pytest.fixture(name=name)(impl) + setattr(plugin_module, name, wrapped) + registered.append(name) + return registered diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py new file mode 100644 index 0000000..d6437c2 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py @@ -0,0 +1,194 @@ +"""body_check fixture: ``page.on("response", ...)`` でレスポンス本文を監視し、 +PHP / SSR が HTML 本文に出力したエラー文字列を検出する (v0.4.0)。 + +config.yaml の ``body_check.enabled`` の default は ``True`` (PHP 系パターン +内蔵)。``page`` fixture を要求している test に限り autouse で listener を +attach する (accessibility autouse と同じ guard 戦略)。 + +opt-out: +- 全体無効化: ``body_check.enabled: false`` を config.yaml に明示 +- カテゴリ単位: ``fatal_patterns: []`` などで明示空指定 +- 個別 test: ``@pytest.mark.no_body_check`` を付与 + +注意: +- 同じ page を ``pwk_body_check_scan`` helper で手動スキャンしつつ autouse + listener も走らせると、同一 violation を重複加算する。helper は autouse の + listener が拾えないタイミング (例: SPA 内のローカル DOM 更新後) でのみ使う。 +""" + +from __future__ import annotations + +import json +from typing import Any, Iterator + +import pytest + +from playwright_kit.body_check import ( + is_html_response, + scan_body, +) +from playwright_kit.config import BodyCheckConfig, Config +from playwright_kit.fixtures.evidence import PwkEvidence + + +def _build_response_handler(cfg: BodyCheckConfig, ev: PwkEvidence): + """``page.on("response", ...)`` 用の handler を closure として作る。 + + listener 内で発生する例外は test 失敗には伝播させず ``ev.log_lines`` に + 記録するに留める (accessibility / pageerror listener と同じ防御方針)。 + """ + fatal = tuple(cfg.fatal_patterns) + warn = tuple(cfg.warning_patterns) + not_found = tuple(cfg.not_found_patterns) + head_chars = int(cfg.warning_head_chars) + + def _on_response(response) -> None: + try: + headers = response.headers or {} + if not is_html_response(headers.get("content-type") or headers.get("Content-Type")): + return + try: + body = response.text() + except Exception: + # navigation 中の中断 / closed context などで text() が失敗するケース + return + violations = scan_body( + body, + response.url, + fatal_patterns=fatal, + warning_patterns=warn, + warning_head_chars=head_chars, + not_found_patterns=not_found, + ) + for v in violations: + ev.body_check_violations.append(v.to_dict()) + except Exception as exc: # pragma: no cover - listener 内で test を落とさない + ev.log_lines.append(f"[body_check listener] {exc}") + + return _on_response + + +def _write_jsonl(ev: PwkEvidence) -> None: + """1 violation = 1 行で ``case_dir/body_check.jsonl`` に書き出す。""" + if not ev.body_check_violations: + return + try: + path = ev.case_dir / "body_check.jsonl" + with path.open("w", encoding="utf-8") as fp: + for v in ev.body_check_violations: + fp.write(json.dumps(v, ensure_ascii=False) + "\n") + except Exception as exc: # pragma: no cover + ev.log_lines.append(f"[body_check] jsonl write failed: {exc}") + + +def _format_violation_summary(violations: list[dict[str, Any]], limit: int = 5) -> str: + """``pytest.fail`` メッセージ用の短いサマリ文字列。""" + parts: list[str] = [] + for v in violations[:limit]: + url = v.get("url", "?") + cat = v.get("category", "?") + pat = v.get("pattern", "?") + parts.append(f"{cat}:{pat!r}@{url}") + if len(violations) > limit: + parts.append(f"... (+{len(violations) - limit} more)") + return "; ".join(parts) + + +@pytest.fixture() +def pwk_body_check_scan(page, pwk_evidence: PwkEvidence, _pwk_config_optional): + """明示呼び出し用: ``violations = pwk_body_check_scan()`` で現在の page 本文を 1 度スキャン。 + + autouse 経路を使わず、特定タイミング (例: フォーム送信後の 200 応答) で + 本文を再評価したい場合の helper。 + """ + config: Config | None = _pwk_config_optional + + def _scan() -> list[dict[str, Any]]: + if config is None or not config.body_check.enabled: + return [] + try: + body = page.content() + except Exception as exc: + pwk_evidence.log_lines.append(f"[body_check] page.content() failed: {exc}") + return [] + violations = scan_body( + body, + page.url, + fatal_patterns=config.body_check.fatal_patterns, + warning_patterns=config.body_check.warning_patterns, + warning_head_chars=config.body_check.warning_head_chars, + not_found_patterns=config.body_check.not_found_patterns, + ) + as_dicts = [v.to_dict() for v in violations] + pwk_evidence.body_check_violations.extend(as_dicts) + return as_dicts + + return _scan + + +@pytest.fixture(autouse=True) +def _pwk_body_check_autouse(request) -> Iterator[None]: + """``page`` を要求する test に限り、HTML response への body_check を自動実行する。 + + ガード戦略: + - ``page`` を fixturename に持たない test は対象外 (browser を起動させない) + - config.body_check.enabled が False なら何もしない + - ``@pytest.mark.no_body_check`` が付いている test は skip + + ``pwk_evidence`` は ``getfixturevalue`` で setup phase に lazy resolve する。 + test 関数の引数に ``pwk_evidence`` を書いていなくても listener が attach される + (Issue #60)。 + + teardown 時に違反があれば ``case_dir/body_check.jsonl`` に書き出し、 + ``fail_on_match`` が True なら ``pytest.fail`` で test を失敗させる。 + """ + if "page" not in request.fixturenames: + yield + return + + config: Config | None = request.getfixturevalue("_pwk_config_optional") + if config is None or not config.body_check.enabled: + yield + return + + if request.node.get_closest_marker("no_body_check") is not None: + yield + return + + page = request.getfixturevalue("page") + # setup phase で fetch して closure に保持する (Issue #61 と同じ teardown + # order 問題を防ぐ)。 + ev: PwkEvidence = request.getfixturevalue("pwk_evidence") + handler = _build_response_handler(config.body_check, ev) + + try: + page.on("response", handler) + except Exception as exc: + ev.log_lines.append(f"[body_check] page.on attach failed: {exc}") + yield + return + + try: + yield + finally: + try: + page.remove_listener("response", handler) + except Exception: + # page が既に閉じられている場合などは黙殺 + pass + + if not ev.body_check_violations: + return + + _write_jsonl(ev) + + ev.log_lines.append( + f"[body_check] {len(ev.body_check_violations)} 件の違反: " + + _format_violation_summary(ev.body_check_violations) + ) + + if config.body_check.fail_on_match: + pytest.fail( + f"[body_check] {len(ev.body_check_violations)} 件の本文エラーを検出: " + + _format_violation_summary(ev.body_check_violations) + ) diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py new file mode 100644 index 0000000..c5ab484 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py @@ -0,0 +1,353 @@ +"""evidence fixture: HAR / trace / console / pageerror の収集。 + +pytest-playwright が提供する ``browser_context_args`` / ``context`` / ``page`` +fixture と組み合わせて、test 関数ごとに以下を自動収集する: + +- HAR: ``browser_context_args`` に ``record_har_path`` を inject +- trace: ``context.tracing.start`` / ``stop`` (``--pwk-no-evidence`` で無効化) +- console.error / pageerror: page listener として attach + (``tolerated_console_errors`` / ``tolerated_page_errors`` でフィルタ) + +artifact の出力先は ``--pwk-out-dir`` (default: ``./reports//``)。 +test 関数 ID から sub-dir を作って 1 test = 1 dir で隔離する。 +""" + +from __future__ import annotations + +import datetime as _dt +import hashlib +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterator + +import pytest + +from playwright_kit.config import Config, PlaywrightConfig + + +# --------------------------------------------------------------------------- +# Output directory resolver (session scope) +# --------------------------------------------------------------------------- + + +def _resolve_out_dir(pytestconfig) -> Path: + """``--pwk-out-dir`` が指定されればそれを、なければ ``reports//``。 + + run_id は session 開始時に 1 度だけ決定し、``pytestconfig._pwk_out_dir`` に + キャッシュする。これにより ``pwk_out_dir`` fixture と + ``pytest_terminal_summary`` が別々に ``datetime.now()`` を呼んで + 秒またぎでディレクトリがズレる問題を防ぐ (新規 Major 対応)。 + + ``--pwk-out-dir`` が明示指定されている場合はキャッシュ不要のため + 常にその値を返す(複数回呼ばれても同じ値)。 + """ + raw: str | None = pytestconfig.getoption("pwk_out_dir", default=None) + if raw: + return Path(raw).resolve() + + # --pwk-out-dir 未指定時のみキャッシュで run_id の秒またぎを防ぐ。 + # hasattr で厳密にチェックし、MagicMock 等が偽の属性を返さないようにする。 + if "_pwk_out_dir" in vars(pytestconfig): + return pytestconfig._pwk_out_dir # type: ignore[attr-defined] + + run_id = _dt.datetime.now().strftime("%Y%m%d-%H%M%S") + out = (Path.cwd() / "reports" / run_id).resolve() + # session-scoped キャッシュとして保存 + pytestconfig._pwk_out_dir = out # type: ignore[attr-defined] + return out + + +@pytest.fixture(scope="session") +def pwk_out_dir(pytestconfig) -> Path: + """session 全体で共有する成果物ルート。session 開始時に作成する。 + + ``_resolve_out_dir`` を通じて ``pytestconfig._pwk_out_dir`` にキャッシュし、 + ``pytest_terminal_summary`` と同じ out_dir を参照する。 + """ + out = _resolve_out_dir(pytestconfig) + out.mkdir(parents=True, exist_ok=True) + return out + + +# --------------------------------------------------------------------------- +# Per-test evidence (function scope) +# --------------------------------------------------------------------------- + + +_FILENAME_SAFE_RE = re.compile(r"[^\w\-]+") + + +def _safe_slug(name: str, fallback: str = "test") -> str: + """文字列から安全なファイル名スラグを生成する (後方互換維持)。""" + s = _FILENAME_SAFE_RE.sub("-", name).strip("-").lower() + return s[:80] or fallback + + +def _safe_case_slug(node: Any) -> str: + """nodeid + xdist worker + sha1[:6] suffix で衝突しない slug を生成する (Codex Major 2)。 + + - parametrize / 同名関数 / xdist 並列で trace.zip / request.har の上書きを防止。 + - 既存の _safe_slug(name, fallback) 仕様は変えず、evidence fixture 内のみ本関数を使う。 + """ + nodeid = getattr(node, "nodeid", getattr(node, "name", "test")) + worker = os.environ.get("PYTEST_XDIST_WORKER", "") + raw = f"{nodeid}@{worker}" if worker else nodeid + slug = _FILENAME_SAFE_RE.sub("-", raw).strip("-").lower() + digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:6] + # 60 文字 + "-" + sha1[:6] = 最大 67 文字程度に圧縮 + return f"{slug[:60]}-{digest}".strip("-") or "test" + + +@dataclass +class PwkEvidence: + """1 test 関数分の証跡コレクタ。""" + + case_dir: Path + config: Config | None + enabled: bool + + har_path: Path | None = None + trace_path: Path | None = None + har_relpath: str | None = None + trace_relpath: str | None = None + + console_errors: list[str] = field(default_factory=list) + page_errors: list[str] = field(default_factory=list) + axe_violations: list[dict[str, Any]] = field(default_factory=list) + web_vitals_metrics: dict[str, float] = field(default_factory=dict) + web_vitals_passed: bool = True + # PHP / SSR ページ本文エラー (body_check) 違反 (v0.4.0)。1 件 = 1 dict + # ({url, category, pattern, snippet})。 + body_check_violations: list[dict[str, Any]] = field(default_factory=list) + + log_lines: list[str] = field(default_factory=list) + + _trace_started: bool = field(default=False, init=False, repr=False) + _tolerated_console_re: list[re.Pattern[str]] = field( + default_factory=list, init=False, repr=False + ) + _tolerated_page_re: list[re.Pattern[str]] = field( + default_factory=list, init=False, repr=False + ) + + def __post_init__(self) -> None: + if self.config is not None: + self._tolerated_console_re = [ + re.compile(p) for p in self.config.tolerated_console_errors + ] + self._tolerated_page_re = [ + re.compile(p) for p in self.config.tolerated_page_errors + ] + + # --- listener ------------------------------------------------------ + + def attach_listeners(self, page) -> None: + page.on("console", self._on_console) + page.on("pageerror", self._on_pageerror) + + def _on_console(self, msg) -> None: + try: + if msg.type != "error": + return + loc = getattr(msg, "location", None) or {} + text = msg.text[:500] + for rx in self._tolerated_console_re: + if rx.search(text): + return + self.console_errors.append(f"{loc.get('url', '?')}: {text}") + except Exception as exc: # pragma: no cover + self.log_lines.append(f"[console listener] {exc}") + + def _on_pageerror(self, exc) -> None: + try: + text = str(exc)[:1000] + for rx in self._tolerated_page_re: + if rx.search(text): + return + self.page_errors.append(text) + except Exception as listener_exc: # pragma: no cover + self.log_lines.append(f"[pageerror listener] {listener_exc}") + + # --- trace lifecycle (context scope) ------------------------------- + + def start_tracing(self, context) -> None: + if not self.enabled or self.trace_path is None: + return + try: + context.tracing.start( + name=self.case_dir.name, + title=self.case_dir.name, + snapshots=True, + screenshots=True, + sources=False, + ) + self._trace_started = True + except Exception as exc: # pragma: no cover + self.log_lines.append(f"[trace] start 失敗: {exc}") + + def stop_tracing(self, context) -> None: + if not self._trace_started or self.trace_path is None: + return + try: + context.tracing.stop(path=str(self.trace_path)) + self.trace_relpath = self.trace_path.name + except Exception as exc: # pragma: no cover + self.log_lines.append(f"[trace] stop 失敗: {exc}") + + def confirm_har(self) -> None: + if self.har_path and self.har_path.exists(): + self.har_relpath = self.har_path.name + + # --- summary ------------------------------------------------------- + + @property + def has_runtime_errors(self) -> bool: + return bool(self.console_errors or self.page_errors) + + def runtime_error_summary(self) -> str: + parts: list[str] = [] + if self.page_errors: + parts.append(f"pageerror {len(self.page_errors)} 件") + if self.console_errors: + parts.append(f"console.error {len(self.console_errors)} 件") + return "Runtime errors detected: " + ", ".join(parts) if parts else "" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def _pwk_config_optional(pytestconfig) -> Config | None: + """``pwk_config`` を session に 1 度だけ load する (失敗時は None)。 + + evidence fixture は pwk_config が無くても動くように optional にしてある。 + """ + cached = getattr(pytestconfig, "_pwk_config", None) + if cached is not None: + return cached # type: ignore[no-any-return] + return None + + +def _resolve_har_mode(pytestconfig, pwk_config) -> str: + """``--pwk-har-mode`` CLI > ``playwright.har_mode`` config > ``PlaywrightConfig`` default。 + + Issue #62 対策で default は ``minimal``。``--pwk-no-evidence`` が True の場合 + 呼び出し側で HAR を一切 inject しない (本関数の戻り値は使われない)。 + + default 値は ``PlaywrightConfig().har_mode`` から引くことで dataclass 側との + 二重管理を防ぐ。 + """ + cli = pytestconfig.getoption("pwk_har_mode", default=None) + if cli: + # ``pytest_plugin.py`` の argparse ``choices`` で値は担保済だが、 + # API 経由 (``--config`` 等) で大文字が来た場合に備える defensive normalize。 + return str(cli).lower() + if pwk_config is not None: + return str(pwk_config.playwright.har_mode).lower() + return PlaywrightConfig().har_mode + + +@pytest.fixture() +def browser_context_args( + browser_context_args, request, pytestconfig, pwk_out_dir, _pwk_config_optional +) -> dict[str, Any]: + """pytest-playwright の ``browser_context_args`` を function scope で override し、 + 1 test = 1 HAR を実現する (Codex Major 1)。 + + - scope を function に変更し、``request.node`` ごとに ``case_dir/request.har`` + を ``record_har_path`` に inject する。 + - session 共通 HAR (``session.har``) は廃止。これにより + ``PwkEvidence.confirm_har()`` が常に None を返す不整合を解消。 + - ``--pwk-no-evidence`` が True なら HAR 収集を OFF。 + - HAR mode (Issue #62): + - ``--pwk-har-mode none`` (または config ``playwright.har_mode: none``): + ``record_har_path`` を inject しない。 + - ``minimal`` (default): ``record_har_mode="minimal"`` でメタデータのみ + 記録。Basic 認証 + redirect 連続時の ``ERR_ABORTED`` race を回避する。 + - ``full``: Playwright 既定の full HAR (body + content) を記録。 + """ + no_evidence = bool(pytestconfig.getoption("pwk_no_evidence", default=False)) + args = dict(browser_context_args or {}) + if no_evidence: + return args + + har_mode = _resolve_har_mode(pytestconfig, _pwk_config_optional) + if har_mode == "none": + return args + + case_dir = pwk_out_dir / _safe_case_slug(request.node) + case_dir.mkdir(parents=True, exist_ok=True) + args.setdefault("record_har_path", str(case_dir / "request.har")) + if har_mode == "minimal": + # Playwright の record_har_mode="minimal" (Playwright >= 1.30) は + # request/response の主要メタデータのみ。content は記録されないので + # omit 指定は不要。 + args.setdefault("record_har_mode", "minimal") + else: # "full" + # 既存挙動 (body 含む) を維持しつつ content だけは省略する。 + args.setdefault("record_har_content", "omit") + return args + + +@pytest.fixture() +def pwk_evidence( + request, + pytestconfig, + pwk_out_dir: Path, + _pwk_config_optional, + context, + page, +) -> Iterator[PwkEvidence]: + """1 test 関数分の evidence collector を返す。 + + - ``--pwk-no-evidence`` が True なら trace/HAR を OFF にし、listener のみ動かす + - ``page`` fixture から console / pageerror listener を attach + - ``context.tracing.start/stop`` を裏で実行 (有効時) + - ``--pwk-overlay`` 指定時は ``overlay.OVERLAY_INIT_SCRIPT`` を ``context.add_init_script`` + で全 page に inject する + - ``pytest_runtest_makereport`` から FAIL 時に ``pwk_evidence`` の状態を確認可能 + """ + enabled = not bool(pytestconfig.getoption("pwk_no_evidence", default=False)) + overlay_enabled = bool(pytestconfig.getoption("pwk_overlay", default=False)) + # _safe_case_slug で nodeid + xdist worker + sha1[:6] の衝突しない slug を使用 (Codex Major 2) + case_dir = pwk_out_dir / _safe_case_slug(request.node) + case_dir.mkdir(parents=True, exist_ok=True) + + # HAR mode が "none" のときは ``request.har`` を期待しないようにする + # (browser_context_args で record_har_path 自体を inject していない: Issue #62)。 + har_mode = _resolve_har_mode(pytestconfig, _pwk_config_optional) + har_enabled = enabled and har_mode != "none" + + ev = PwkEvidence( + case_dir=case_dir, + config=_pwk_config_optional, + enabled=enabled, + har_path=(case_dir / "request.har") if har_enabled else None, + trace_path=(case_dir / "trace.zip") if enabled else None, + ) + ev.attach_listeners(page) + ev.start_tracing(context) + + # overlay (赤丸カーソル + 字幕、旧名 HUD) を init_script で inject。 + if overlay_enabled: + try: + from playwright_kit.overlay import OVERLAY_INIT_SCRIPT + + context.add_init_script(OVERLAY_INIT_SCRIPT) + except Exception as exc: # pragma: no cover + ev.log_lines.append(f"[overlay] add_init_script 失敗: {exc}") + + # request.node に ev を保持して makereport hook から参照可能にする + request.node._pwk_evidence = ev # type: ignore[attr-defined] + + try: + yield ev + finally: + try: + ev.stop_tracing(context) + finally: + ev.confirm_har() diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py new file mode 100644 index 0000000..924e382 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py @@ -0,0 +1,109 @@ +"""Web Vitals fixture: ``page_role`` marker に応じた Core Web Vitals 自動計測。 + +``@pytest.mark.page_role("dashboard")`` 等が付与された test の終了直前に +LCP (Largest Contentful Paint) / CLS (Cumulative Layout Shift) / +TTFB (Time To First Byte) / longest_task (Long Tasks API) を計測する。 +""" + +from __future__ import annotations + +from typing import Iterator + +import pytest + +from playwright_kit import web_vitals as web_vitals_mod +from playwright_kit.config import Config +from playwright_kit.fixtures.evidence import PwkEvidence + + +def _page_roles_from_marker(item) -> list[str]: + roles: list[str] = [] + for marker in item.iter_markers(name="page_role"): + for arg in marker.args: + if isinstance(arg, str): + roles.append(arg) + elif isinstance(arg, (list, tuple)): + roles.extend(str(a) for a in arg) + return roles + + +@pytest.fixture() +def pwk_web_vitals_measure(page, pwk_evidence: PwkEvidence, _pwk_config_optional): + """明示呼び出し用: ``metrics = pwk_web_vitals_measure()`` で 1 度計測。""" + config: Config | None = _pwk_config_optional + + def _measure(*, observe_ms: int | None = None) -> dict[str, float]: + ms = ( + int(observe_ms) + if observe_ms is not None + else ( + int(config.web_vitals.observe_ms) + if config is not None + else 5000 + ) + ) + metrics = web_vitals_mod.measure_page(page, observe_ms=ms) + pwk_evidence.web_vitals_metrics.update(metrics) + pwk_evidence.web_vitals_passed = web_vitals_mod.passed(pwk_evidence.web_vitals_metrics) + return metrics + + return _measure + + +@pytest.fixture(autouse=True) +def _pwk_web_vitals_autouse(request) -> Iterator[None]: + """``page_role`` marker が付いた test の終了直前に Web Vitals 計測を行う。 + + accessibility autouse と同じく ``page`` fixture を要求している test のみ対象。 + + Issue #60 fix: 旧版の ``"pwk_evidence" not in request.fixturenames`` ガードを + 廃止。test 引数に ``pwk_evidence`` を書いていなくても ``getfixturevalue`` + 経由で lazy 取得し、Web Vitals autouse が走るようにする。 + + teardown order 対策 (Issue #61): ``yield`` 後に ``pwk_evidence`` を fetch + しようとすると LIFO 解放済の AssertionError になるため、setup phase で + ``ev`` / ``page`` を取得して closure に保持する。 + """ + + if "page" not in request.fixturenames: + yield + return + + config: Config | None = request.getfixturevalue("_pwk_config_optional") + if config is None or not config.web_vitals.enabled: + yield + return + page_roles = _page_roles_from_marker(request.node) + if not page_roles: + yield + return + if not web_vitals_mod.should_auto_measure( + page_roles, auto_roles=frozenset(config.web_vitals.auto_roles) + ): + yield + return + + # setup phase: closure に保持。 + pwk_evidence: PwkEvidence = request.getfixturevalue("pwk_evidence") + page = request.getfixturevalue("page") + + yield + + # teardown phase: closure 経由でアクセス。 + try: + if page.is_closed(): + return + except Exception: + return + + metrics = web_vitals_mod.measure_page(page, observe_ms=int(config.web_vitals.observe_ms)) + pwk_evidence.web_vitals_metrics.update(metrics) + pwk_evidence.web_vitals_passed = web_vitals_mod.passed(pwk_evidence.web_vitals_metrics) + + detail = ", ".join( + f"{k}={v:.1f}({web_vitals_mod.judge(k, v)})" for k, v in metrics.items() + ) or "no metrics collected" + pwk_evidence.log_lines.append(f"[web_vitals autouse] {detail}") + + if not pwk_evidence.web_vitals_passed and config.web_vitals.fail_on_poor: + pytest.fail(f"[web_vitals] poor metric を検出: {detail}") diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/overlay.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/overlay.py new file mode 100644 index 0000000..21e9844 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/overlay.py @@ -0,0 +1,220 @@ +"""動画録画用 HUD オーバーレイ (赤丸カーソル + 2 行字幕) の制御。 + +ブラウザ標準のカーソルは録画に焼き付かないため、JS で `
` を 2 つ inject する: + - `#__hud_cursor` — mousemove/down/up を追う赤丸(クリックで黄色リップル) + - `#__hud_caption` — 画面上部の 2 行字幕(`直前 │ … / 次へ │ …`) + +OVERLAY_INIT_SCRIPT は context.add_init_script() に渡す。 +他の関数は Page を受け取り、HUD の状態を JS evaluate 越しに更新する。 +""" + +from __future__ import annotations + +from playwright.sync_api import Page + + +OVERLAY_INIT_SCRIPT = r""" +(() => { + if (window.__hudInited) return; + window.__hudInited = true; + function setup() { + if (!document.body) return false; + const cursor = document.createElement('div'); + cursor.id = '__hud_cursor'; + cursor.style.cssText = + 'position:fixed;width:24px;height:24px;border-radius:50%;' + + 'background:rgba(255,80,80,0.55);border:2px solid #f33;' + + 'pointer-events:none;z-index:2147483647;' + + 'transform:translate(-50%,-50%);' + + 'box-shadow:0 0 10px rgba(255,0,0,0.7);transition:background 0.1s;'; + document.documentElement.appendChild(cursor); + // 前のページから引き継いだ最終カーソル位置と表示状態を復元 + try { + const cx = sessionStorage.getItem('__hudCursorX'); + const cy = sessionStorage.getItem('__hudCursorY'); + const cv = sessionStorage.getItem('__hudCursorVisible'); + if (cx !== null) cursor.style.left = cx + 'px'; + if (cy !== null) cursor.style.top = cy + 'px'; + // 既定は「非表示」 (擬似クリック対象がないステップは消す方針) + if (cv === '1') { + cursor.style.opacity = '1'; + cursor.style.visibility = 'visible'; + } else { + cursor.style.opacity = '0'; + cursor.style.visibility = 'hidden'; + } + } catch (e) {} + document.addEventListener('mousemove', (e) => { + cursor.style.left = e.clientX + 'px'; + cursor.style.top = e.clientY + 'px'; + try { + sessionStorage.setItem('__hudCursorX', String(e.clientX)); + sessionStorage.setItem('__hudCursorY', String(e.clientY)); + } catch (err) {} + }, true); + // 任意座標でクリックリップルを発火させる外部 API + window.__hudFlash = function(x, y) { + cursor.style.left = x + 'px'; + cursor.style.top = y + 'px'; + cursor.style.opacity = '1'; + cursor.style.visibility = 'visible'; + try { sessionStorage.setItem('__hudCursorVisible', '1'); } catch (e) {} + spawnRipple(x, y); + cursor.style.background = 'rgba(0,255,200,0.95)'; + cursor.style.transform = 'translate(-50%,-50%) scale(1.6)'; + cursor.style.boxShadow = '0 0 22px rgba(255,224,0,0.95)'; + cursor.style.borderColor = '#ffe000'; + setTimeout(() => { + cursor.style.background = 'rgba(255,80,80,0.55)'; + cursor.style.transform = 'translate(-50%,-50%) scale(1)'; + cursor.style.boxShadow = '0 0 10px rgba(255,0,0,0.7)'; + cursor.style.borderColor = '#f33'; + }, 400); + }; + + // 擬似クリック対象が見つからないステップで非表示にする + window.__hudHideCursor = function() { + cursor.style.opacity = '0'; + cursor.style.visibility = 'hidden'; + try { sessionStorage.setItem('__hudCursorVisible', '0'); } catch (e) {} + }; + + function spawnRipple(x, y) { + // 3 重リングのリップルでクリック箇所を強調 + for (let i = 0; i < 3; i++) { + const ring = document.createElement('div'); + ring.style.cssText = + 'position:fixed;pointer-events:none;z-index:2147483645;' + + 'left:' + x + 'px;top:' + y + 'px;' + + 'width:0;height:0;border:4px solid #ffe000;' + + 'border-radius:50%;transform:translate(-50%,-50%);' + + 'box-shadow:0 0 12px rgba(255,224,0,0.8);'; + document.documentElement.appendChild(ring); + ring.animate( + [ + { width: '24px', height: '24px', opacity: 1, borderWidth: '5px', + borderColor: '#ffe000' }, + { width: '110px', height: '110px', opacity: 0, borderWidth: '2px', + borderColor: '#ff6600' } + ], + { duration: 800, delay: i * 130, easing: 'ease-out', + fill: 'forwards' } + ); + setTimeout(() => { try { ring.remove(); } catch(e) {} }, + 850 + i * 130); + } + const cross = document.createElement('div'); + cross.style.cssText = + 'position:fixed;pointer-events:none;z-index:2147483647;' + + 'left:' + x + 'px;top:' + y + 'px;' + + 'width:48px;height:48px;transform:translate(-50%,-50%);' + + 'background:radial-gradient(circle,rgba(255,224,0,0.55) 0%,rgba(255,224,0,0) 70%);'; + document.documentElement.appendChild(cross); + cross.animate( + [{ opacity: 1 }, { opacity: 0 }], + { duration: 600, easing: 'ease-out', fill: 'forwards' } + ); + setTimeout(() => { try { cross.remove(); } catch(e) {} }, 650); + } + + document.addEventListener('mousedown', (e) => { + cursor.style.background = 'rgba(0,255,200,0.95)'; + cursor.style.transform = 'translate(-50%,-50%) scale(1.6)'; + cursor.style.boxShadow = '0 0 22px rgba(255,224,0,0.95)'; + cursor.style.borderColor = '#ffe000'; + spawnRipple(e.clientX, e.clientY); + }, true); + document.addEventListener('mouseup', () => { + cursor.style.background = 'rgba(255,80,80,0.55)'; + cursor.style.transform = 'translate(-50%,-50%) scale(1)'; + cursor.style.boxShadow = '0 0 10px rgba(255,0,0,0.7)'; + cursor.style.borderColor = '#f33'; + }, true); + + const HUD_HEIGHT = 60; // 字幕 2 行分の固定高 + const cap = document.createElement('div'); + cap.id = '__hud_caption'; + cap.style.cssText = + 'position:fixed;top:0;left:0;right:0;' + + 'height:' + HUD_HEIGHT + 'px;' + + 'background:rgba(0,0,0,0.88);color:#fff;' + + 'font:14px/1.5 "Noto Sans CJK JP","Noto Sans JP",' + + '"Hiragino Sans","Yu Gothic","Meiryo",IPAGothic,sans-serif;' + + 'font-feature-settings:"palt";' + + 'padding:8px 16px;pointer-events:none;z-index:2147483646;' + + 'box-sizing:border-box;border-bottom:3px solid #fa0;' + + 'white-space:pre-wrap;word-break:break-all;'; + document.documentElement.appendChild(cap); + + // 本文が字幕で隠れないよう body を下にずらす (border-bottom 3px を含めて +3) + try { + const padTop = (HUD_HEIGHT + 3) + 'px'; + document.body.style.paddingTop = padTop; + document.documentElement.style.scrollPaddingTop = padTop; + } catch (e) {} + let stored = ''; + try { stored = sessionStorage.getItem('__hudCaption') || ''; } catch (e) {} + cap.textContent = window.__pendingCaption || stored || ''; + return true; + } + if (!setup()) { + document.addEventListener('DOMContentLoaded', setup); + } +})(); +""" + + +_SET_CAPTION_JS = """(text) => { + window.__pendingCaption = text; + try { sessionStorage.setItem('__hudCaption', text); } catch (e) {} + const cap = document.getElementById('__hud_caption'); + if (cap) cap.textContent = text; +}""" + + +def set_caption(page: Page, *, previous: str = "", next_action: str = "") -> None: + """現ページの HUD 字幕を「直前 / 次へ」の 2 行で更新する。 + + sessionStorage にも書き込むので、次の navigation 後に init script が拾い直す。 + """ + parts = [ + f"直前 │ {previous}" if previous else None, + f"次へ │ {next_action}" if next_action else None, + ] + text = "\n".join(p for p in parts if p) + try: + page.evaluate(_SET_CAPTION_JS, text) + except Exception: + pass + + +def flash_click(page: Page, x: int, y: int, *, settle_ms: int = 250) -> None: + """指定座標 (viewport 内) にカーソルを移動し、HUD リップルを発火させる。 + + DOM 要素はクリックしない (`page.mouse.click` は呼ばない)。HUD オーバーレイの + `__hudFlash(x, y)` を JS evaluate で呼び出すだけ。 + """ + try: + page.mouse.move(x, y, steps=10) + except Exception: + pass + try: + page.evaluate( + "(c) => { if (window.__hudFlash) window.__hudFlash(c[0], c[1]); }", + [x, y], + ) + except Exception: + return + if settle_ms > 0: + try: + page.wait_for_timeout(settle_ms) + except Exception: + pass + + +def hide_cursor(page: Page) -> None: + """擬似クリック対象が見つからないとき、HUD カーソルを非表示にする。""" + try: + page.evaluate("() => { if (window.__hudHideCursor) window.__hudHideCursor(); }") + except Exception: + pass diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py new file mode 100644 index 0000000..81d7dff --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py @@ -0,0 +1,491 @@ +"""playwright-scenario-test の pytest plugin。 + +CLI options: +- ``--pwk-config ``: scenario.config.yaml を指定 +- ``--pwk-out-dir ``: 成果物 (HAR / trace / 動画 / report) の出力先 +- ``--pwk-no-evidence``: evidence 収集を OFF +- ``--pwk-overlay``: overlay (赤丸カーソル + 字幕、旧名 HUD) を ON +- ``--pwk-drive-folder ``: Drive 連携 + +markers: +- ``page_role(*roles)``: accessibility / web vitals autouse の判定材料 +- ``role(role_id)``: login する role を明示 (`pwk_role_` fixture と並用可) +- ``phase(num)``: report.md のフェーズ集計用 +- ``priority(level)``: report.md のソート用 +""" + +from __future__ import annotations + +import datetime as _dt +from pathlib import Path +from typing import Any + +import pytest + +from playwright_kit.pytest_report import PwkTestEntry, write_report + +# 配下の fixture モジュールを pytest_plugins として読み込む +# (こうすると entry-point 経由で plugin がロードされた瞬間に fixture が +# 全 test に対して discover される)。 +pytest_plugins = [ + "playwright_kit.fixtures.auth", + "playwright_kit.fixtures.evidence", + "playwright_kit.fixtures.accessibility", + "playwright_kit.fixtures.web_vitals", + "playwright_kit.fixtures.body_check", +] + + +# --------------------------------------------------------------------------- +# CLI options +# --------------------------------------------------------------------------- + + +def pytest_addoption(parser: pytest.Parser) -> None: + group = parser.getgroup("pwk", "playwright-scenario-test (playwright_kit)") + group.addoption( + "--pwk-config", + action="store", + default=None, + help="scenario.config.yaml へのパス (env PWK_CONFIG, または ./scenario.config.yaml も可)", + ) + group.addoption( + "--pwk-out-dir", + action="store", + default=None, + help="成果物出力先ディレクトリ (default: ./reports//)", + ) + group.addoption( + "--pwk-no-evidence", + action="store_true", + default=False, + help="HAR / trace / video の収集を OFF にする", + ) + group.addoption( + "--pwk-har-mode", + action="store", + default=None, + choices=["minimal", "full", "none"], + help=( + "HAR 録画モード (Issue #62)。" + "minimal=メタデータのみ (default; Basic 認証 + redirect race を回避), " + "full=Playwright 既定の full HAR, " + "none=HAR を出力しない。" + "config の playwright.har_mode より優先。" + ), + ) + group.addoption( + "--pwk-no-video", + action="store_true", + default=False, + help="動画収集を明示的に OFF にする (デフォルトは全テストで動画 ON)", + ) + group.addoption( + "--pwk-overlay", + action="store_true", + default=False, + help="overlay (赤丸カーソル + 字幕、旧名 HUD) を全 page に inject する", + ) + group.addoption( + "--pwk-drive-folder", + action="store", + default=None, + help=( + "Drive アップロード先フォルダ ID (terminal_summary 後に upload 実行)。" + "trace.zip / *.har / 動画には機微情報 (URL / Cookie / localStorage / 操作履歴) " + "が含まれる可能性があります。private folder + 信頼できる共有相手のみに限定してください。" + " (Codex Minor 8)" + ), + ) + + +# --------------------------------------------------------------------------- +# Markers / Config +# --------------------------------------------------------------------------- + + +_PWK_MARKERS: list[tuple[str, str]] = [ + ("page_role", "page_role(*roles): accessibility / web vitals autouse の判定 (例: form, list, dashboard)"), + ("role", "role(role_id): test がどの login role を要求するか (`pwk_role_` 経由でも可)"), + ("phase", "phase(num): report.md のフェーズ集計用 (1〜N の整数)"), + ("priority", "priority(level): report.md のソート用 (high/mid/low など任意文字列)"), + ("no_body_check", "no_body_check: body_check autouse をこの test では skip する"), +] + + +def pytest_configure(config: pytest.Config) -> None: + """marker 登録 + config の早期 load を試みる。 + + config 読み込みは ``pwk_config`` fixture でも遅延ロードされるが、 + ``pwk_role_`` fixture を *動的登録* するためには + ``pytest_configure`` で 1 度 Config をロードしておく必要がある。 + failure は警告にとどめ、利用者が playwright_kit 機能を使わない場合に test 全体を + 潰さないようにする。 + """ + for name, doc in _PWK_MARKERS: + config.addinivalue_line("markers", f"{name}: {doc}") + + # 動的 fixture 登録のため、可能なら Config を early load する。 + cfg = _try_load_config_silently(config) + if cfg is not None: + from playwright_kit.fixtures import auth as auth_module + + registered = auth_module.register_role_fixtures(auth_module, cfg) + if registered: + # plugin 自体にも公開しておく (ユーザが import 元を調整しなくて良いように)。 + import playwright_kit.pytest_plugin as plugin_self + + for name in registered: + fn = getattr(auth_module, name, None) + if fn is not None: + setattr(plugin_self, name, fn) + # session 中で再利用するためにキャッシュする。 + config._pwk_config = cfg # type: ignore[attr-defined] + + # 動画デフォルト ON (大原則: エビデンス動画を常に取得) + # ユーザーが --video を CLI で明示指定した場合はそちらを優先する。 + # --pwk-no-video 指定時は video='off' に設定する。 + # --pwk-no-evidence 指定時も video='off' に設定する (全エビデンス OFF)。 + # pytest-playwright の --video デフォルト値は 'off' であるため、 + # getoption() の返り値では明示指定の有無を判別できない。 + # invocation_params.args を走査して明示指定を検出する。 + try: + cli_args = list(config.invocation_params.args) + video_explicitly_set = any( + a == "--video" or a.startswith("--video=") for a in cli_args + ) + no_video = config.getoption("pwk_no_video", default=False) + no_evidence = config.getoption("pwk_no_evidence", default=False) + if not video_explicitly_set: + if no_video or no_evidence: + config.option.video = "off" + else: + config.option.video = "on" + except (ValueError, AttributeError): + pass + + +# --------------------------------------------------------------------------- +# Reports / hooks +# --------------------------------------------------------------------------- + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """test の各 phase 終了時に ``pwk_evidence`` の状態をレポートに紐付ける。 + + FAIL 時には evidence の trace/HAR path を log に追記し、 + 成果物 path / marker を rep.user_properties に保存して + ``pytest_terminal_summary`` で report.md に集約する。 + + HAR lifecycle 修正 (Codex Major-1 完遂 / 3回目): + Playwright は HAR を ``context.close()`` 時に flush する。 + ``pwk_evidence`` の finalizer は ``context`` の finalizer より先に動くため、 + call phase の時点では ``har_relpath`` / ``trace_relpath`` がまだ未確定の場合がある。 + + teardown phase の makereport は pytest-playwright の ``context`` finalizer が + teardown 中に完了した後に走るため、ここで再度 ``confirm_har()`` を呼んで + HAR の存在を確認し直し、確定した path を teardown report の user_properties に + 積む。``_collect_entries()`` が teardown report の pwk_har/pwk_trace を call + entry に merge することで、report.md に artifact path が反映される。 + """ + outcome = yield + rep = outcome.get_result() + + ev = getattr(item, "_pwk_evidence", None) + + # teardown phase: context.close() 後に HAR が flush されるため confirm_har() 再呼び出し。 + # 確定した har_relpath / trace_relpath を teardown report の user_properties に積む。 + # body_check_violations もこの phase で確定する (autouse fixture finalizer が + # pytest.fail 直前まで populate してから走る)。 + # _collect_entries() がこれらの値を call entry に merge する。 + if rep.when == "teardown" and ev is not None: + ev.confirm_har() + if ev.har_relpath: + rep.user_properties.append(("pwk_har", str(ev.case_dir / ev.har_relpath))) + if ev.trace_relpath: + rep.user_properties.append( + ("pwk_trace", str(ev.case_dir / ev.trace_relpath)) + ) + rep.user_properties.append( + ("pwk_body_check_violations", len(ev.body_check_violations)) + ) + if ev.body_check_violations: + rep.user_properties.append( + ("pwk_body_check_detail", list(ev.body_check_violations)) + ) + return + + if rep.when != "call": + return + + # pwk_evidence fixture が attach した状態を直接参照 + if ev is not None: + if ev.har_relpath: + rep.user_properties.append(("pwk_har", str(ev.case_dir / ev.har_relpath))) + if ev.trace_relpath: + rep.user_properties.append( + ("pwk_trace", str(ev.case_dir / ev.trace_relpath)) + ) + rep.user_properties.append(("pwk_console_errors", len(ev.console_errors))) + rep.user_properties.append(("pwk_page_errors", len(ev.page_errors))) + + # markers を user_properties に転写 + page_roles: list[str] = [] + for marker in item.iter_markers(name="page_role"): + for arg in marker.args: + if isinstance(arg, str): + page_roles.append(arg) + elif isinstance(arg, (list, tuple)): + page_roles.extend(str(a) for a in arg) + if page_roles: + rep.user_properties.append(("pwk_page_role", page_roles)) + + role_marker = item.get_closest_marker("role") + if role_marker is not None and role_marker.args: + rep.user_properties.append(("pwk_role", str(role_marker.args[0]))) + + phase_marker = item.get_closest_marker("phase") + if phase_marker is not None and phase_marker.args: + try: + rep.user_properties.append(("pwk_phase", int(phase_marker.args[0]))) + except (TypeError, ValueError): + pass + + priority_marker = item.get_closest_marker("priority") + if priority_marker is not None and priority_marker.args: + rep.user_properties.append(("pwk_priority", str(priority_marker.args[0]))) + + +# --------------------------------------------------------------------------- +# Terminal summary / session finish +# --------------------------------------------------------------------------- + + +def _collect_entries(terminalreporter) -> list[PwkTestEntry]: + """terminalreporter から ``PwkTestEntry`` のリストを構築する。 + + xfailed / xpassed も集約する (Codex Major 3)。 + pytest 内部では xfailed の rep は stats["xfailed"] に直接入るため、 + "xfailed" / "xpassed" キーを明示的に走査する。 + + artifact 伝搬 (Codex Major-1 / 3回目): + HAR は context.close() 時に flush されるため、call phase 時点では + har_relpath / trace_relpath が未確定の場合がある。 + teardown phase の makereport で確定した pwk_har / pwk_trace を + call entry に merge することで、report.md に artifact path を反映する。 + """ + # Step 1: call/setup phase の entry を nodeid でインデックス化 + call_entries: dict[str, PwkTestEntry] = {} + for outcome_key in ("passed", "failed", "skipped", "error", "xfailed", "xpassed"): + for rep in terminalreporter.stats.get(outcome_key, []): + if getattr(rep, "when", "call") not in ("call", "setup"): + continue + props = dict(rep.user_properties or []) + nodeid = getattr(rep, "nodeid", "?") + entry = PwkTestEntry( + nodeid=nodeid, + name=getattr(rep, "head_line", nodeid), + outcome=outcome_key, + duration_s=float(getattr(rep, "duration", 0.0) or 0.0), + page_role=list(props.get("pwk_page_role") or []), + role=props.get("pwk_role"), + phase=int(props.get("pwk_phase") or 0), + priority=props.get("pwk_priority"), + har_path=props.get("pwk_har"), + trace_path=props.get("pwk_trace"), + console_errors=int(props.get("pwk_console_errors") or 0), + page_errors=int(props.get("pwk_page_errors") or 0), + # Amazon Q Critical-3: skipped 時の longrepr は tuple 形式のため + # failed / error のときのみ str() 化する。他 outcome は None のまま。 + error_message=( + str(rep.longrepr) + if outcome_key in ("failed", "error") and rep.longrepr + else None + ), + ) + call_entries[nodeid] = entry + + # Step 2: teardown report の pwk_har / pwk_trace / body_check を call entry に merge する。 + # teardown 時点で context.close() 後の確定値や body_check の violation 集計が + # 積まれているため、call phase で未確定だった値をここで埋める。 + # pytest は setup/teardown の rep を stats[""] (空文字キー) に格納するため、 + # "" キーも含めて全キーを走査する。 + for outcome_key in terminalreporter.stats: + for rep in terminalreporter.stats[outcome_key]: + if getattr(rep, "when", None) != "teardown": + continue + nodeid = getattr(rep, "nodeid", "?") + if nodeid not in call_entries: + continue + entry = call_entries[nodeid] + props = dict(rep.user_properties or []) + if not entry.har_path and props.get("pwk_har"): + entry.har_path = props["pwk_har"] + if not entry.trace_path and props.get("pwk_trace"): + entry.trace_path = props["pwk_trace"] + if "pwk_body_check_violations" in props: + entry.body_check_violations = int( + props.get("pwk_body_check_violations") or 0 + ) + detail = props.get("pwk_body_check_detail") + if detail: + entry.body_check_detail = list(detail) + # body_check が teardown で pytest.fail を起こした場合、call phase + # は passed / xfailed / xpassed / skipped のまま teardown report + # のみ failed/error になる。call phase の本物の failure は上書き + # しないが、それ以外の outcome は teardown 失敗を反映させる + # (xfail テストでも teardown の body_check fail は実バグ扱い)。 + teardown_outcome = getattr(rep, "outcome", None) + if ( + teardown_outcome in ("failed", "error") + and entry.outcome not in ("failed", "error") + ): + entry.outcome = ( + "error" if teardown_outcome == "error" else "failed" + ) + if rep.longrepr and not entry.error_message: + entry.error_message = str(rep.longrepr) + + return list(call_entries.values()) + + +def pytest_terminal_summary(terminalreporter, exitstatus, config): + """``reports//report.md`` を生成する。 + + ``--pwk-out-dir`` 指定があればそこに、なければ ``pwk_out_dir`` fixture と + 同一の ``pytestconfig._pwk_out_dir`` キャッシュを参照する。 + キャッシュが無い場合 (pwk_out_dir fixture が一度も呼ばれていない) は + ``_resolve_out_dir`` 経由でセットする。これにより evidence と report.md の + 出力先が秒またぎでズレる問題を防ぐ (新規 Major 対応)。 + """ + # session 中で 1 件も test を回していない (collect-only など) は先に entries で判断。 + # xfailed / xpassed のみの session でも report を生成するため、 + # early return は _collect_entries() の結果で判断する (新規 Minor 対応)。 + entries = _collect_entries(terminalreporter) + if not entries: + return + + cached_cfg = getattr(config, "_pwk_config", None) + base_url = cached_cfg.base_url if cached_cfg is not None else None + title = ( + cached_cfg.report.title + if cached_cfg is not None + else "シナリオ E2E テスト 実施報告書" + ) + + # evidence.py の _resolve_out_dir と同一キャッシュ (_pwk_out_dir) を参照する。 + # これにより両者が独立に datetime.now() を呼んで別ディレクトリを作る問題を解消。 + from playwright_kit.fixtures.evidence import _resolve_out_dir + + out_dir = _resolve_out_dir(config) + out_dir.mkdir(parents=True, exist_ok=True) + + # Amazon Q Critical-4: xdist 並列実行時の session 開始時刻計算が不正確な問題を修正。 + # terminalreporter._sessionstarttime (pytest 内部 float) を優先利用し、 + # 無ければ従来の逐次実行前提の計算にフォールバックする。 + session_start_ts = getattr(terminalreporter, "_sessionstarttime", None) + if session_start_ts is not None: + started = _dt.datetime.fromtimestamp(session_start_ts) + else: + started = _dt.datetime.now() - _dt.timedelta( + seconds=sum(e.duration_s for e in entries) + ) + finished = _dt.datetime.now() + path = write_report( + entries, + out_dir=out_dir, + started_at=started, + finished_at=finished, + title=title, + base_url=base_url, + ) + terminalreporter.write_sep("-", "pwk report") + terminalreporter.write_line(f"report.md generated: {path}") + + # session 後の Drive アップロードに使うため pickle 不要な情報を保存 + config._pwk_report_path = path # type: ignore[attr-defined] + config._pwk_out_dir = out_dir # type: ignore[attr-defined] + + +def pytest_sessionfinish(session, exitstatus): + """``--pwk-drive-folder`` 指定時、生成済 report.md と evidence を Drive アップ。 + + ``upload_evidence.upload`` を直接呼ぶ。失敗時は警告のみで test 結果には影響しない。 + """ + folder_id: str | None = session.config.getoption( + "pwk_drive_folder", default=None + ) + if not folder_id: + return + + report_path: Path | None = getattr(session.config, "_pwk_report_path", None) + out_dir: Path | None = getattr(session.config, "_pwk_out_dir", None) + if report_path is None or out_dir is None: + return + + try: + # Amazon Q Critical-5: sys.path への動的 inject を廃止し、 + # playwright_kit.uploaders パッケージ経由で安全に import する。 + # scripts/upload_evidence.py は CLI スタンドアロン用途として残す。 + from playwright_kit.uploaders import upload, detect_kind + + # report.md は kind=any でアップ + if report_path.exists(): + upload( + report_path, kind="any", parent_folder_id=folder_id, public=False + ) + + # trace.zip / *.har / *.mp4 / body_check.jsonl を 1 階層下から拾い上げる + for sub in out_dir.iterdir(): + if not sub.is_dir(): + continue + for f in sub.iterdir(): + suffix = f.suffix + if suffix not in (".zip", ".har", ".mp4", ".webm", ".jsonl"): + continue + # detect_kind は body_check.jsonl 等の任意ファイルを未知の kind + # と扱うため、jsonl は ``any`` に固定する。 + kind = "any" if suffix == ".jsonl" else detect_kind(f) + upload( + f, kind=kind, parent_folder_id=folder_id, public=False + ) + except Exception as exc: # pragma: no cover - depends on Drive auth + import warnings + + warnings.warn( + f"[pwk] Drive upload 失敗 (session continues): {exc}", + stacklevel=1, + ) + + +def _try_load_config_silently(config: pytest.Config) -> Any | None: + """``--pwk-config`` 等から Config を試行ロードする。失敗時は None。""" + import os + from pathlib import Path + + raw_path: str | None = config.getoption("pwk_config", default=None) + if not raw_path: + env = os.environ.get("PWK_CONFIG") + if env: + raw_path = env + if not raw_path: + candidate = Path.cwd() / "scenario.config.yaml" + if candidate.exists(): + raw_path = str(candidate) + if not raw_path: + return None + + try: + from playwright_kit.config import Config + + return Config.load(Path(raw_path).resolve()) + except Exception as exc: # pragma: no cover - depends on user config + import warnings + + warnings.warn( + f"[pwk] config load 失敗 ({raw_path}): {exc}. " + "pwk_role_ fixture は動的登録されません。", + stacklevel=2, + ) + return None diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_report.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_report.py new file mode 100644 index 0000000..2d67a89 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/pytest_report.py @@ -0,0 +1,212 @@ +"""pytest hook で集めた test result から Markdown レポートを生成する。 + +``pytest_terminal_summary`` から呼ばれ、``reports//report.md`` を生成する。 +``--pwk-drive-folder`` 指定時は Drive アップロードと URL 差し込みも担当。 +""" + +from __future__ import annotations + +import datetime as _dt +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable + + +@dataclass +class PwkTestEntry: + """1 test 関数分のレポート用エントリ。 + + pytest の ``TestReport`` から要点だけを抽出して保持する + (``user_properties`` 経由で ``pwk_evidence`` の状態が紐付く)。 + """ + + nodeid: str + name: str + outcome: str # passed / failed / skipped / xfailed / xpassed / error + duration_s: float + page_role: list[str] = field(default_factory=list) + role: str | None = None + phase: int = 0 + priority: str | None = None + har_path: str | None = None + trace_path: str | None = None + console_errors: int = 0 + page_errors: int = 0 + error_message: str | None = None + # body_check (PHP / SSR エラー検出, v0.4.0) + body_check_violations: int = 0 + body_check_detail: list[dict] = field(default_factory=list) + + @property + def ok(self) -> bool: + return self.outcome in ("passed", "xfailed") + + @property + def status_label(self) -> str: + return { + "passed": "OK", + "failed": "FAIL", + "skipped": "SKIP", + "xfailed": "XFAIL", + "xpassed": "XPASS", + "error": "ERROR", + }.get(self.outcome, self.outcome.upper()) + + +def render_markdown( + entries: Iterable[PwkTestEntry], + *, + started_at: _dt.datetime, + finished_at: _dt.datetime, + title: str = "シナリオ E2E テスト 実施報告書", + base_url: str | None = None, +) -> str: + """test entries から Markdown 文字列を生成する。""" + entries_list = list(entries) + total = len(entries_list) + passed = sum(1 for e in entries_list if e.outcome == "passed") + failed = sum(1 for e in entries_list if e.outcome == "failed") + skipped = sum(1 for e in entries_list if e.outcome == "skipped") + errors = sum(1 for e in entries_list if e.outcome == "error") + xfailed = sum(1 for e in entries_list if e.outcome == "xfailed") + xpassed = sum(1 for e in entries_list if e.outcome == "xpassed") + duration = (finished_at - started_at).total_seconds() + # xfailed は期待通りの失敗なので OK 扱い (PwkTestEntry.ok と同じ方針) + # xpassed は意図せず pass したため注意喚起 (全PASS とはしない) + all_pass = total > 0 and (passed + xfailed) == total and xpassed == 0 + + lines: list[str] = [ + f"# {title}", + "", + f"- 実行開始: {started_at:%Y-%m-%d %H:%M:%S}", + f"- 実行終了: {finished_at:%Y-%m-%d %H:%M:%S}", + f"- 所要時間: {duration:.1f} 秒", + ] + if base_url: + lines.append(f"- 対象URL : {base_url}") + + # 集計サマリ行を構築 + extra_parts: list[str] = [] + if failed: + extra_parts.append(f"FAIL {failed}") + if skipped: + extra_parts.append(f"SKIP {skipped}") + if errors: + extra_parts.append(f"ERROR {errors}") + if xfailed: + extra_parts.append(f"XFAIL {xfailed}") + if xpassed: + extra_parts.append(f"XPASS {xpassed}") + # 全PASS でも xfailed / xpassed があれば内訳を明示する + if all_pass: + if extra_parts: + result_suffix = " (全PASS) / " + " / ".join(extra_parts) + else: + result_suffix = " (全PASS)" + else: + result_suffix = " / " + " / ".join(extra_parts) if extra_parts else "" + + lines.extend([ + f"- **結果: {passed}/{total} test PASS{result_suffix}**", + "", + "## サマリ", + "", + "| nodeid | role | page_role | status | duration | console.error | pageerror | body_check |", + "|---|---|---|---|---|---|---|---|", + ]) + + # phase / priority / nodeid の順でソート + sorted_entries = sorted( + entries_list, + key=lambda e: (e.phase, e.priority or "", e.nodeid), + ) + for e in sorted_entries: + page_role = ",".join(e.page_role) if e.page_role else "-" + lines.append( + f"| `{e.nodeid}` | {e.role or '-'} | {page_role} | " + f"{e.status_label} | {e.duration_s:.2f}s | " + f"{e.console_errors} | {e.page_errors} | " + f"{e.body_check_violations} |" + ) + + failures = [e for e in sorted_entries if e.outcome in ("failed", "error")] + if failures: + lines.extend(["", "## FAIL / ERROR の詳細", ""]) + for e in failures: + lines.append(f"### `{e.nodeid}` — {e.status_label}") + lines.append("") + if e.error_message: + lines.append("```") + lines.append(e.error_message[:2000]) + lines.append("```") + if e.trace_path: + lines.append(f"- trace: `{e.trace_path}`") + if e.har_path: + lines.append(f"- HAR: `{e.har_path}`") + lines.append("") + + body_check_hits = [e for e in sorted_entries if e.body_check_violations > 0] + if body_check_hits: + lines.extend(["", "## body_check 違反の詳細", ""]) + for e in body_check_hits: + lines.append( + f"### `{e.nodeid}` — body_check {e.body_check_violations} 件 " + f"({e.status_label})" + ) + lines.append("") + lines.append("| # | URL | category | pattern | snippet |") + lines.append("|---:|---|---|---|---|") + for i, v in enumerate(e.body_check_detail[:20], start=1): + url = _escape_table_cell(str(v.get("url", "?"))) + cat = _escape_table_cell(str(v.get("category", "?"))) + pat = _escape_table_cell(str(v.get("pattern", "?"))) + snippet = _escape_table_cell(str(v.get("snippet", ""))) + if len(snippet) > 200: + snippet = snippet[:200] + "..." + lines.append(f"| {i} | `{url}` | {cat} | `{pat}` | {snippet} |") + if len(e.body_check_detail) > 20: + lines.append( + f"\n_(表示は先頭 20 件のみ。詳細は ``body_check.jsonl`` を参照)_" + ) + lines.append("") + + return "\n".join(lines) + "\n" + + +def _escape_table_cell(text: str) -> str: + """Markdown 表のセル値を 1 行に潰してエスケープする。 + + 改行・タブが残ると行が分割されて表が崩れるので空白に置換する。 + ``|`` と backtick もエスケープして表構造とコード span を破壊しないようにする。 + """ + return ( + text.replace("\r\n", " ") + .replace("\r", " ") + .replace("\n", " ") + .replace("\t", " ") + .replace("|", "\\|") + .replace("`", "\\`") + ) + + +def write_report( + entries: Iterable[PwkTestEntry], + *, + out_dir: Path, + started_at: _dt.datetime, + finished_at: _dt.datetime, + title: str = "シナリオ E2E テスト 実施報告書", + base_url: str | None = None, +) -> Path: + """``out_dir/report.md`` を書き出してそのパスを返す。""" + out_dir.mkdir(parents=True, exist_ok=True) + md = render_markdown( + entries, + started_at=started_at, + finished_at=finished_at, + title=title, + base_url=base_url, + ) + path = out_dir / "report.md" + path.write_text(md, encoding="utf-8") + return path diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py new file mode 100644 index 0000000..283d2f0 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py @@ -0,0 +1,151 @@ +"""Drive アップロード機能を playwright_kit パッケージから直接 import するためのラッパー。 + +scripts/upload_evidence.py の CLI スタンドアロン用途 (利用者が +``python upload_evidence.py ...`` で叩く) を壊さずに、pytest_sessionfinish から +安全に import できるようにする (Amazon Q Critical-5: sys.path 廃止)。 + +使い方 (pytest_plugin.py から): + from playwright_kit.uploaders import upload, detect_kind + +この module は google-auth スキルが存在しない環境でも import できる。 +実際のアップロード時のみ google-auth を必要とする (遅延 import)。 +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from urllib.parse import quote + + +_HERE = Path(__file__).resolve() +_CANDIDATES: tuple[Path, ...] = tuple( + Path(p).expanduser() + for p in ( + os.environ.get("GOOGLE_AUTH_SCRIPTS"), + "~/.claude/skills/google-auth/scripts", + "~/.codex/skills/google-auth/scripts", + str(_HERE.parent.parent.parent / "scripts"), + str(_HERE.parent.parent.parent.parent / "google-auth" / "scripts"), + ) + if p +) + + +def _ensure_google_auth_on_path() -> None: + for p in _CANDIDATES: + if p.is_dir(): + path = str(p) + if path not in sys.path: + sys.path.insert(0, path) + return + searched = "\n - ".join(str(p) for p in _CANDIDATES) + raise RuntimeError( + "Google Drive 連携には optional skill `google-auth` が必要です。\n" + "Codex 公開セットには同梱していないため、Drive 系コマンドを使う前に " + "`GOOGLE_AUTH_SCRIPTS` を google-auth/scripts へ設定してください。\n" + "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts\n" + "検索した候補:\n - " + f"{searched}" + ) + + +# 拡張子 → kind の自動判定 +_EXT_KIND: dict[str, str] = { + ".zip": "trace", + ".har": "har", + ".mp4": "video", + ".webm": "video", +} + +_MIME_BY_KIND: dict[str, str] = { + "trace": "application/zip", + "har": "application/json", + "video": "video/mp4", + "any": "application/octet-stream", +} + +_MIME_BY_EXT: dict[str, str] = { + ".webm": "video/webm", + ".mp4": "video/mp4", + ".har": "application/json", + ".zip": "application/zip", +} + +ALLOWED_KINDS: frozenset[str] = frozenset(_MIME_BY_KIND) + + +def detect_kind(path: Path) -> str: + """拡張子から evidence kind を自動判定する。""" + return _EXT_KIND.get(path.suffix.lower(), "any") + + +def detect_mime(path: Path, kind: str) -> str: + """拡張子優先で MIME を決定し、未知拡張子は kind の既定値にフォールバック。""" + return _MIME_BY_EXT.get( + path.suffix.lower(), + _MIME_BY_KIND.get(kind, "application/octet-stream"), + ) + + +def upload( + file_path: Path, + *, + kind: str = "any", + parent_folder_id: str | None = None, + public: bool = False, +) -> dict: + """ファイルを Drive にアップして metadata + 補助 URL を返す。 + + ⚠️ trace.zip / HAR / video には DOM snapshot や入力痕跡・HTTP request body が含まれる。 + 既定では非公開アップロード。``public=True`` のときだけ anyone/read を付与する。 + ``parent_folder_id`` には **private folder** の ID を指定し、 + 共有相手を信頼できるメンバーに限定してください (Amazon Q Critical-5 / Codex Minor 8)。 + """ + if kind not in ALLOWED_KINDS: + raise ValueError( + f"未対応の kind: {kind!r} (allowed: {sorted(ALLOWED_KINDS)})" + ) + + _ensure_google_auth_on_path() + from google_auth import get_credentials # type: ignore # noqa: E402 + from googleapiclient.discovery import build # noqa: E402 + from googleapiclient.http import MediaFileUpload # noqa: E402 + + creds = get_credentials(["drive.file"]) + service = build("drive", "v3", credentials=creds) + + metadata: dict = {"name": file_path.name} + if parent_folder_id: + metadata["parents"] = [parent_folder_id] + media = MediaFileUpload( + str(file_path), mimetype=detect_mime(file_path, kind), + ) + f = service.files().create( + body=metadata, media_body=media, fields="id,webViewLink", + ).execute() + file_id = f["id"] + + if public: + service.permissions().create( + fileId=file_id, body={"type": "anyone", "role": "reader"}, + ).execute() + + direct_url: str | None = None + viewer_url: str | None = None + if public: + direct_url = f"https://drive.google.com/uc?export=download&id={file_id}" + if kind == "trace": + viewer_url = ( + f"https://trace.playwright.dev/?trace={quote(direct_url, safe='')}" + ) + + return { + "file_id": file_id, + "drive_view": f.get("webViewLink"), + "direct_download": direct_url, + "playwright_trace_viewer": viewer_url, + "is_public": public, + "kind": kind, + } diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/video.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/video.py new file mode 100644 index 0000000..e1a018e --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/video.py @@ -0,0 +1,71 @@ +"""Playwright 録画 webm → Google Drive 互換 mp4 への変換。 + +Drive のストリーミング再生で「処理中」になりにくい設定: + - H.264 High profile / Level 4.0 / yuv420p / bt709 + - 30 fps CFR (Constant Frame Rate) + - 60 frames keyframe interval (2 sec at 30fps) + - AAC LC stereo 48kHz 128kbps (無音でも音声トラックは必須) + - +faststart で moov atom を先頭配置 → プログレッシブ再生 + +依存: imageio-ffmpeg (静的 ffmpeg バイナリ同梱の Python パッケージ) +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def _ffmpeg_args(webm: Path, mp4: Path) -> list[str]: + """Drive 互換 mp4 への変換用 ffmpeg 引数を組み立てる。""" + return [ + "-y", + "-i", str(webm), + # 無音 AAC を映像と並行して生成 (一部プレイヤは音声トラック必須) + "-f", "lavfi", "-i", + "anullsrc=channel_layout=stereo:sample_rate=48000", + # --- 映像 --- + "-c:v", "libx264", + "-profile:v", "high", + "-level", "4.0", + "-pix_fmt", "yuv420p", + "-preset", "medium", + "-crf", "23", + "-r", "30", # 固定フレームレート + "-fps_mode", "cfr", # 旧 -vsync の後継 + "-g", "60", # キーフレーム間隔 2秒 + "-keyint_min", "30", + "-sc_threshold", "0", # シーン検出キーフレーム無効 + "-color_primaries", "bt709", + "-color_trc", "bt709", + "-colorspace", "bt709", + # --- 音声 (無音) --- + "-c:a", "aac", + "-b:a", "128k", + "-ar", "48000", + "-ac", "2", + "-shortest", # 映像終了で打ち切り + # --- ストリーミング向け --- + "-movflags", "+faststart", + str(mp4), + ] + + +def convert_webm_to_mp4(webm: Path, mp4: Path, *, timeout_sec: int = 300) -> Path | None: + """webm を Drive 互換 mp4 に変換。成功時は mp4 のパス、失敗時は None。""" + try: + import imageio_ffmpeg + ffmpeg = imageio_ffmpeg.get_ffmpeg_exe() + except Exception: + return None + + try: + subprocess.run( + [ffmpeg, *_ffmpeg_args(webm, mp4)], + check=True, capture_output=True, timeout=timeout_sec, + ) + except Exception: + return None + if not mp4.exists() or mp4.stat().st_size == 0: + return None + return mp4 diff --git a/plugins/ndf/skills/playwright-kit-ops/playwright_kit/web_vitals.py b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/web_vitals.py new file mode 100644 index 0000000..034247a --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/playwright_kit/web_vitals.py @@ -0,0 +1,142 @@ +"""runner 内蔵の Core Web Vitals (Web Vitals) 計測モジュール。 + +Core Web Vitals は Google が定義する「ユーザ体感パフォーマンス指標」群で、 +本モジュールは以下の 4 指標を計測する: + +- **LCP** (Largest Contentful Paint): 最大コンテンツ描画時間 (体感ロード速度) +- **CLS** (Cumulative Layout Shift): 累積レイアウトずれ量 (視覚的安定性) +- **TTFB** (Time To First Byte): 初バイト到達時間 (サーバ応答速さ) +- **longest_task** (Long Tasks API): 観測した最長タスクのミリ秒値 (応答性代理指標) + +`scripts/check_cwv.py` (CLI) はこのモジュールの薄いラッパで、runner からは +`measure_page(page)` を直接呼び出して `EvidenceCollectors.web_vitals_metrics` に +格納する。 + +page_role が `lp / list / dashboard` のとき runner が自動実行する +(config.web_vitals.auto_roles で上書き可能)。 + +注意: +- INP は実 user 入力ベースの指標であり Playwright で完全再現は不可能。 + `longest_task_ms` (50ms 超を 1 件以上検出) を「応答性低下の代理指標」として記録 + するが、INP の代わりにはならない。 +- 計測は page.evaluate で 5 秒間 PerformanceObserver を回す ため、testcase の + 最後 (全 step 実行後) に呼ぶこと。 +""" + +from __future__ import annotations + +from typing import Any + +from playwright.sync_api import Page + + +# web.dev 公式閾値 (75 percentile 基準) +THRESHOLDS: dict[str, dict[str, float]] = { + "lcp_ms": {"good": 2500, "poor": 4000}, + "cls": {"good": 0.1, "poor": 0.25}, + "ttfb_ms": {"good": 800, "poor": 1800}, + "longest_task_ms": {"good": 50, "poor": 200}, +} + +# page_role × Web Vitals 自動実行のデフォルト対象。インタラクション主体 (form / cart) は +# 過度な負荷になるため除外し、初回表示性能が UX に直結する role に限定する。 +DEFAULT_AUTO_ROLES: frozenset[str] = frozenset({"lp", "list", "dashboard", "search"}) + + +# Crit-1 (amazon-q-developer 指摘): JS 内の "5000" を `str.replace` する旧実装は +# JS 中に同じ数値が他出現すると壊れる fragile な置換だった。`page.evaluate` の +# arg 経由で `observeMs` を渡し、JS 関数引数として明示的に bind する。 +_PERF_JS = r""" +(observeMs) => new Promise((resolve) => { + const result = {lcp: null, cls: 0, longest_task: 0, ttfb: null}; + + try { + const lcpObs = new PerformanceObserver((list) => { + const entries = list.getEntries(); + const last = entries.at(-1); + if (last) result.lcp = last.startTime; + }); + lcpObs.observe({type: 'largest-contentful-paint', buffered: true}); + } catch (e) {} + + try { + const clsObs = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (!entry.hadRecentInput) { + result.cls += entry.value; + } + } + }); + clsObs.observe({type: 'layout-shift', buffered: true}); + } catch (e) {} + + try { + const ltObs = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.duration > result.longest_task) { + result.longest_task = entry.duration; + } + } + }); + ltObs.observe({type: 'longtask', buffered: true}); + } catch (e) {} + + try { + const nav = performance.getEntriesByType('navigation')[0]; + if (nav) result.ttfb = nav.responseStart - nav.requestStart; + } catch (e) {} + + setTimeout(() => resolve(result), observeMs); +}); +""" + + +def measure_page(page: Page, *, observe_ms: int = 5000) -> dict[str, float]: + """既にロード済みの Page で Core Web Vitals を `observe_ms` ミリ秒観察し、metrics dict を返す。 + + Returns: `{"lcp_ms": float, "cls": float, "ttfb_ms": float, "longest_task_ms": float}` + 計測失敗した metric は dict から除外される。 + """ + try: + raw: dict[str, Any] = page.evaluate(_PERF_JS, int(observe_ms)) + except Exception: + return {} + + out: dict[str, float] = {} + if raw.get("lcp") is not None: + out["lcp_ms"] = float(raw["lcp"]) + if raw.get("cls") is not None: + out["cls"] = float(raw["cls"]) + if raw.get("ttfb") is not None: + out["ttfb_ms"] = float(raw["ttfb"]) + if raw.get("longest_task") is not None: + out["longest_task_ms"] = float(raw["longest_task"]) + return out + + +def judge(metric: str, value: float | None) -> str: + """値を `good` / `needs-improvement` / `poor` / `unknown` に分類する。""" + if value is None: + return "unknown" + th = THRESHOLDS.get(metric) + if not th: + return "unknown" + if value <= th["good"]: + return "good" + if value <= th["poor"]: + return "needs-improvement" + return "poor" + + +def passed(metrics: dict[str, float]) -> bool: + """すべての metric が good または needs-improvement なら True (poor が 1 件でも あれば False)。""" + return all(judge(k, v) != "poor" for k, v in metrics.items()) + + +def should_auto_measure( + page_roles: list[str], + *, + auto_roles: frozenset[str] = DEFAULT_AUTO_ROLES, +) -> bool: + """testcase の page_role に基づき Web Vitals を自動計測すべきか判定する。""" + return any(r in auto_roles for r in page_roles) diff --git a/plugins/ndf/skills/playwright-kit-ops/pyproject.toml b/plugins/ndf/skills/playwright-kit-ops/pyproject.toml new file mode 100644 index 0000000..14e535b --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/pyproject.toml @@ -0,0 +1,50 @@ +[project] +name = "playwright_kit" +version = "0.5.0" +description = "pytest-playwright 上で動く Web シナリオ E2E テストの理論ベース実施フレームワーク (Claude Code skill)。v0.3.0: 自前 YAML DSL を全廃し、pytest plugin / fixture / marker / accessibility autouse / web vitals autouse / overlay / report.md / Google Drive 連携を提供。v0.4.0: PHP / SSR がフロント漏れさせる Fatal error / STRICT: 等を検出する body_check autouse を default 有効で復活 (PHP 系パターン内蔵)。v0.5.0: パッケージ名を playwright_kit に rename し、利用者プロジェクト直下で自己完結 (Skill 非依存) に動作させる構成へ移行。" +requires-python = ">=3.11" +dependencies = [ + "playwright>=1.50,<2.0", + "pyyaml>=6.0", + # webm → mp4 変換用。Playwright 録画は webm のみ生成するが、 + # Google Drive のプレビュアでは mp4 (H.264) の方が安定するため変換する。 + "imageio-ffmpeg>=0.4", + # v0.3.0: pure pytest-playwright 移行 + # pytest 本体と pytest-playwright fixture (page / context / browser_context_args)、 + # pytest-xdist による並列実行を main 依存として要求する。 + "pytest>=8.0", + "pytest-playwright>=0.5", + "pytest-xdist>=3.0", + # accessibility autouse は scenario.config.yaml で default 有効。 + # runtime テンプレート (templates/pyproject.toml.runtime) と揃えて main dep に含める。 + "axe-playwright-python>=0.1.4", +] + +[project.optional-dependencies] +# Drive 連携: scripts/trace_link.py / build_gdoc_with_drive_links.py 等が使用 +drive = [ + "google-api-python-client>=2.0", + "google-auth>=2.0", + "google-auth-oauthlib>=1.0", +] +# 開発用: pure function の単体テスト (uv run --extra dev pytest) +# pytest 本体は main deps に移行済 (v0.3.0)。dev には開発時のみの拡張を入れる余地。 +dev = [ + "pytest>=8.0", +] + +[project.entry-points."pytest11"] +# pytest が auto-discover する plugin entry-point。 +# ``uv pip install -e .`` 後は ``pytest`` 起動だけで playwright_kit plugin が有効になる。 +playwright-kit = "playwright_kit.pytest_plugin" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["playwright_kit"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/_drive_auth.py b/plugins/ndf/skills/playwright-kit-ops/scripts/_drive_auth.py new file mode 100644 index 0000000..647ab52 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/scripts/_drive_auth.py @@ -0,0 +1,57 @@ +"""google-auth スキル経由で Drive API クレデンシャルを取得する共通ヘルパ。 + +3 つの uploader スクリプト (gdrive_upload_dir / build_gdoc_with_drive_links / +upload_md_as_gdoc) はいずれも同じ手順で `google_auth.get_credentials()` を +sys.path から発見する。本モジュールにロジックを集約する。 + +Drive 連携は optional dependency。`GOOGLE_AUTH_SCRIPTS` 環境変数が設定されて +いればそれを使い、それ以外は標準インストール先と sibling の google-auth +スキルを探す。Codex 公開セットには google-auth を含めないため、Codex で +Drive 系コマンドを使う場合は `GOOGLE_AUTH_SCRIPTS` を明示する。 +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +_HERE = Path(__file__).resolve() +_CANDIDATES: tuple[Path, ...] = tuple( + Path(p).expanduser() + for p in ( + os.environ.get("GOOGLE_AUTH_SCRIPTS"), + "~/.claude/skills/google-auth/scripts", + "~/.codex/skills/google-auth/scripts", + str(_HERE.parent.parent.parent / "google-auth" / "scripts"), + ) + if p +) + + +def _ensure_google_auth_on_path() -> None: + """`from google_auth import get_credentials` できるよう sys.path を整える。""" + for p in _CANDIDATES: + if p.is_dir(): + path = str(p) + if path not in sys.path: + sys.path.insert(0, path) + return + searched = "\n - ".join(str(p) for p in _CANDIDATES) + raise RuntimeError( + "Google Drive 連携には optional skill `google-auth` が必要です。\n" + "Codex 公開セットには同梱していないため、Drive 系コマンドを使う前に " + "`GOOGLE_AUTH_SCRIPTS` を google-auth/scripts へ設定してください。\n" + "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts\n" + "検索した候補:\n - " + f"{searched}" + ) + + +def drive_service(scopes: list[str]): + """認証済み Drive API v3 service を返す。""" + _ensure_google_auth_on_path() + from google_auth import get_credentials # type: ignore + from googleapiclient.discovery import build + return build("drive", "v3", credentials=get_credentials(scopes)) diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py b/plugins/ndf/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py new file mode 100644 index 0000000..47990c6 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py @@ -0,0 +1,126 @@ +"""report.md の相対リンクを Google Drive URL に置換し、Google Docs として再アップロードする。 + +事前に対象ディレクトリを Drive にアップロード済みである前提。 +このスクリプトは: + 1. Drive 上の フォルダから {相対パス: file_id} mapping を構築 + 2. report.md 中の `(./TC-XX/foo.ext)` 形式リンクを Drive URL に書き換え + 3. text/markdown としてアップロードし mimeType=Google Docs 指定で自動変換 +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _drive_auth import drive_service # noqa: E402 + +from googleapiclient.http import MediaFileUpload # noqa: E402 + +SCOPES = ["drive.file", "drive.readonly"] +FOLDER_MIME = "application/vnd.google-apps.folder" +DOC_MIME = "application/vnd.google-apps.document" +LINK_PATTERN = re.compile(r"\(\.?\/?(TC-[\w-]+/[^\)\s]+)\)") + + +def list_folder_files(service, folder_id: str, prefix: str = "") -> dict[str, str]: + """folder_id 配下のファイルを再帰的に列挙し、{相対パス: file_id} を返す。""" + out: dict[str, str] = {} + page_token: str | None = None + while True: + resp = service.files().list( + q=f"'{folder_id}' in parents and trashed=false", + fields="nextPageToken, files(id,name,mimeType)", + pageSize=200, pageToken=page_token, + supportsAllDrives=True, includeItemsFromAllDrives=True, + ).execute() + for f in resp.get("files", []): + rel = f"{prefix}/{f['name']}".lstrip("/") + if f["mimeType"] == FOLDER_MIME: + out.update(list_folder_files(service, f["id"], rel)) + else: + out[rel] = f["id"] + page_token = resp.get("nextPageToken") + if not page_token: + return out + + +def find_run_folder_id(service, parent_id: str, run_id: str) -> str: + """parent 配下の run_id 名フォルダの ID を返す。なければ例外。""" + files = service.files().list( + q=( + f"'{parent_id}' in parents and name='{run_id}' " + f"and mimeType='{FOLDER_MIME}' and trashed=false" + ), + fields="files(id,name)", + supportsAllDrives=True, includeItemsFromAllDrives=True, + ).execute().get("files", []) + if not files: + raise SystemExit(f"ERROR: run-id folder '{run_id}' not found under {parent_id}") + return files[0]["id"] + + +def _drive_url_for(rel: str, fid: str) -> str: + # PNG は uc?id (画像直接表示)、その他 (動画/zip/etc) は file/d//view + if rel.endswith(".png"): + return f"https://drive.google.com/uc?id={fid}" + return f"https://drive.google.com/file/d/{fid}/view" + + +def rewrite_links(md: str, mapping: dict[str, str]) -> tuple[str, int]: + """`(./TC-XX/foo.ext)` 形式リンクを Drive URL に置換し、(新md, 置換件数) を返す。""" + replaced = 0 + + def rep(m: re.Match[str]) -> str: + nonlocal replaced + rel = m.group(1) + fid = mapping.get(rel) + if fid is None: + return m.group(0) # 未マップは原文のまま + replaced += 1 + return f"({_drive_url_for(rel, fid)})" + + return LINK_PATTERN.sub(rep, md), replaced + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--md", required=True, type=Path) + p.add_argument("--folder", required=True, + help="Drive folder containing the run-id subfolder") + p.add_argument("--run-id", required=True, + help="Run id subfolder name (= local report dir name)") + p.add_argument("--name", required=True) + args = p.parse_args() + + service = drive_service(SCOPES) + run_folder_id = find_run_folder_id(service, args.folder, args.run_id) + print(f"run folder: {run_folder_id}") + + mapping = list_folder_files(service, run_folder_id) + print(f"Indexed {len(mapping)} files") + + md_new, replaced = rewrite_links(args.md.read_text(encoding="utf-8"), mapping) + print(f"Replaced links: {replaced} matches") + + tmp_md = Path("/tmp/report_with_drive_links.md") + tmp_md.write_text(md_new, encoding="utf-8") + + media = MediaFileUpload(str(tmp_md), mimetype="text/markdown", resumable=True) + file = service.files().create( + body={"name": args.name, "mimeType": DOC_MIME, "parents": [args.folder]}, + media_body=media, + fields="id,name,webViewLink,mimeType", + supportsAllDrives=True, + ).execute() + print(f"OK: created {file['name']} ({file['mimeType']})") + print(f" id: {file['id']}") + print(f" url: {file['webViewLink']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/check_cwv.py b/plugins/ndf/skills/playwright-kit-ops/scripts/check_cwv.py new file mode 100644 index 0000000..679554d --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/scripts/check_cwv.py @@ -0,0 +1,113 @@ +"""Core Web Vitals (LCP/CLS/TTFB/longest_task) を 1 URL に対して計測する CLI。 + +`playwright_kit.web_vitals` モジュールの薄いラッパ。runner は testcase 内蔵で同 module +を呼ぶため、本 CLI は外部 URL の単発計測専用。 + +Usage: + python check_cwv.py --url https://example.com + python check_cwv.py --url-list urls.txt --output web_vitals.json + python check_cwv.py --url https://example.com --device "Pixel 5" +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from playwright.sync_api import sync_playwright + +_SKILL_ROOT = Path(__file__).resolve().parent.parent +if str(_SKILL_ROOT) not in sys.path: + sys.path.insert(0, str(_SKILL_ROOT)) + +from playwright_kit.web_vitals import THRESHOLDS, judge, measure_page # noqa: E402 + + +def measure( + url: str, + *, + storage_state: str | None = None, + device_name: str | None = None, + timeout_ms: int = 30_000, + headless: bool = True, + observe_ms: int = 5000, +) -> dict[str, Any]: + with sync_playwright() as p: + browser = p.chromium.launch(headless=headless) + ctx_kwargs: dict[str, Any] = {"ignore_https_errors": True} + if storage_state: + ctx_kwargs["storage_state"] = storage_state + if device_name: + device = p.devices.get(device_name) + if not device: + browser.close() + return {"url": url, "error": f"unknown device: {device_name}"} + ctx_kwargs.update(device) + + context = browser.new_context(**ctx_kwargs) + page = context.new_page() + try: + page.goto(url, timeout=timeout_ms, wait_until="domcontentloaded") + except Exception as exc: + browser.close() + return {"url": url, "error": str(exc)} + + metrics = measure_page(page, observe_ms=observe_ms) + browser.close() + return { + "url": url, + "device": device_name or "desktop", + "metrics": metrics, + "judgement": {k: judge(k, v) for k, v in metrics.items()}, + "thresholds": THRESHOLDS, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Core Web Vitals を計測") + parser.add_argument("--url", help="計測対象 URL") + parser.add_argument("--url-list", type=Path, help="URL を 1 行 1 件で書いたファイル") + parser.add_argument("--storage-state", default=None) + parser.add_argument("--device", default=None, + help="Playwright device 名 (例: 'Pixel 5')") + parser.add_argument("--observe-ms", type=int, default=5000, + help="PerformanceObserver 観測時間 (ms)") + parser.add_argument("--output", type=Path, default=None) + parser.add_argument("--fail-on-poor", action="store_true", + help="poor 判定 1 件以上で exit 1") + parser.add_argument("--headed", action="store_true") + args = parser.parse_args() + + if not args.url and not args.url_list: + parser.error("--url または --url-list が必要です") + + urls = ( + [line.strip() for line in args.url_list.read_text().splitlines() if line.strip()] + if args.url_list else [args.url] + ) + + results = [ + measure(u, storage_state=args.storage_state, device_name=args.device, + headless=not args.headed, observe_ms=args.observe_ms) + for u in urls + ] + + text = json.dumps(results, indent=2, ensure_ascii=False) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text, encoding="utf-8") + print(f"OK: web_vitals → {args.output}", file=sys.stderr) + else: + sys.stdout.write(text + "\n") + + has_poor = any( + any(v == "poor" for v in r.get("judgement", {}).values()) for r in results + ) + return 1 if args.fail_on_poor and has_poor else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/ndf/skills/playwright-kit-ops/scripts/classify_page_role.py b/plugins/ndf/skills/playwright-kit-ops/scripts/classify_page_role.py new file mode 100644 index 0000000..2943843 --- /dev/null +++ b/plugins/ndf/skills/playwright-kit-ops/scripts/classify_page_role.py @@ -0,0 +1,238 @@ +"""URL の page role を DOM の (implicit + explicit) role 集計から判定する。 + +実装はブラウザ DOM クエリで `