diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2d37d60b..4ebcfc42 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,14 +1,14 @@ { "name": "ai-plugins", "owner": { - "name": "devbasex", + "name": "takemi-ohama", "email": "takemi.ohama+ai-plugins@gmail.com" }, "plugins": [ { "name": "ndf", "source": "./plugins/ndf", - "description": "All-in-one plugin combining 6 MCP servers, 6 development workflow commands, 6 specialized agents, and Slack notifications with AI-generated summaries" + "description": "All-in-one plugin (v4.0.0): 8 specialized agents, 33 skills (PR/review workflows, principles, data analysis, Codex CLI integration, skill usage stats), SessionStart hook (transcript retention >= 90 days), Stop hook (AI-summarized Slack notifications). Codex CLI is invoked via /ndf:codex skill or corder agent (MCP server removed in v4.0.0)." }, { "name": "affaan-m", diff --git a/.gitignore b/.gitignore index 3e8d8a9e..d22db56e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ *.key credentials.json service-account-key.json +client_secret.json +google_token.json dbhub.toml # IDE @@ -30,6 +32,13 @@ yarn-error.log* *.tmp *.bak +# Python +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +.venv/ + # Plugin-generated context files # (none currently) diff --git a/.kiro/agents/default.json b/.kiro/agents/default.json new file mode 100644 index 00000000..c4e739d8 --- /dev/null +++ b/.kiro/agents/default.json @@ -0,0 +1,32 @@ +{ + "name": "default", + "description": "NDF統合開発エージェント(Kiro CLI用)", + "resources": [ + "file://AGENTS.md", + "file://README.md", + "file://.kiro/skills/ndf-policies/SKILL.md", + "skill://.kiro/skills/**/SKILL.md" + ], + "hooks": { + "agentSpawn": [ + { + "command": "if [ -f \"${PWD}/CLAUDE.ndf.md\" ] || [ -f \"$HOME/.claude/CLAUDE.ndf.md\" ]; then echo \"[NDF] CLAUDE.ndf.md が検出されました。廃止済みです。cleanup を実行して削除してください。\"; fi" + } + ], + "stop": [ + { + "command": "node plugins/ndf/scripts/slack-notify.js session_end", + "timeout_ms": 70000 + } + ] + }, + "mcpServers": { + "codex": { + "command": "codex", + "args": [ + "mcp-server" + ], + "env": {} + } + } +} \ No newline at end of file diff --git a/.kiro/prompts/clean.md b/.kiro/prompts/clean.md new file mode 100644 index 00000000..9ccc684f --- /dev/null +++ b/.kiro/prompts/clean.md @@ -0,0 +1,3 @@ +mainマージ済みブランチをローカル/リモート一括削除してください。 + +cleanスキルの手順に従って実行してください。引数があればそのまま使用します。 diff --git a/.kiro/prompts/fix.md b/.kiro/prompts/fix.md new file mode 100644 index 00000000..468dacab --- /dev/null +++ b/.kiro/prompts/fix.md @@ -0,0 +1,3 @@ +PRのレビューコメントを確認し、修正対応を実行してください。 + +fixスキルの手順に従って実行してください。引数があればそのまま使用します。 diff --git a/.kiro/prompts/merged.md b/.kiro/prompts/merged.md new file mode 100644 index 00000000..8efb8651 --- /dev/null +++ b/.kiro/prompts/merged.md @@ -0,0 +1,3 @@ +PRマージ後のクリーンアップを実行してください(main更新、ブランチ削除)。 + +mergedスキルの手順に従って実行してください。引数があればそのまま使用します。 diff --git a/.kiro/prompts/pr-tests.md b/.kiro/prompts/pr-tests.md new file mode 100644 index 00000000..85fb20da --- /dev/null +++ b/.kiro/prompts/pr-tests.md @@ -0,0 +1,3 @@ +PRのTest Planを自動実行し、結果をPRコメントに反映してください。 + +pr-testsスキルの手順に従って実行してください。引数があればそのまま使用します。 diff --git a/.kiro/prompts/pr.md b/.kiro/prompts/pr.md new file mode 100644 index 00000000..43ca66ba --- /dev/null +++ b/.kiro/prompts/pr.md @@ -0,0 +1,3 @@ +commit, push, PR作成を一括実行してください。 + +prスキルの手順に従って実行してください。引数があればそのまま使用します。 diff --git a/.kiro/prompts/review.md b/.kiro/prompts/review.md new file mode 100644 index 00000000..ef88b28a --- /dev/null +++ b/.kiro/prompts/review.md @@ -0,0 +1,3 @@ +PRを専門家としてレビューし、Approve/Request Changesを判定してください。 + +reviewスキルの手順に従って実行してください。引数があればそのまま使用します。 diff --git a/.kiro/skills/clean b/.kiro/skills/clean new file mode 120000 index 00000000..bb0f97eb --- /dev/null +++ b/.kiro/skills/clean @@ -0,0 +1 @@ +../../plugins/ndf/skills/clean \ No newline at end of file diff --git a/.kiro/skills/cleanup b/.kiro/skills/cleanup new file mode 120000 index 00000000..f591fd7d --- /dev/null +++ b/.kiro/skills/cleanup @@ -0,0 +1 @@ +../../plugins/ndf/skills/cleanup \ No newline at end of file diff --git a/.kiro/skills/corder-code-templates b/.kiro/skills/corder-code-templates new file mode 120000 index 00000000..d2208e86 --- /dev/null +++ b/.kiro/skills/corder-code-templates @@ -0,0 +1 @@ +../../plugins/ndf/skills/corder-code-templates \ No newline at end of file diff --git a/.kiro/skills/corder-test-generation b/.kiro/skills/corder-test-generation new file mode 120000 index 00000000..a49175ab --- /dev/null +++ b/.kiro/skills/corder-test-generation @@ -0,0 +1 @@ +../../plugins/ndf/skills/corder-test-generation \ No newline at end of file diff --git a/.kiro/skills/data-analyst-export b/.kiro/skills/data-analyst-export new file mode 120000 index 00000000..4e10342d --- /dev/null +++ b/.kiro/skills/data-analyst-export @@ -0,0 +1 @@ +../../plugins/ndf/skills/data-analyst-export \ No newline at end of file diff --git a/.kiro/skills/data-analyst-sql-optimization b/.kiro/skills/data-analyst-sql-optimization new file mode 120000 index 00000000..993cf61a --- /dev/null +++ b/.kiro/skills/data-analyst-sql-optimization @@ -0,0 +1 @@ +../../plugins/ndf/skills/data-analyst-sql-optimization \ No newline at end of file diff --git a/.kiro/skills/deepwiki-transfer b/.kiro/skills/deepwiki-transfer new file mode 120000 index 00000000..fffa4e32 --- /dev/null +++ b/.kiro/skills/deepwiki-transfer @@ -0,0 +1 @@ +../../plugins/ndf/skills/deepwiki-transfer \ No newline at end of file diff --git a/.kiro/skills/docker-container-access b/.kiro/skills/docker-container-access new file mode 120000 index 00000000..b704d774 --- /dev/null +++ b/.kiro/skills/docker-container-access @@ -0,0 +1 @@ +../../plugins/ndf/skills/docker-container-access \ No newline at end of file diff --git a/.kiro/skills/fix b/.kiro/skills/fix new file mode 120000 index 00000000..1a3e7bea --- /dev/null +++ b/.kiro/skills/fix @@ -0,0 +1 @@ +../../plugins/ndf/skills/fix \ No newline at end of file diff --git a/.kiro/skills/git-gh-operations b/.kiro/skills/git-gh-operations new file mode 120000 index 00000000..09511f14 --- /dev/null +++ b/.kiro/skills/git-gh-operations @@ -0,0 +1 @@ +../../plugins/ndf/skills/git-gh-operations \ No newline at end of file diff --git a/.kiro/skills/google-auth b/.kiro/skills/google-auth new file mode 120000 index 00000000..59196385 --- /dev/null +++ b/.kiro/skills/google-auth @@ -0,0 +1 @@ +../../plugins/ndf/skills/google-auth \ No newline at end of file diff --git a/.kiro/skills/knowledge-reorg b/.kiro/skills/knowledge-reorg new file mode 120000 index 00000000..ee9e9be6 --- /dev/null +++ b/.kiro/skills/knowledge-reorg @@ -0,0 +1 @@ +../../plugins/ndf/skills/knowledge-reorg \ No newline at end of file diff --git a/.kiro/skills/markdown-writing b/.kiro/skills/markdown-writing new file mode 120000 index 00000000..b280e817 --- /dev/null +++ b/.kiro/skills/markdown-writing @@ -0,0 +1 @@ +../../plugins/ndf/skills/markdown-writing \ No newline at end of file diff --git a/.kiro/skills/merged b/.kiro/skills/merged new file mode 120000 index 00000000..41896432 --- /dev/null +++ b/.kiro/skills/merged @@ -0,0 +1 @@ +../../plugins/ndf/skills/merged \ No newline at end of file diff --git a/.kiro/skills/ndf-policies b/.kiro/skills/ndf-policies new file mode 120000 index 00000000..e562c773 --- /dev/null +++ b/.kiro/skills/ndf-policies @@ -0,0 +1 @@ +../../plugins/ndf/skills/ndf-policies \ No newline at end of file diff --git a/.kiro/skills/pr b/.kiro/skills/pr new file mode 120000 index 00000000..9d1f7444 --- /dev/null +++ b/.kiro/skills/pr @@ -0,0 +1 @@ +../../plugins/ndf/skills/pr \ No newline at end of file diff --git a/.kiro/skills/pr-tests b/.kiro/skills/pr-tests new file mode 120000 index 00000000..7b27f952 --- /dev/null +++ b/.kiro/skills/pr-tests @@ -0,0 +1 @@ +../../plugins/ndf/skills/pr-tests \ No newline at end of file diff --git a/.kiro/skills/python-execution b/.kiro/skills/python-execution new file mode 120000 index 00000000..67f9cebb --- /dev/null +++ b/.kiro/skills/python-execution @@ -0,0 +1 @@ +../../plugins/ndf/skills/python-execution \ No newline at end of file diff --git a/.kiro/skills/qa-security-scan b/.kiro/skills/qa-security-scan new file mode 120000 index 00000000..58e57000 --- /dev/null +++ b/.kiro/skills/qa-security-scan @@ -0,0 +1 @@ +../../plugins/ndf/skills/qa-security-scan \ No newline at end of file diff --git a/.kiro/skills/researcher-report-templates b/.kiro/skills/researcher-report-templates new file mode 120000 index 00000000..a198fd0f --- /dev/null +++ b/.kiro/skills/researcher-report-templates @@ -0,0 +1 @@ +../../plugins/ndf/skills/researcher-report-templates \ No newline at end of file diff --git a/.kiro/skills/review b/.kiro/skills/review new file mode 120000 index 00000000..2860043d --- /dev/null +++ b/.kiro/skills/review @@ -0,0 +1 @@ +../../plugins/ndf/skills/review \ No newline at end of file diff --git a/.kiro/skills/scanner-pdf-analysis b/.kiro/skills/scanner-pdf-analysis new file mode 120000 index 00000000..2e38afd3 --- /dev/null +++ b/.kiro/skills/scanner-pdf-analysis @@ -0,0 +1 @@ +../../plugins/ndf/skills/scanner-pdf-analysis \ No newline at end of file diff --git a/.kiro/skills/skill-development b/.kiro/skills/skill-development new file mode 120000 index 00000000..bc580814 --- /dev/null +++ b/.kiro/skills/skill-development @@ -0,0 +1 @@ +../../plugins/ndf/skills/skill-development \ No newline at end of file diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 00000000..773afc5a --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,3 @@ +/cache +/logs +/language_servers diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 00000000..c583c4dc --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,140 @@ + + +# list of languages for which language servers are started; choose from: +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# swift systemverilog terraform toml typescript +# typescript_vts vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- bash + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" +# the name by which the project can be referenced within Serena +project_name: "ai-plugins" + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] diff --git a/.serena/serena_config.yml b/.serena/serena_config.yml new file mode 100644 index 00000000..51c8bd3d --- /dev/null +++ b/.serena/serena_config.yml @@ -0,0 +1,160 @@ +# the language backend to use for code understanding and manipulation. +# Possible values are: +# * LSP: Use the language server protocol (LSP), spawning freely available language servers +# via the SolidLSP library that is part of Serena. +# * JetBrains: Use the Serena plugin in your JetBrains IDE. +# (requires the plugin to be installed and the project being worked on to be open +# in your IDE). +language_backend: LSP + +# whether to open a graphical window with Serena's logs. +# This is mainly supported on Windows and (partly) on Linux; not available on macOS. +# If you prefer a browser-based tool, use the `web_dashboard` option instead. +# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html +# +# Being able to inspect logs is useful both for troubleshooting and for monitoring the tool calls, +# especially when using the agno playground, since the tool calls are not always shown, +# and the input params are never shown in the agno UI. +# When used as MCP server for Claude Desktop, the logs are primarily for troubleshooting. +# Note: unfortunately, the various entities starting the Serena server or agent do so in +# mysterious ways, often starting multiple instances of the process without shutting down +# previous instances. This can lead to multiple log windows being opened, and only the last +# window being updated. Since we can't control how agno or Claude Desktop start Serena, +# we have to live with this limitation for now. +gui_log_window: false + +# whether to start the Serena Dashboard, which provides detailed information on your Serena session, +# the current configuration and furthermore allows some settings to be conveniently modified on the fly. +# We strongly recommend to always enable this option! +# If you want to prevent the Dashboard window from being opened on launch, +# set `web_dashboard_open_on_launch` to false (see below). +# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html +web_dashboard: false + +# the address the web dashboard will listen on (bind address). +web_dashboard_listen_address: 127.0.0.1 + +# whether to open the Dashboard window/browser tab when Serena starts (provided that web_dashboard is enabled). +# If set to false, you can still open the dashboard manually by clicking on the Serena icon in your system +# tray on Windows and macOS. On Linux, there is no system tray support, so you can only open the dashboard by +# a) telling the LLM to "open the dashboard" (provided that the open_dashboard tool is enabled) or by +# b) manually navigating to http://localhost:24282/dashboard/ in your web browser (actual port +# may be higher if you have multiple instances running; try ports 24283, 24284, etc.) +# See also: https://oraios.github.io/serena/02-usage/060_dashboard.html +web_dashboard_open_on_launch: true + +# address where JetBrains plugin servers are running (only relevant when using the JetBrains language backend) +jetbrains_plugin_server_address: 127.0.0.1 + +# the minimum log level for the GUI log window and the dashboard (10 = debug, 20 = info, 30 = warning, 40 = error) +log_level: 20 + +# whether to trace the communication between Serena and the language servers. +# This is useful for debugging language server issues. +trace_lsp_communication: false + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# timeout, in seconds, after which tool executions are terminated +tool_timeout: 240 + +# list of tools to be globally excluded +excluded_tools: [] + +# list of optional tools (which are disabled by default) to be included +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If this is undefined, no base modes are included. +# The project configuration (project.yml) may override this setting. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# These modes can be overridden by the project configuration (project.yml) or through the CLI (--mode). +default_modes: +- interactive +- editing +default_max_tool_answer_chars: 150000 + +# the name of the token count estimator to use for tool usage statistics. +# See the `RegisteredTokenCountEstimator` enum for available options. +# +# By default, a very naive character count estimator is used, which simply counts the number of characters. +# You can configure this to TIKTOKEN_GPT4 to use a local tiktoken-based estimator for GPT-4 (will download tiktoken +# data files on first run), or ANTHROPIC_CLAUDE_SONNET_4 which will use the (free of cost) Anthropic API to +# estimate the token count using the Claude Sonnet 4 tokenizer. +token_count_estimator: CHAR_COUNT + +# the list of registered project paths (updated automatically). +projects: +- /work/ai-plugins + +# list of paths to ignore across all projects. +# Same syntax as gitignore, so you can use * and **. +# These patterns are merged additively with each project's own ignored_paths. +ignored_paths: [] + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# (currently only used by LSP-based tools). +# If the budget is exceeded, Serena stops issuing further retrieval requests +# and returns partial info results. +# 0 disables the budget (no early stopping). Negative values are invalid. +# This is an advanced setting that can help alleviate problems with LSP servers +# that have a slow implementation of request_hover (clangd is one of those) +# or with tool calls that find very many symbols. +# Can be overridden in project.yml. +symbol_info_budget: 10.0 + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# For example, "global/.*" will mark all global memories as read-only. +# You can extend the list on a per-project basis in the project.yml configuration file. +read_only_memory_patterns: [] + +# template for the location of the per-project .serena data folder (memories, caches, etc.). +# Supports the following placeholders: +# $projectDir - the absolute path to the project root directory +# $projectFolderName - the name of the project directory +# Default: "$projectDir/.serena" (data stored inside the project directory) +# Example for a central location: "/projects-metadata/$projectFolderName/.serena" +project_serena_folder_location: $projectDir/.serena + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# This is useful for projects with large numbers of archived memory files. +# You can extend the list on a per-project basis in the project.yml configuration file. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# line ending convention to use when writing source files. +# Possible values: "lf" (Unix), "crlf" (Windows), "native" (platform default). +# Note that Serena's own files (e.g. memories and configuration files) always use native line endings. +# This setting can be overridden on a per-project basis in project.yml files. +line_ending: native + +# defines the interface (application mode) used for the web dashboard (if enabled). +# If empty/null, use platform-dependent default. Otherwise, possible values: +# * browser: the dashboard is opened in the default browser (if `web_dashboard_open_on_launch` is true) +# This is supported on all platforms. +# * app: the dashboard is opened in a separate native-like app window with accompanying tray icon, whose +# lifecycle is tied to the Serena process. +# If `web_dashboard_open_on_launch` is false, the dashboard can be conveniently accessed via the tray icon. +# This is supported on Windows and macOS, but note that on macOS, where tray icons are very visible, +# this may result in too many icons being displayed when using multi-agent setups. +# * tray_manager: use a global tray icon to provide access to the dashboards of all running Serena instances, +# opening the dashboard in browser tabs when selected from the tray menu. +# This is EXPERIMENTAL. It is tested on Windows only. We will establish macOS support, but it is yet untested. +# On Linux, this cannot be universally supported, but it may work in some desktop environments. +web_dashboard_interface: diff --git a/AGENTS.md b/AGENTS.md index de079ac5..529f407e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ **Claude Codeプラグインマーケットプレイス**の開発プロジェクトです。チーム全体でClaude Codeの導入を加速するための事前設定されたプラグインを提供します。 -**リポジトリ**: https://github.com/devbasex/ai-plugins +**リポジトリ**: https://github.com/takemi-ohama/ai-plugins ## ポリシー @@ -62,12 +62,12 @@ ai-plugins/ ## NDFプラグインについて -**NDFプラグイン**は、このマーケットプレイスの主要プラグインです: -- コアMCPサーバー(Codex CLI等) -- スラッシュコマンド(PR作成、レビュー、修正対応、マージ、クリーンアップ等) -- 6個の専門サブエージェント(director、data-analyst、corder、researcher、scanner、qa) -- 23個のSkills(SQL最適化、データエクスポート、コードテンプレート、テスト生成等) -- 自動Slack通知 +**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v4.7.0): +- 8個の専門サブエージェント(director、data-analyst、corder、researcher、qa、debugger、devops-engineer、code-reviewer) +- 39個のSkills(PR/コードレビューワークフロー、AIクロスレビュー (codex/gemini)、原則・ガイドライン、issue→multi-PR ワークフロー戦略、SQL最適化、データエクスポート、skill利用統計、Codex CLI連携、Playwrightシナリオテスト、Google Drive/Chat連携 等) +- SessionStartフック(transcript保持期間自動管理 `cleanupPeriodDays >= 90`) +- Stopフック(AI要約生成 + 自動Slack通知) +- 外部AI委譲は `/ndf:codex` skill と `corder` エージェント経由で Codex CLI を呼び出し(v4.0.0 で Codex MCP サーバは廃止) 詳細は `plugins/ndf/README.md` および `docs/ndf-plugin-reference.md` を参照。 diff --git a/KIRO.md b/KIRO.md index cefb10e7..031e9393 100644 --- a/KIRO.md +++ b/KIRO.md @@ -4,6 +4,47 @@ このファイルにはKiro CLI固有の設定のみを記載します。 +## NDFプラグインのセットアップ + +### 前提条件 +- Kiro CLI がインストール済み +- Node.js(Slack通知を使う場合) +- Codex CLI(`/ndf:codex` skill で外部AI委譲を使う場合、または `--with-codex` で Kiro に Codex MCP サーバ設定を生成する場合): `npm install -g @openai/codex` + +### インストール + +```bash +# 基本(Skills + agentSpawnフックのみ) +bash scripts/install-kiro.sh + +# Slack通知も有効化 +bash scripts/install-kiro.sh --with-slack + +# 全部入り(Slack通知 + Kiro 側 Codex MCP 設定生成) +# 注: NDF v4.0.0 本体は Codex MCP に依存せず、/ndf:codex skill 経由で +# CLI 直接実行に一本化。--with-codex は Kiro セッションで +# `mcp__codex__*` を直接呼びたい場合のみ有効化すればよい。 +bash scripts/install-kiro.sh --with-slack --with-codex +``` + +インストーラーは `plugin.json` からskills一覧を読み取り、`.kiro/agents/default.json` を生成します。 + +### Slack通知の設定(オプション) + +`.env` に以下を設定: +``` +SLACK_CHANNEL_ID=C0123456789 +SLACK_BOT_TOKEN=xoxb-... +SLACK_USER_MENTION=<@U0123456789> +``` + +### 利用方法 + +- スキルはdescriptionのキーワードに基づいて自動参照されます +- ワークフロー系スキル(pr, fix, review等)は手動で指示してください + - 例: 「prスキルの手順に従ってPRを作成して」 +- plugin.jsonにskillが追加された場合、再度インストーラーを実行してください + ## Kiro CLIでのコード探索 ### 基本ワークフロー diff --git a/README.md b/README.md index 4ca9d4d4..6f086a0b 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,87 @@ # AI Plugins -Claude Codeプラグイン、プロジェクトスキル、MCP(Model Context Protocol)設定を共有するための内部マーケットプレイスです。 +Claude CodeプラグインおよびKiro CLI向けのスキル・MCP設定を共有するための内部マーケットプレイスです。 ## 概要 -このマーケットプレイスは、チーム全体でClaude Codeの導入を加速するための事前設定されたプラグインを提供します。 +このマーケットプレイスは、チーム全体でAI開発ツール(Claude Code / Kiro CLI)の導入を加速するための事前設定されたプラグインを提供します。 -**NDFプラグイン**は、以下の機能を**オールインワン**で提供する統合プラグインです: +**NDFプラグイン v4.0.0** は、以下の機能を**オールインワン**で提供する統合プラグインです: -- **2つのコアMCPサーバー**: Serena、Codex CLI -- **9つの開発ワークフローコマンド**: `/ndf:pr`, `/ndf:pr-tests`, `/ndf:fix`, `/ndf:review`, `/ndf:merged`, `/ndf:clean`, `/ndf:serena`, `/ndf:mem-review`, `/ndf:mem-capture` -- **6つの専門エージェント**: **director指揮者**、データ分析、コーディング、調査、ファイル読み取り、品質管理 -- **13個のSkills**: SQL最適化、データエクスポート、コードテンプレート、テスト生成、Python実行、Docker判定、Skill開発、調査レポート、PDF解析、Excel抽出、セキュリティスキャン、Markdown文書作成、記憶戦略 -- **自動フック**: Claude Code終了時にAI要約生成とSlack通知 - -> **Note (v2.6.0)**: 追加のMCP(BigQuery、Chrome DevTools、AWS Docs、DBHub、Notion)は個別プラグインとしてインストール可能です。GitHub MCP、Context7 MCPは公式プラグインから利用できます。 +- **33個のSkills**: + - PR/レビューワークフロー (pr, pr-tests, fix, review, review-branch, review-pr-comments, resolve-pr-comments, cherry-pick-pr, deploy, sync-main, merged, clean, browser-test) + - 原則・ガイドライン (ndf-policies, branch-fix-strategy, implementation-plan, investigation-rules, problem-solving, logging-guidelines, markdown-writing) + - データ分析・品質 (data-analyst-sql-optimization, data-analyst-export, qa-security-scan) + - 外部連携・環境 (codex, git-gh-operations, google-auth, python-execution, docker-container-access, deepwiki-transfer, knowledge-reorg, mcp-builder, official-skills-autoloader) + - 運用 (skill-stats) +- **8つの専門エージェント**: director, data-analyst, corder, researcher, qa, debugger, devops-engineer, code-reviewer +- **自動フック**: SessionStart (transcript保持期間を最低90日に保つ) + Stop (AI要約生成+Slack通知) +- **外部AI委譲**: `/ndf:codex` skill + `corder` エージェント経由で Codex CLI をバックグラウンド実行 (v4.0.0 で Codex MCP サーバは廃止) +- **Kiro CLI対応**: インストーラーによるワンコマンドセットアップ ## 利用方法 -### 1. マーケットプレイスの追加 +### Claude Code -Claude Codeでマーケットプレイスを追加します: +#### 1. マーケットプレイスの追加 ```bash -/plugin marketplace add https://github.com/devbasex/ai-plugins +/plugin marketplace add https://github.com/takemi-ohama/ai-plugins ``` -### 2. プラグインのインストール - -利用したいプラグインをインストールします: +#### 2. プラグインのインストール ```bash # NDFプラグイン(オールインワン統合プラグイン) /plugin install ndf@ai-plugins ``` -このプラグイン1つで、MCP統合、開発ワークフロー、専門エージェント、自動フックのすべてが利用可能です。 +### Kiro CLI + +#### 1. リポジトリをクローン + +```bash +git clone https://github.com/takemi-ohama/ai-plugins.git +cd ai-plugins +``` + +#### 2. インストーラーを実行 + +```bash +# 基本(Skills + agentSpawnフックのみ) +bash scripts/install-kiro.sh + +# Slack通知も有効化 +bash scripts/install-kiro.sh --with-slack + +# 全部入り(Slack + Codex CLI 連携) +bash scripts/install-kiro.sh --with-slack --with-codex +``` + +インストーラーは `plugin.json` からskills一覧を読み取り、`.kiro/agents/default.json` を自動生成します。 + +#### 3. Slack通知の設定(オプション) + +`.env` に以下を設定: +``` +SLACK_CHANNEL_ID=C0123456789 +SLACK_BOT_TOKEN=xoxb-... +SLACK_USER_MENTION=<@U0123456789> +``` + +#### 4. 起動 + +```bash +kiro-cli chat +``` + +詳細は [KIRO.md](./KIRO.md) を参照。 ### 利用可能なプラグイン | プラグイン名 | バージョン | 説明 | 詳細 | |------------|----------|------|------| -| **ndf** | 2.6.0 | Claude Code開発環境を**オールインワン**で強化する統合プラグイン。2つのコアMCPサーバー(Serena、Codex CLI)、9つの開発ワークフローコマンド、6つの専門エージェント(**director指揮者**、データ分析、コーディング、調査、ファイル読み取り、品質管理)、13個のSkills、Stopフック(AI要約生成とSlack通知)を提供。追加のMCPは個別プラグインとしてインストール可能。 | [README](./plugins/ndf/README.md) | +| **ndf** | 4.0.0 | Claude Code / Kiro CLI開発環境を**オールインワン**で強化する統合プラグイン。8個の専門エージェント(director、data-analyst、corder、researcher、qa、debugger、devops-engineer、code-reviewer)、33個のSkills(PR/レビューワークフロー、原則・ガイドライン、データ分析、品質、Codex CLI連携、skill利用統計など)、SessionStartフック(transcript保持期間自動管理)、Stopフック(AI要約生成+Slack通知)を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:codex` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [README](./plugins/ndf/README.md) | ## 開発ガイドライン @@ -235,7 +275,8 @@ git push #### プロジェクト内ドキュメント -- [CLAUDE.md](./CLAUDE.md) - AIエージェント向けガイドライン +- [CLAUDE.md](./CLAUDE.md) - AIエージェント向けガイドライン(Claude Code) +- [KIRO.md](./KIRO.md) - AIエージェント向けガイドライン(Kiro CLI) - [LICENSE](./LICENSE) - MITライセンス ## コントリビューション diff --git a/docs/external-reviews/pr-57-codex-business-review.md b/docs/external-reviews/pr-57-codex-business-review.md new file mode 100644 index 00000000..732bef51 --- /dev/null +++ b/docs/external-reviews/pr-57-codex-business-review.md @@ -0,0 +1,474 @@ +# PR #57 Codex ビジネス意義レビュー + +| メタ | 値 | +|---|---| +| **PR** | [#57 — feat(ndf)!: playwright-scenario-test v0.3.0 — pure pytest-playwright 完全移行 (v4.2.0)](https://github.com/takemi-ohama/ai-plugins/pull/57) | +| **レビュアー** | OpenAI Codex CLI (`codex exec`) | +| **観点** | 事業・GTM・市場戦略 (技術レビューは別途完了済) | +| **実行日** | 2026-04-26 | +| **実行時間** | 約 8 分 / tokens 129,705 | +| **背景** | 技術レビュー (Codex 3 ラウンド + Amazon Q + Gemini) 完了後、事業観点のみに絞って依頼 | +| **結論** | ⚠️ **コード品質ではなく、事業的な梱包がまだ追いついていない**。power-user 向け merge は合理的、新看板機能として広報するなら周辺メッセージ修正を同時に。 | +| **直近 1ヶ月の最重要アクション** | (1) `marketplace.json` / `plugin.json` / `plugins/ndf/README.md` の storefront 文言更新 / (2) `browser-test` と `playwright-scenario-test` の役割分担を docs で明確化 / (3) 10分で動くデモケースの公開 | +| **PR コメント** | https://github.com/takemi-ohama/ai-plugins/pull/57#issuecomment-4321871998 | + +--- + +# PR #57 ビジネス意義レビュー (Codex) + +## エグゼクティブサマリー +この PR は、`playwright-scenario-test` を「独自 DSL を覚えないと使えない社内向け道具」から、「pytest-playwright を使う開発者がそのまま乗れる AI 時代向けの拡張レイヤー」へ再定義した、事業上かなり重要な転換です。 +結論として、**市場戦略としては正しい方向**です。特に、Claude Code のようなエージェントがテストを“書く側”に回る世界では、独自 DSL より標準 pytest に寄せた方が普及確率は高いです。 +一方で、この PR 単体では **プロダクトの価値は上がったが、見つかりやすさ・伝わりやすさ・買われやすさはまだ弱い** です。 +最大の論点は実装ではなく、**「これは誰の何の業務を、どれだけ短縮する製品なのか」を marketplace と README レベルでまだ言い切れていないこと** です。 +したがって、技術的には前進、事業的には **merge 後 1 か月で GTM メッセージと導線整備をやり切れるか** が勝負です。 + +## 1. 市場ポジショニング + +### 1.1 強み +この skill の本質は、Playwright そのものの代替ではなく、**Playwright を AI エージェント運用に最適化した“実務レイヤー”** にあります。 +Playwright 公式はブラウザ自動化基盤として強いですが、`/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/SKILL.md` を読む限り、本 skill はそこに **テスト設計の方法論、page role 分類、証跡標準化、Drive 共有、Markdown レポート** を足しています。 +つまり競争軸は「ブラウザを動かせるか」ではなく、**“AI が再現性の高い E2E を短時間で作り、レビュー可能な証跡まで揃えられるか”** です。 +この位置づけは、Playwright / Selenium / Puppeteer のような実行基盤とは競争しつつも、実は真正面からは競合しません。 +むしろ「それらをどう現場の開発運用に落とすか」の層を取ろうとしている点が重要です。 + +Cypress と比べたときの強みは、**Python / pytest エコシステムへの自然な接続**です。 +Cypress は体験の一貫性とプロダクト完成度が高い一方、NDF は Claude Code から自然言語で指示し、pytest ベースでそのまま CI・既存 Python テスト文化に乗せやすい。 +これは JS 中心組織には刺さりにくいですが、**バックエンド主導の Python チーム、QA 専任が薄い SaaS チーム、データ/業務アプリ系チーム**にはかなり実用的です。 + +Selenium と比べると、差別化は「新しさ」ではなく **運用思想のパッケージ化**です。 +Selenium は広い互換性と歴史がありますが、NDF は `docs/01-methodology.md` と `docs/02-page-roles.md` に見られるように、**何をテストすべきかまで同梱**している。 +単なる自動化ツールではなく、**AI にテスト観点を教え込む教材兼ランタイム**になっている点は独自です。 + +Puppeteer と比べると、NDF は「細かいブラウザ操作の自由度」では勝負していません。 +代わりに、**pytest fixture と marker で“チームが再利用できる品質運用”にした**のが価値です。 +これは OSS 開発者向けには十分説明可能な違いです。 + +TestRail と比べると、競合ではなく補完です。 +TestRail はテスト管理と可視化の製品です。 +NDF の強みは、**手で作るテストケース管理ではなく、AI と開発者が一緒に“今必要な E2E と証跡”を素早く作ること**です。 +したがって、NDF は TestRail の代替ではなく、**TestRail に入る前の生成・実行・証跡収集レイヤー**として語るべきです。 + +Browserbase と比べると、差別化軸はインフラではなく**ワークフロー所有**です。 +Browserbase はクラウドブラウザとエージェント実行基盤です。 +NDF はその上に載せられる可能性があるが、現状は **ローカル/既存 CI に近い導入の軽さ**が利点です。 +逆に言えば、将来は競合より提携余地が大きいカテゴリです。 + +最大の強みは、AI コーディングエージェント時代における **“テストを書く主体が人からエージェントに移る” ことを前提にした設計**です。 +`SKILL.md` の page role、checklist、template、`conftest.py.template` は、全部「人間 QA の教育コスト」を下げるだけでなく、**AI が迷いなくテストを書ける文法と足場**になっています。 +これは単なる E2E ツールではなく、**AI に品質業務を外注するための最小オペレーティングシステム**という見方ができます。 + +### 1.2 弱み / 競合との差 +弱みは明確で、**今のままだと“何でも入った NDF の一機能”に見えてしまい、単独価値として発見されにくい**ことです。 +`plugins/ndf/.claude-plugin/plugin.json` の description には機能が多く並びますが、焦点が広すぎます。 +利用者は「PR ワークフロー」「Codex 連携」「Slack 通知」「Google Drive」「E2E テスト」のどれを主価値として認識すべきか迷います。 + +`plugin.json` の `keywords` に `playwright` `pytest` `e2e` `qa` `accessibility` `testing` が入っていないのも機会損失です。 +ビジネス的には、検索で見つからないプロダクトは存在しないのと同義です。 + +さらに `.claude-plugin/marketplace.json` の `ndf` description はまだ v4.0.0 ベースで、**36 skills や scenario test の価値が表に出ていません**。 +これは実装以前に、**店頭 POP が古い**状態です。 +このままでは PR #57 の価値を merge しても市場に伝わりません。 + +また、Cypress は「全部入りの体験」と「わかりやすい UI/Cloud」を売れますが、NDF はそこに対して **学習・運用・導入後の価値訴求が文章頼み**です。 +OSS では普通でも、事業観点では **“最初の 10 分で価値がわかるデモ” がまだ不足**しています。 + +Browserbase のようなエージェント基盤は「AI エージェント向け」を非常に前面に出しています。 +一方 NDF は、実際にはかなり agent-native なのに、外から見ると「pytest skill の一つ」に見えます。 +ここはポジショニングの負けです。 + +TestRail のようなエンタープライズ QA 製品と比べると、NDF は **監査、権限、実行履歴、カバレッジ管理、組織導入の説明責任**が弱いです。 +そのため現状の主要ターゲットは enterprise QA 本体ではなく、**開発チーム主導の品質活動**に限定されます。 +この限定自体は悪くありませんが、誰向けかを曖昧にしたまま広く売ろうとすると失敗します。 + +### 1.3 評価 +**独自 DSL 廃止は、市場戦略として正しかった**です。 +理由は単純で、AI エージェントがコードを書く世界では、差別化は DSL ではなく **“標準の上にどう勝つか”** に移るからです。 + +独自 DSL は短期的には差別化になります。 +しかし普及フェーズでは、学習コスト、IDE 連携、CI 接続、外部 contributor 参加、AI による自動生成のしやすさで不利になります。 +PR 本文でも触れている通り、pytest-native 化によって pytest ecosystem をそのまま使えるのは、事業上かなり大きいです。 + +これは「差別化を捨てた」のではありません。 +**差別化の場所を、記法から運用知に移した**のです。 +その意味で、この PR はプロダクトの軸を正しく変えています。 + +ただし今後の差別化は、もう「pytest の上で動く」だけでは足りません。 +勝ち筋は **role-based test planning、AI 自動起動、証跡パッケージ、Drive/PR/CI 連携、リリース判定の型化** にあります。 +ここを前面に出せれば強いです。 +出せなければ「ちょっと便利な pytest fixture 集」に見えて埋もれます。 + +## 2. ターゲットユーザーと採用シナリオ + +### 2.1 主要ターゲット (推定) +現状の一次ターゲットは、**Claude Code をすでに使っている個人開発者と 2〜15 名規模の開発チーム**です。 +理由は、配布チャネルが Claude Code plugin marketplace であり、導入動線が `/plugin marketplace add` → `/plugin install ndf@ai-plugins` に依存しているからです。 +これは市場の母数を絞りますが、同時に「AI 活用に前向きな層」に絞れる利点もあります。 + +二次ターゲットは、**QA 専任が弱い SaaS / 内製プロダクトチーム**です。 +特に PM、EM、テックリードが「リリース前確認を人海戦術で回している」組織に向いています。 +`SKILL.md` が提供する動画、trace、HAR、a11y、CWV、report.md は、まさにその手の組織が雑にやっている品質確認を型化できます。 + +三次ターゲットは、**受託・制作会社や複数案件を持つ小規模ベンダー**です。 +page role 別 checklist は、案件ごとにゼロからテスト観点を起こす工数を減らせます。 +これは「毎回誰かの経験に依存していた確認作業」を半製品化できます。 + +現時点でメインターゲットではないのは、**大企業の中央 QA 組織**です。 +彼らが重視するのは、標準化だけでなく、証跡保管、承認フロー、監査、権限分離、既存テスト管理ツール連携、SLA です。 +NDF にはそこまでのパッケージングがまだありません。 +したがって enterprise を狙うなら、現段階では QA 本部より **開発部門の先行導入**から入るべきです。 + +### 2.2 解消する pain point +この skill が解消する最大の pain は、**「E2E をやるべきとわかっていても、設計と証跡が面倒で継続できない」** です。 +多くのチームは Playwright 自体は知っていても、何をテストすべきか、どこまで証跡を残すべきか、どう共有すべきかで止まります。 +`docs/01-methodology.md` と `docs/02-page-roles.md` は、そこを強く埋めています。 + +2 つ目の pain は、**AI にテストを書かせても品質観点が抜けやすい**ことです。 +普通の LLM は UI を触るテストを書けても、role 別のチェック観点までは安定しません。 +この skill は、**AI の行動範囲を狭めることで品質を上げる**タイプの製品です。 +それは agent 時代の非常に正しいプロダクト設計です。 + +3 つ目の pain は、**テスト失敗時の説明責任**です。 +動画、HAR、trace、console、a11y、CWV をまとめて `report.md` 化し、場合によっては Drive 共有できるのは、単なる「テスト実行」ではなく「関係者に説明できること」を売っています。 +ここはビジネス価値が高いです。 +特に PM やデザイナーや CS を巻き込む現場では、テストコードより証跡の方が価値になります。 + +4 つ目の pain は、**認証ありフローの再利用の面倒さ**です。 +これは技術実装には触れませんが、事業上は「ログインが必要な業務アプリでも回せる」という採用可能性に直結します。 +一般論として、認証をまたぐと E2E は一気に面倒になります。 +そこを超えられる体験は中小チームに効きます。 + +5 つ目の pain は、**a11y と Core Web Vitals が“やるべきだが後回し”になりがち**なことです。 +この skill はそれをテスト本体に隣接させています。 +つまり、別施策として予算化しにくい品質観点を、**E2E 導入予算の中に潜り込ませる**設計です。 +これは B2B QA 製品として強い発想です。 + +### 2.3 採用障壁 +最大の採用障壁は、**「だったら素の pytest-playwright を直接使えばよいのでは?」という問い**です。 +これは避けられません。 +したがって、NDF は“pytest を置き換えるもの”ではなく、**pytest 導入後に必ず発生する面倒ごとを減らすもの**として語る必要があります。 + +この問いに対する優位性は、現状だと 5 つあります。 +1 つ目は、**role-based planning** です。 +2 つ目は、**証跡の標準化** です。 +3 つ目は、**AI 自動起動との相性** です。 +4 つ目は、**a11y/CWV の近接配置** です。 +5 つ目は、**Drive 共有まで含めた関係者コミュニケーション** です。 + +逆に言うと、この 5 点がドキュメントや LP で明確でなければ、利用者は「自前 fixture の方がコントロールできる」と判断します。 +特に上級者はその傾向が強いです。 +上級者を説得するには、「自由度」ではなく **導入後 2 週間の運用負債をどれだけ減らせるか** を見せる必要があります。 + +導入障壁の 2 つ目は、**NDF が大きすぎること**です。 +Playwright scenario test だけ欲しい人にも、8 agents / 36 skills / hooks / Slack などの文脈がついてきます。 +統合プラグインとしては合理的でも、単機能導入の観点では重いです。 +これは将来的に **standalone plugin 化** を検討すべき論点です。 + +導入障壁の 3 つ目は、**配布チャネルの限定**です。 +Claude Code plugin marketplace に乗っていることは強みでもありますが、Claude Code を使わない組織には届きません。 +Cursor、GitHub Copilot、Devin を使うチームには、そのままでは市場が閉じています。 +OSS として広げるなら、**“Claude Code plugin でありつつ、中身は普通の pytest package”** という二重の売り方が必要です。 + +導入障壁の 4 つ目は、**`browser-test` skill との役割重複の見え方**です。 +`browser-test` は「ブラウザで動作確認」、`playwright-scenario-test` は「理論ベース E2E と証跡収集」です。 +しかし利用者の自然言語では両者はしばしば同じ依頼に見えます。 +このままだと簡易確認の skill に流れてしまい、本命 skill が使われない可能性があります。 + +### 2.4 評価 +ターゲットは広く見積もらない方がよいです。 +この PR 時点で最も勝ちやすいのは、**“AI を使っているが QA 専任がいないプロダクト開発チーム”** です。 +そこでは、この skill は「E2E フレームワーク」ではなく、**リリース前確認を省力化する品質ワークフロー**として売れます。 + +現時点では enterprise QA に正面から行くより、**開発チーム起点で横展開される道具**を狙うべきです。 +その方が product-market fit を作りやすいです。 + +## 3. Skill のディスカバラビリティと活性化 + +### 3.1 description / Triggers の妥当性 +`/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/SKILL.md` の `description` は情報量が豊富で、技術的にはかなり良いです。 +ただし business 観点では、**少し盛り込みすぎ**です。 +利用者に価値を伝える文章としては強いが、AI の auto-invoke trigger としては信号が散っています。 + +良い点は、`pytest-playwright` `a11y` `Core Web Vitals` `Google Drive` `video` `page role` など、差別化要素がほぼ全部入っていることです。 +悪い点は、それにより **skill の中心価値がぼやけやすい**ことです。 +AI にとっても人にとっても、「これは E2E planning skill なのか、動画証跡 skill なのか、品質ゲート skill なのか」が一読で定まりにくい。 + +`when_to_use` で Triggers を分離した設計自体は正しいです。 +`plugins/ndf/CLAUDE.md` でもその改善意図が記録されており、方向性は良いです。 +ただし trigger phrase はまだ改善余地があります。 + +現状の trigger は、技術者寄りの言葉に少し偏っています。 +実務で出やすい自然言語は、もっとこうです。 +「リリース前に主要導線だけ見て」 +「フォームまわりの回帰確認して」 +「E2E を自動で書いて」 +「動画つきで不具合報告ほしい」 +「UI の受け入れ確認を回したい」 +「サインアップ導線の smoke test して」 +こうした表現が今の `when_to_use` には十分に入っていません。 + +### 3.2 自動起動トリガーの抜け +抜けている重要語は、**`smoke test` `受け入れテスト` `UI回帰` `主要導線` `動作確認` `release signoff` `サインアップ` `購入フロー` `チェックアウト` `証跡` `録画` `デモ用動画`** あたりです。 +特に `browser-test` が「動作確認」を取りに行くため、scenario-test は **“継続的 E2E / 証跡付き / pytest 化” の文脈に強く寄せた trigger 設計**が必要です。 + +もう一つの抜けは、**役割ベースの具体シーン**です。 +今の `page role` 言及はテスト設計者には刺さりますが、一般利用者は page role という言葉を使いません。 +代わりに「一覧ページ」「申込フォーム」「管理画面」「ログイン」「設定画面」などの平易な語を trigger にもっと入れた方が自動起動しやすいです。 + +さらに、「AI にテストを書かせたい」という意図も前面に出し切れていません。 +今の文章は skill の内容説明としては優秀ですが、**エージェントに“これは自分が使うべきものだ”と気付かせるタグ付け**としては、もう少し直接的でよいです。 + +### 3.3 インストール導線 +導線は概ねシンプルです。 +`README.md` と `docs/project-overview.md` の通り、マーケットプレイス追加と `ndf` インストールで入るのは摩擦が小さいです。 +Claude Code plugin モデルと相性は良いです。 + +ただし問題は、**scenario-test 単体の価値に最短で到達できない**ことです。 +利用者はまず NDF 全体を理解させられます。 +`plugins/ndf/README.md` も大きな統合プラグインの説明が先に来るため、testing use case の人には情報密度が高すぎます。 + +`marketplace.json` の `ndf` description が古いのは、導線上かなり痛いです。 +ここは marketplace 上の第一印象なので、**PR #57 の価値を最初に伝える場所**です。 +ここが古いままだと、いくら skill が良くても click されません。 + +また `plugin.json` の description は良くなっていますが長いです。 +長い description は詳細説明にはよくても、storefront では読み飛ばされます。 +一文目に **“AI が pytest-playwright テストと証跡を自動生成する”** と言い切る短い表現が必要です。 + +### 3.4 評価 +discoverability は **60 点**です。 +skill 自体の設計は良いです。 +しかし市場に見つけてもらう設計はまだ弱いです。 + +最優先でやるべきなのは 3 つです。 +1 つ目は `marketplace.json` と `plugin.json` の storefront 文言更新です。 +2 つ目は `keywords` に testing 系語彙を足すことです。 +3 つ目は `browser-test` との役割分担を README 上で明確にすることです。 + +この 3 つはコードよりはるかに小さい作業ですが、採用率には大きく効きます。 + +## 4. コミュニティ & エコシステム + +### 4.1 OSS 持続可能性 +MIT であることは良いです。 +ただし、持続可能性はライセンスだけでは決まりません。 +現状の repo は **内部導入 acceleration の色が強く、外部 contributor がどこから参加してよいかがやや見えにくい**です。 +これは `README.md` と `AGENTS.md` の位置づけからも感じます。 + +単独 maintainer 色が強いプロジェクトで、かつ大きい PR が続くと、外部 contributor は入りにくくなります。 +PR #57 自体も大規模で、しかも高度です。 +技術的にはよいのですが、コミュニティ形成の観点では **「後から人が追いつける粒度」** をどう作るかが課題です。 + +OSS として伸ばしたいなら、外部参加者に必要なのは実装の正しさ以上に **参加しやすい入口**です。 +具体的には、`good first issue`、サンプルプロジェクト、導入事例、サポート範囲、安定 API 面の宣言、破壊的変更の方針です。 +今は skill の中身は強いが、コミュニティ運営の型はまだ薄いです。 + +### 4.2 pytest-playwright 公式との関係 +この skill は、pytest-playwright 公式と競合するより、**その上の opinionated companion** として共生すべきです。 +それが一番筋がよいです。 + +上流に送れるものは、コードより **知見・事例・パターン** の方が多そうです。 +たとえば fixture 運用、AI との相性、artifact 管理、role-based template などは、公式に「こうすべき」と入れるより、周辺エコシステムとして存在した方が自然です。 + +したがって、戦略は「上流を置き換える」ではなく、**“Playwright/pytest-playwright を採用したチームが、その翌日に必要になる運用レイヤー”** として位置づけるべきです。 +これは敵を作らないし、導入時の説明も容易です。 + +Browserbase などの agent/browser infra とも競合一辺倒にしない方がよいです。 +彼らは実行基盤とスケールを売り、NDF は test authoring と evidence workflow を売る。 +この分業は成立します。 +将来的には「Browserbase 上で NDF scenario-test を動かす」物語も作れます。 + +Anthropic 公式 Skill との関係は、基本的に**補完**です。 +`mcp-builder` のような公式 skill は汎用の土台です。 +NDF の `playwright-scenario-test` は、**品質/QA という垂直ドメインに深く刺した skill** です。 +重複より、公式エコシステムを埋める実務 skill として見せる方がよいです。 + +### 4.3 評価 +OSS としての種はあります。 +ただし今のままだと、**“よくできた個人/チーム用 power tool” で止まる確率が高い**です。 +それ自体は悪くありません。 +ただ、もし事業化やコミュニティ拡大を狙うなら、次に必要なのはコードではなく **運営のインターフェース**です。 + +## 5. 収益化 / 商用展開の余地 + +### 5.1 現状の SaaS / Enterprise 化余地 +現状の OSS 単体でそのまま SaaS になるわけではありません。 +しかし、**商用化の芽は明確にある**と思います。 + +売れる可能性があるのは、ランナーそのものではなく **証跡・標準化・運用連携**です。 +具体的には、動画、trace、HAR、a11y、CWV、report.md、Drive 共有の束は、開発チームにとって「実行結果」ではなく **リリース可否を議論する資料**です。 +この資料化こそ B2B でお金になる部分です。 + +SaaS 化するなら、有望なのは **Evidence Hub** です。 +各 test run の証跡を保管し、PR、Jira、Slack、Drive、GitHub Releases と紐づけ、リリース単位で履歴比較できる。 +これなら OSS ランナーの上に有料レイヤーを被せられます。 + +Enterprise 機能として売りやすいのは、**権限、保持期間、PII マスキング、承認フロー、監査ログ、品質ゲート、SAML/SSO、Jira/TestRail 連携**です。 +現状 skill はそこまで持っていませんが、方向性としては自然です。 + +コンサル販売も現実的です。 +特に「AI エージェント時代の品質ワークフロー設計」「Claude Code + pytest-playwright の導入テンプレート」「page role ベースの test design 内製化」は、組織導入支援として売りやすいです。 +多くの組織はツールより **型**にお金を払います。 +NDF はその型をすでに持っています。 + +### 5.2 提案 +収益化の順序は、**SaaS 直行よりも、まず導入支援と premium workflow から**が堅いです。 +最初の商材としては、次の 3 本が考えやすいです。 + +1 本目は、**“AI E2E Starter Pack”** です。 +対象は中小の SaaS チーム。 +Claude Code / pytest-playwright / NDF を 1〜2 週間でセットアップし、主要導線 10 本程度を自動テスト化する導入支援。 + +2 本目は、**“Release Evidence Pack”** です。 +動画、a11y、CWV、HAR、trace を PR や Slack に整形して流す運用をテンプレ化し、品質会議の時間を削減する。 +これは PM、QA、EM が直接価値を感じやすいです。 + +3 本目は、**“Enterprise Governance Add-on”** です。 +有料機能として証跡保管、権限、承認、チーム別テンプレート、監査を追加する。 +この線なら OSS コアを殺さずに monetization できます。 + +Drive 共有、HUD 動画、a11y/CWV は、単体では売りにくいです。 +しかし **「非エンジニアにも読めるリリース証跡パッケージ」** として束ねると売り物になります。 +要するに、機能ではなく **会議資料を自動生成する製品**として売るべきです。 + +## 6. ロードマップ提案 + +### 6.1 v0.4.0 で優先すべきこと (Top 3) +**Top 1: プロダクトの見せ方を刷新すること** +`marketplace.json`、`plugin.json`、`plugins/ndf/README.md` の scenario-test 訴求を更新し、 +「AI が pytest-playwright テストを書き、証跡を残し、共有まで行う skill」 +と一文で伝わるようにするべきです。 +これは最優先です。 +実装を足すより効果が大きいです。 + +**Top 2: 単独導入の黄金導線を作ること** +今の quickstart は悪くありませんが、もっと「10 分で動く」に寄せるべきです。 +サンプルアプリ、1 コマンド bootstrap、1 本の smoke test、1 つの動画レポートまでを最短導線にする。 +ここができると adoption が跳ねます。 + +**Top 3: “証跡から意思決定へ” を完成させること** +report.md はあるので、次は「PR コメント」「Slack 要約」「リリース判定テンプレ」に接続するべきです。 +単なる artifact ではなく、**誰が何を判断するための出力か** を明確にする。 +ここが製品価値を一段引き上げます。 + +### 6.2 v1.0.0 までに必要なこと +v1.0.0 の条件は、機能網羅ではなく **ポジションの固定**です。 +つまり「この製品は何者か」を市場に一言で説明できる状態です。 + +必要なのは 5 点です。 +1 つ目は、**standalone plugin または package としての切り出し検討**です。 +NDF 全体の一機能のままだと、testing use case の採用が伸びにくいです。 + +2 つ目は、**導入事例または benchmark case**です。 +「主要導線 5 本を何分で作れたか」 +「リリース前確認が何時間減ったか」 +「a11y/CWV の見落としをどれだけ拾えたか」 +この種の数字が必要です。 + +3 つ目は、**CI/PR 連携の business story**です。 +人がローカルで使えるだけでは v1.0.0 として弱いです。 +PR 単位、release 単位で結果が消費されるところまでつなげるべきです。 + +4 つ目は、**チーム運用テンプレートの充実**です。 +role ベースの test 雛形、プロジェクト種別別テンプレート、導入チェックリスト、失敗時 runbook まで整うと、製品として厚みが出ます。 + +5 つ目は、**商用の布石になる evidence registry の原型**です。 +SaaS でなくてもよいですが、「成果物がどこにあり、何を意味し、誰が承認したか」を残せる基盤が欲しいです。 +これが enterprise への橋になります。 + +### 6.3 やるべきでないこと +**独自 DSL を復活させること**はやるべきではありません。 +これは戦略的に逆戻りです。 +AI 時代の勝ち筋は標準の上に乗ることです。 + +**テスト管理プラットフォーム全部入りを目指すこと**も避けるべきです。 +TestRail 的な領域に正面から行くと、プロダクトが散ります。 +NDF は test management ではなく **AI-assisted test execution workflow** に集中すべきです。 + +**page role を増やしすぎること**も罠です。 +分類は増えるほど賢く見えますが、導入コストと説明コストも増えます。 +まずは採用される role に絞り、使用頻度で伸ばすべきです。 + +**Claude Code 専用性を過度に強めること**も避けたいです。 +配布は Claude Code plugin でよいですが、価値自体は `pytest package` として外でも読める形を維持すべきです。 +そうしないと市場が狭すぎます。 + +**機能の多さを売りにしすぎること**も危険です。 +今の NDF は全体として多機能です。 +しかし testing 領域で勝つには、「多機能」より **“これを入れると release signoff が速くなる”** の方が効きます。 + +## 7. 総括 (Go / No-Go ではなく、What's Next) +- このまま merge してよいか: ⚠️ +- 理由はコード品質ではなく、**事業的な梱包がまだ追いついていない**からです。 +- ただしこれは release block ではありません。 +- **power-user 向けに merge するのは合理的**です。 +- 逆に、これを「広く採用される新看板機能」として出すなら、周辺メッセージ修正を同時にやるべきです。 + +- 直近 1 ヶ月の最重要アクション 1: +- `marketplace.json` `plugin.json` `plugins/ndf/README.md` を更新し、scenario-test の価値を storefront レベルで伝える。 +- ここで「AI が pytest-playwright テストと証跡を自動生成する」という一文を明示する。 + +- 直近 1 ヶ月の最重要アクション 2: +- `browser-test` と `playwright-scenario-test` の役割分担をドキュメント上で明確化する。 +- 「簡易動作確認」と「再利用可能 E2E + 証跡収集」を分けて説明しないと活性化が鈍る。 + +- 直近 1 ヶ月の最重要アクション 3: +- 1 つのデモケースを作る。 +- 例として「ログイン → 一覧 → フォーム送信 → report.md + 動画 + Drive リンク」までを 10 分で体験できる導線を公開する。 +- これは README を 100 行足すより効きます。 + +- 6 ヶ月後の理想ポジション: +- 「Claude Code 向けの testing skill」ではなく、 +- **“AI エージェント時代の release evidence workflow for pytest-playwright”** +- として認知されている状態です。 +- そのときの主顧客は、QA 専任の厚い大企業ではなく、 +- **開発チーム主体で品質活動を回す SaaS / 内製チーム**であるはずです。 +- そこから、証跡保管、承認、監査、CI 統合を有料レイヤーに伸ばすのが自然です。 + +## 補足評価 +この PR の事業上の意義は、単に DSL を捨てたことではありません。 +**「AI が読むための独自記法」から「AI も人も既存 ecosystem で扱える標準記法」へ移ったこと**です。 +これは配布チャネルが Claude Code plugin であっても、中身の市場を広げる判断です。 + +より厳しく言うと、ここで捨てたのは DSL ではなく、**“閉じた賢さ”**です。 +その代わりに得たのは、**“開いた普及可能性”**です。 +事業としては後者の方が圧倒的に重要です。 + +この PR は、技術の完成より先に、**売れる形に近づいた PR** と評価します。 +残っているのは、売り方です。 + +## 参照したローカル資料 +- [README.md](/work/ai-plugins/README.md) +- [AGENTS.md](/work/ai-plugins/AGENTS.md) +- [CLAUDE.md](/work/ai-plugins/CLAUDE.md) +- [docs/project-overview.md](/work/ai-plugins/docs/project-overview.md) +- [docs/ndf-plugin-reference.md](/work/ai-plugins/docs/ndf-plugin-reference.md) +- [plugins/ndf/README.md](/work/ai-plugins/plugins/ndf/README.md) +- [plugins/ndf/CLAUDE.md](/work/ai-plugins/plugins/ndf/CLAUDE.md) +- [plugins/ndf/.claude-plugin/plugin.json](/work/ai-plugins/plugins/ndf/.claude-plugin/plugin.json) +- [.claude-plugin/marketplace.json](/work/ai-plugins/.claude-plugin/marketplace.json) +- [plugins/ndf/skills/playwright-scenario-test/SKILL.md](/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/SKILL.md) +- [plugins/ndf/skills/playwright-scenario-test/docs/README.md](/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/docs/README.md) +- [plugins/ndf/skills/playwright-scenario-test/docs/01-methodology.md](/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/docs/01-methodology.md) +- [plugins/ndf/skills/playwright-scenario-test/docs/02-page-roles.md](/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/docs/02-page-roles.md) +- [plugins/ndf/skills/playwright-scenario-test/docs/06-pytest-playwright.md](/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/docs/06-pytest-playwright.md) +- [plugins/ndf/skills/playwright-scenario-test/pyproject.toml](/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/pyproject.toml) +- [plugins/ndf/skills/playwright-scenario-test/templates/conftest.py.template](/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/templates/conftest.py.template) + +## 外部比較で参照した公式情報 +- Playwright Python: https://playwright.dev/python/ +- Playwright pytest plugin reference: https://playwright.dev/python/docs/test-runners +- Cypress docs: https://docs.cypress.io/app/core-concepts/testing-types +- Cypress product site: https://www.cypress.io/ +- Selenium: https://www.selenium.dev/ +- Puppeteer docs: https://pptr.dev/ +- Browserbase docs: https://docs.browserbase.com/introduction +- Browserbase agent browser: https://docs.browserbase.com/integrations/agent-browser/introduction +- TestRail: https://www.testrail.com/ +- Anthropic Claude Code overview: https://docs.anthropic.com/en/docs/claude-code/overview +- Anthropic Claude Code plugins announcement: https://www.anthropic.com/news/claude-code-plugins +- Anthropic Claude Code product page: https://www.anthropic.com/product/claude-code +- Cursor Background Agents: https://docs.cursor.com/background-agents +- GitHub Copilot: https://github.com/features/copilot +- Devin docs: https://docs.devin.ai/get-started/first-run +- Devin Knowledge: https://docs.devin.ai/product-guides/knowledge diff --git a/docs/external-reviews/pr-57-gemini-technical-review.md b/docs/external-reviews/pr-57-gemini-technical-review.md new file mode 100644 index 00000000..8b6f72db --- /dev/null +++ b/docs/external-reviews/pr-57-gemini-technical-review.md @@ -0,0 +1,81 @@ +# PR #57 Gemini 独立技術レビュー + +| メタ | 値 | +|---|---| +| **PR** | [#57 — feat(ndf)!: playwright-scenario-test v0.3.0 — pure pytest-playwright 完全移行 (v4.2.0)](https://github.com/takemi-ohama/ai-plugins/pull/57) | +| **レビュアー** | Gemini CLI v0.39.1 (`gemini -y -p` headless mode) | +| **観点** | 技術 (慣用性 / 可読性 / コード品質 / 保守性 / セキュリティ / テストカバレッジ) | +| **実行日** | 2026-04-26 | +| **実行時間** | 約 8 分 | +| **背景** | Codex 3 ラウンド + Amazon Q 5 件 全対応済みの状態で、未指摘観点を中心に依頼 | +| **結論** | **Approve** | +| **指摘** | Critical: 0 / Major: 4 (全て賛辞) / Minor: 3 (うち実質的な指摘 1) | +| **対応 commit** | [`19b0efb`](https://github.com/takemi-ohama/ai-plugins/commit/19b0efb) — Minor 5.1 (storage_state origin 跨ぎ) を予防修正 | +| **PR コメント** | https://github.com/takemi-ohama/ai-plugins/pull/57#issuecomment-4321848186 | + +--- + +# PR #57 Gemini 独立レビュー + +## 総評 +**結論: Approve** + +本 PR は `playwright-scenario-test` を独自の YAML DSL から純粋な `pytest-playwright` プラグインへと見事に移行させています。単なるライブラリの置き換えにとどまらず、`pytest` のエコシステム(`fixture`, `marker`, `hook`, `pytester`)を深く理解した、極めて完成度の高い実装です。特に、xdist 並列実行時の衝突回避、認証キャッシュ、HAR ライフサイクルの考慮、そして非 UI テストへの影響を最小限にする設計など、シニアエンジニアらしい配慮が随所に見られます。 + +## 1. pytest-playwright 慣用性 (Idiomatic) に関する指摘 +### 1.1 `autouse` フィクスチャの条件付きスキップ [重要度: Major] +- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/scenario_test/fixtures/a11y.py:59` +- 状況: `_ndf_a11y_autouse` が `autouse=True` ですが、内部で `if "page" not in request.fixturenames: return` を行っています。 +- 評価: これは非常に優れた設計です。`autouse=True` なフィクスチャが不用意に `page` を要求すると、全てのテスト(DB テストや単体テスト等)でブラウザが起動してしまいます。`request.fixturenames` を確認することで、Playwright を使うテストのみに計測を限定し、テストスイート全体の速度低下を防いでいます。`cwv.py` でも同様の処理が行われており、一貫性があります。 + +### 1.2 `browser_context_args` による HAR/Trace の個別制御 [重要度: Major] +- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/scenario_test/fixtures/evidence.py:165` +- 状況: `browser_context_args` を `function` スコープで override し、テストごとに `record_har_path` を動的に注入しています。 +- 評価: `pytest-playwright` の機能を活かした最も慣用的な方法です。旧実装の session 単位 HAR を廃止し、テストごとの隔離(Isolation)を担保している点は、デバッグの容易性を大きく向上させます。 + +## 2. 可読性に関する指摘 +### 2.1 独自マーカーのドキュメント化 [重要度: Minor] +- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/scenario_test/pytest_plugin.py:75` +- 指摘: `_NDF_MARKERS` を定義し、`pytest_configure` で `config.addinivalue_line("markers", ...)` を行っています。 +- 評価: これにより `pytest --markers` を実行した際に NDF 独自のマーカー(`page_role`, `role`, `phase`, `priority`)の説明が表示されるようになり、利用者にとってのセルフドキュメンテーションが効いています。 + +## 3. コード品質に関する指摘 +### 3.1 エラーメッセージの切り詰め [重要度: Minor] +- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/scenario_test/pytest_report.py:114` +- 状況: `lines.append(e.error_message[:2000])` +- 評価: 長大なスタックトレースやログによって `report.md` が肥大化するのを防ぐ適切な配慮です。 + +### 3.2 ログイン送信ボタンのフォールバック順序 [重要度: Major] +- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/scenario_test/fixtures/auth.py:53` +- 評価: `_submit_login_form` において、明示的なセレクタ → 汎用的な role/text マッチ → パスワード欄での Enter キー、という順序で試行しています。この「最後に Enter を押す」というフォールバックは、ボタンの特定が困難な難解な UI においてもログインを成功させる可能性を高める、実戦的な工夫です。 + +## 4. 保守性に関する指摘 +### 4.1 テンプレートの充実 [重要度: Major] +- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/templates/` +- 評価: `conftest.py` だけでなく、`test_auth.py.template` などの役割別テンプレートが用意されており、利用者が迷わずに導入できるようになっています。特に `SKILL.md` のクイックスタートが「雛形をコピーして動かす」という手順で完結しており、OSS 的な使い勝手が非常に良いです。 + +## 5. セキュリティに関する指摘 +### 5.1 `localStorage` 注入時の origin 跨ぎ [重要度: Minor] +- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/scenario_test/fixtures/auth.py:192` +- 状況: `state.get("origins")` をループして `page.goto(url)` してから `localStorage.setItem` を実行しています。 +- 懸念: 外部のオリジン(広告や計測タグ等)が `storage_state` に含まれている場合、それらに対しても `goto` が試行されます。 +- 評価: ただし、`try...except` で囲まれており、失敗しても認証自体(Cookie)には影響しないようになっているため、実用上の問題はありません。 + +## 6. テストカバレッジに関する指摘 +### 6.1 `pytester` による高度な検証 [重要度: Major] +- ファイル: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/tests/test_pytester_integration.py` +- 評価: `pytest` の内部挙動に依存する「HAR の teardown 時フラッシュ」や「xdist 時の開始時刻計算」を、`pytester` を用いて隔離環境で本物の pytest を走らせて検証している点は非常に高く評価できます。これにより、プラグインとしての堅牢性が担保されています。 + +## 7. 良い点 (Codex / Amazon Q が触れていない点を中心に) +- **`NdfTestEntry` の `ok` プロパティの定義**: `xfailed` を `ok=True` としつつ、`all_pass` の判定では `xpassed` (意図せず通ってしまったテスト) がある場合に `False` とするロジックは、品質管理の観点から非常に正確です。 +- **`HUD_INIT_SCRIPT` の `sessionStorage` 連携**: ナビゲーションを跨いでもカーソル位置や字幕を維持するための JS 実装が丁寧です。 + +## 8. 統計 +- Critical: 0 件 +- Major: 4 件 (1.1, 1.2, 3.2, 4.1, 6.1 - 全て「高く評価」) +- Minor: 3 件 (2.1, 3.1, 5.1) +- 合計: 7 件 + +## 9. 承認可否 +- 結論: **Approve** +- 根拠: `pytest-playwright` への移行が、単なる機能移行に留まらず、開発者の生産性とテストの信頼性を大幅に向上させるレベルで完遂されています。実装の細部(特に fixture の依存関係とライフサイクルの管理)にまでこだわりが感じられ、即座にマージして OSS 配布可能な品質に達していると判断します。 diff --git a/docs/id_rsa.pub b/docs/id_rsa.pub new file mode 100644 index 00000000..115f0b9a --- /dev/null +++ b/docs/id_rsa.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA0SJOOkxgbjr9HIFNdiRzn7P0STZMAU1i8/mivpCBRPSMhN8w99zrELpWorAEyyQWAE+2AeHeTkXU3jxLyvpmQbI14MechEvTNfieQWUa3GdPner3N69ysW4gTkTqAKFMWEeELXHXH2yJkN8vhyaRqS9iiut7R8jMXzU+898Oizbi0Ty6uALqNT/X9kPP915JYw+ZaDwZ6skAK4udsr//z+hlAG1ilhQy0cwed1hjvZ1HXxjjorjyWvxFL0xDM/ow7W+pmfV/8TlWttIisYMm5227fk8gLZ1Xo0O+1NrFkDr9Sg0KAFqzw7JCD7Oq4ryXao3n9dfaAL6u+5j0rsnWDQ== hammer diff --git a/docs/ndf-plugin-reference.md b/docs/ndf-plugin-reference.md index 23eddf66..2ea433d8 100644 --- a/docs/ndf-plugin-reference.md +++ b/docs/ndf-plugin-reference.md @@ -2,7 +2,9 @@ ## 概要 -NDFプラグインは、3つの既存プラグイン(mcp-integration、install-slack-hook、workflow-commands)を統合したオールインワンプラグインです。 +NDF プラグインは、Claude Code / Kiro CLI 向けのオールインワン開発支援プラグイン。エージェント、Skills、フックを統合して提供する。 + +**現行バージョン**: **v4.7.0** — `/ndf:fix` の修正ポリシー刷新(minor/nit のうち performance/readability/duplication は積極修正、+30 行超は要問い合わせ)、CI 完了待ち廃止、PR範囲外 flaky テストも修正対象。`/ndf:cross-review` 内のサブエージェントプロンプトも同期。重要度ラベルは AI agent の付与を鵜呑みにせず独自再判定。完了報告には PR URL 必須。詳細は [CHANGELOG.md](../plugins/ndf/CHANGELOG.md)。`/ndf:codex` skill + `corder` エージェント経由の Codex CLI 直接実行に一本化、Serena MCP は別プラグイン `mcp-serena` に分離済み、Playwright シナリオ E2E、Google Drive / Chat 連携 skill を提供。 ## ディレクトリ構造 @@ -10,103 +12,166 @@ NDFプラグインは、3つの既存プラグイン(mcp-integration、install plugins/ndf/ ├── .claude-plugin/ │ └── plugin.json # プラグインメタデータ -├── .mcp.json # MCPサーバー定義 ├── hooks/ -│ └── hooks.json # Slack通知フック +│ └── hooks.json # SessionStart (保持期間管理) / Stop (Slack通知) ├── scripts/ -│ └── slack-notify.sh # Slack通知スクリプト -├── commands/ # スラッシュコマンド(6個) -├── agents/ # 専門エージェント(6個) -├── skills/ # Skills(23個) -├── CLAUDE.ndf.md # プラグイン利用者向けガイドライン -└── README.md # 統合ドキュメント +│ ├── ensure-retention.sh # cleanupPeriodDays >= 90 を保つ +│ └── slack-notify.js # Slack通知スクリプト +├── agents/ # 専門エージェント(8個) +├── skills/ # Skills(36個) +├── CLAUDE.md # プラグイン開発者向けガイド +└── README.md # 利用者向けドキュメント ``` ## 機能 -### 1. MCPサーバー - -**注意**: GitHub MCP, Serena MCP, Context7 MCPは公式プラグインに移行済み。 - -NDF固有のMCPサーバー: -- **notion** (HTTP) - Notion統合 -- **awslabs.aws-documentation-mcp-server** (stdio) - AWS公式ドキュメント -- **mcp-server-bigquery** (stdio) - BigQuery統合 -- **dbhub** (stdio) - ユニバーサルデータベースゲートウェイ -- **chrome-devtools-mcp** (stdio) - Chromeブラウザ自動化 -- **codex** (stdio) - AIコードレビュー - -### 2. 開発ワークフローコマンド(6個) - -- `/ndf:serena` - 開発記憶の記録 -- `/ndf:pr` - PR作成 -- `/ndf:fix` - PR修正対応 -- `/ndf:review` - PRレビュー -- `/ndf:merge` - マージ後クリーンアップ -- `/ndf:clean` - ブランチクリーンアップ - -### 3. 専門エージェント(6個) - -| エージェント | 役割 | -|-------------|------| -| **director** | タスク統括・設計立案(オーケストレーター) | -| **data-analyst** | データ分析・SQL操作 | -| **corder** | 高品質コード生成 | -| **researcher** | 情報収集・分析 | -| **scanner** | ファイル読み取り(PDF、画像、Office) | -| **qa** | 品質管理・テスト | - -### 4. Slack通知 - -Stopフックで自動実行。AIが作業内容を40文字以内の日本語で要約してSlackに通知。 - -**通知メカニズム:** -1. メンション付き投稿(通知音) -2. メッセージ削除 -3. メンションなし再投稿(クリーンな履歴) +### 1. MCP サーバー + +NDF プラグイン本体はコア MCP サーバを**同梱しない**(v4.0.0 で Codex MCP を廃止)。関連 MCP は個別プラグインとして提供: + +| MCP | 提供プラグイン | 用途 | +|---|---|---| +| Serena MCP | `mcp-serena` | セマンティックコード操作 | +| GitHub MCP | Anthropic 公式 | GitHub 操作 | +| Context7 MCP | Anthropic 公式 | 最新ライブラリドキュメント | +| Chrome DevTools MCP | `mcp-chrome-devtools` | ブラウザ自動化・パフォーマンス | +| BigQuery MCP | `mcp-bigquery` | BigQuery データ分析 | +| AWS Docs MCP | `mcp-aws-docs` | AWS 公式ドキュメント | +| DBHub MCP | `mcp-dbhub` | 汎用データベース | +| Notion MCP | `mcp-notion` | Notion 連携 | + +### 2. ワークフロー Skills(スラッシュコマンド) + +`/ndf:` でユーザーから直接起動する Skill 群: + +| Skill | 用途 | +|---|---| +| `/ndf:pr` | commit+push+PR 作成 / 既存 PR 説明更新 | +| `/ndf:pr-tests` | PR の Test Plan を自動実行 | +| `/ndf:fix` | レビューコメントの修正対応 | +| `/ndf:review` | PR を Approve/Request Changes 判定 | +| `/ndf:review-branch` | PR 前のローカル差分レビュー | +| `/ndf:review-pr-comments` | PR コメントの分類 (READ-ONLY) | +| `/ndf:resolve-pr-comments` | 対応済みコメント返信+Resolve | +| `/ndf:cherry-pick-pr` | 環境ブランチへの cherry-pick PR | +| `/ndf:deploy` | 環境ブランチへのデプロイ PR | +| `/ndf:sync-main` | main を現ブランチに取り込み | +| `/ndf:merged` | マージ後のクリーンアップ | +| `/ndf:clean` | マージ済みブランチ一括削除 | +| `/ndf:browser-test` | Playwright/Chrome DevTools での動作確認 | +| `/ndf:skill-stats` | Skill 利用統計の集計(期間/プロジェクト別) | + +### 3. 原則・ガイドライン Skills(モデル起動型) + +該当文脈で自動的に参照される Skill 群: + +| Skill | 対象領域 | +|---|---| +| `ndf-policies` | プラグイン共通ポリシー(常時注入) | +| `branch-fix-strategy` | 複数ブランチ適用戦略 (cherry-pick) | +| `implementation-plan` | `issues/` 配下の実装プラン管理 | +| `investigation-rules` | 調査時のエビデンス主義 | +| `problem-solving` | 根本原因分析・多層防御 | +| `logging-guidelines` | ログ運用 (言語非依存) | +| `markdown-writing` | Markdown 文書の体裁 | + +### 4. 補助 Skills + +| Skill | 用途 | +|---|---| +| `data-analyst-sql-optimization` | SQL 最適化パターン | +| `data-analyst-export` | CSV/JSON/Excel 出力 | +| `qa-security-scan` | OWASP Top 10 チェック | +| `python-execution` | Python 実行環境の自動判定 | +| `docker-container-access` | Docker コンテナ接続判定 | +| `git-gh-operations` | git/gh 操作パターン | +| `google-auth` | Google API OAuth2 | +| `codex` | Codex CLI 直接実行ガイド | +| `deepwiki-transfer` | DeepWiki 知識転送 | +| `knowledge-reorg` | 知識再編成 | +| `mcp-builder` | MCP サーバ作成(Anthropic 公式) | +| `official-skills-autoloader` | Anthropic 公式 Skill の自動ロード | + +### 5. 専門エージェント(8個、モデル階層化) + +| エージェント | モデル | 役割 | +|-------------|------|------| +| **director** | opus | タスク統括・設計立案 | +| **corder** | sonnet | Codex CLI 経由の独立レビュー・大規模調査 | +| **data-analyst** | sonnet | データ分析・SQL | +| **researcher** | sonnet | AWS Docs / Chrome DevTools 調査 | +| **qa** | sonnet | 品質・セキュリティ検証 | +| **debugger** | sonnet | 根本原因分析 | +| **devops-engineer** | sonnet | Docker/CI/CD/K8s | +| **code-reviewer** | sonnet | git diff/PR レビュー(Codex 非使用) | + +### 6. 自動フック + +| イベント | 用途 | +|---|---| +| `SessionStart` (matcher: `startup`) | `~/.claude/settings.json` の `cleanupPeriodDays` を最低 90 日に保つ (7日タイムスタンプガード + flock でアトミック更新) | +| `Stop` | AI 要約を生成して Slack に通知 (`SLACK_BOT_TOKEN` 設定時のみ) | ## 環境変数 -**必須:** -- `GITHUB_PERSONAL_ACCESS_TOKEN` - GitHub MCP用 +### Slack 通知(推奨) +- `SLACK_BOT_TOKEN` — Bot User OAuth Token (`xoxb-...`) +- `SLACK_CHANNEL_ID` — 通知先チャンネル (`C...`) +- `SLACK_USER_MENTION` — メンション対象ユーザー (`<@U...>`) -**オプション:** -- `NOTION_API_KEY` - Notion MCP用 -- `GOOGLE_APPLICATION_CREDENTIALS` - BigQuery MCP用 -- `DATABASE_DSN` - DBHub MCP用 -- `SLACK_BOT_TOKEN` - Slack通知用(Bot User OAuth Token) -- `SLACK_CHANNEL_ID` - Slack通知送信先チャンネルID -- `SLACK_USER_MENTION` - Slackメンション対象ユーザーID +### Codex CLI(`/ndf:codex` / `corder` エージェント利用時) +- `CODEX_HOME` — Codex CLI のホーム (default: `~/.codex`) +- `OPENAI_API_KEY` — `codex login` 済みなら不要 + +### 個別 MCP プラグイン(利用する場合) +各プラグイン README を参照。 ## 実装上の知見 -### Stop Hook無限ループ防止 +### Stop Hook 無限ループ防止 -Stop hookスクリプト内でClaude CLIを呼び出す際は、`--settings`で無限ループを防止: +Stop hook 内で Claude CLI を呼び出す際は `--settings` で hooks と plugins を両方無効化する: ```bash claude -p --settings '{"disableAllHooks": true, "disableAllPlugins": true}' --output-format text ``` -**試行錯誤の結果:** -- `CLAUDE_DISABLE_HOOKS`環境変数 → 存在しない -- `stop_hook_active`フィールド → 実際には送信されない -- `--settings`でhooksとplugins両方を無効化 → 確実に動作 +- `CLAUDE_DISABLE_HOOKS` 環境変数 → 存在しない +- `stop_hook_active` フィールド → 実際には送信されない +- `--settings` で両方無効化 → 確実に動作 ### 要約生成の3段階フォールバック -1. **Claude CLI**(優先)- AIによる高品質な要約 -2. **transcript解析**(フォールバック1)- セッションログから抽出 -3. **git diff**(フォールバック2)- ファイル変更から推測 +1. **Claude CLI**(優先) — AI による高品質要約 +2. **transcript 解析**(フォールバック1) — セッションログから抽出 +3. **git diff**(フォールバック2) — ファイル変更から推測 + +### 保持期間管理の実装 + +`SessionStart` hook で `~/.claude/settings.json` の `cleanupPeriodDays` を検査し、90 未満なら 90 に更新する: + +- 実行は `~/.claude/.ndf-retention-checked` の 7 日タイムスタンプで抑止 +- 書き込みは `flock` で排他ロックし、並列セッションでの lost update を防止 (flock 不在環境は atomic rename に依存) +- Claude Code の公開 API には「プラグインインストール時」hook が存在しないため、`SessionStart + startup` matcher が実用上の最適解 + +### Codex の扱い(v4.0.0) + +- Codex MCP サーバは廃止 +- `/ndf:codex` skill に CLI 直接実行の詳細手順(サンドボックス、プロンプト設計、バックグラウンド実行、stderr/stdout 回収)を記載 +- `corder` エージェントは本 skill を参照して `codex exec` を呼び出す -### バージョン変遷 +### バージョン変遷(抜粋) | バージョン | 主な変更 | |-----------|---------| | v1.0.0 | 初期リリース | -| v1.0.1 | 3プラグインを統合 | -| v1.0.6 | directorエージェント追加 | -| v1.2.0 | Skills導入(10個) | -| v2.0.0 | 公式プラグインとの重複解消(MCP 10→7) | -| v2.1.0 | directorエージェント再定義、Slack通知簡略化 | -| v3.0.0 | CLAUDE.ndf.md廃止、Serena MCP分離、3層構造移行 | +| v2.0.0 | 公式プラグインへの MCP 重複解消 | +| v2.6.0 | NDF コア MCP 最小化 (Serena + Codex) | +| v2.7.0 | commands → skills 統合 (Claude Code 2.1.3 対応) | +| v3.0.0 | Serena MCP 分離 (`mcp-serena`)、memory 系 Skill 廃止、CLAUDE.ndf.md 注入廃止 | +| v3.1.0 | Kiro CLI 対応、`google-auth` skill 追加 | +| v3.5.0 | Agent/Skill 再編、モデル階層化、公式 Skill 連携 | +| v3.6.0 | 汎用 skill 13 個追加 (原則系・PR ワークフロー系・codex) | +| v3.7.0 | transcript 保持期間自動管理 hook、`/ndf:skill-stats` skill | +| **v4.0.0 (BREAKING)** | **Codex MCP 廃止 → CLI 直接実行一本化**、レガシー CLAUDE.ndf.md 救済機構削除、skill-stats にプロジェクト別/日付範囲フィルタ追加 | +| **v4.1.0** | `playwright-scenario-test` / `google-drive` / `google-chat` skill 追加、`google-auth` v0.2.0 (永続トークン `~/.config/gcloud/google_token.json` + `get_credentials()` API + 手動 copy-paste フロー) | diff --git a/docs/official-skills-installation.md b/docs/official-skills-installation.md new file mode 100644 index 00000000..2873d7dc --- /dev/null +++ b/docs/official-skills-installation.md @@ -0,0 +1,134 @@ +# Anthropic公式Skillsのインストール手順 + +Anthropicは `anthropics/skills` リポジトリで高品質なSkillを公開しています。本ドキュメントはNDFプラグイン利用者向けに、**自動インストーラ** と **手動インストール** の両方を案内します。 + +調査日: 2026-04-23 +参照: https://github.com/anthropics/skills + +## クイックスタート(推奨) + +NDFプラグイン同梱のインストーラを使う方法: + +```bash +# 利用可能Skill一覧を表示(ライセンス分類付き) +bash plugins/ndf/scripts/install-official-skills.sh --list + +# 必要なSkillをユーザー領域(~/.claude/skills/)にインストール +bash plugins/ndf/scripts/install-official-skills.sh docx pptx xlsx + +# プロジェクト .claude/skills/ に配置する場合 +bash plugins/ndf/scripts/install-official-skills.sh --scope project pdf + +# 全Skillをインストール +bash plugins/ndf/scripts/install-official-skills.sh --all + +# 公式リポジトリを最新化 +bash plugins/ndf/scripts/install-official-skills.sh --update +``` + +### インストーラの動作 + +1. 公式リポジトリを `~/.cache/anthropic-skills/` にshallow clone(初回のみ) +2. 指定Skillのディレクトリへ **シンボリックリンク** を作成 +3. 重複時はユーザー確認後に上書き + +リンク方式のため、`--update` で最新化すると全Skillが同時に追従します。 + +## ライセンス別の分類(2026-04-23時点) + +**Skill単位でライセンスが異なる**ため、必ず各Skillの `LICENSE.txt` を確認してください。`--list` オプションでも表示されます。 + +### Apache License 2.0(再配布・派生物OK) + +| Skill | 用途 | +|---|---| +| `mcp-builder` | MCPサーバー雛形生成(Python FastMCP / Node TypeScript SDK) | +| `skill-creator` | 新規Skill作成ガイド | +| `frontend-design` | フロントエンド設計 | +| `webapp-testing` | Playwrightでのテスト | +| `web-artifacts-builder` | HTML/Reactアプリ生成 | +| `claude-api` | Claude API / SDK開発 | +| `theme-factory` | テーマ生成 | +| `canvas-design` | キャンバスデザイン | +| `algorithmic-art` | アルゴリズム系アート生成 | +| `brand-guidelines` | ブランドガイド | +| `internal-comms` | 社内コミュニケーション | +| `slack-gif-creator` | Slack GIF作成 | + +### プロプライエタリ(再配布・派生物禁止) + +| Skill | 用途 | +|---|---| +| `docx` | Word文書の作成・編集 | +| `pptx` | PowerPoint作成・編集 | +| `xlsx` | Excel作成・編集 | +| `pdf` | PDF処理(読取・生成) | + +> **ライセンス表記例**(docx/pptx/xlsx/pdfに含まれる `LICENSE.txt`): +> 「© 2025 Anthropic, PBC. All rights reserved. これらの資料をAnthropicのサービスから抽出または外部に保持すること、複製、派生物作成を禁止する」 + +### ライセンス未指定 + +- `doc-coauthoring`: `LICENSE.txt` なし → 利用前にリポジトリ側で確認推奨 + +## 手動インストール + +インストーラを使わずに個別管理したい場合: + +### 方法1: ユーザー領域(全プロジェクトで有効) + +```bash +# 公式リポジトリをclone +git clone https://github.com/anthropics/skills.git ~/work/anthropic-skills + +# 必要なSkillをシンボリックリンク +mkdir -p ~/.claude/skills +ln -s ~/work/anthropic-skills/skills/docx ~/.claude/skills/docx +ln -s ~/work/anthropic-skills/skills/pptx ~/.claude/skills/pptx +ln -s ~/work/anthropic-skills/skills/xlsx ~/.claude/skills/xlsx +``` + +### 方法2: プロジェクト単位 + +```bash +cd /path/to/your/project +mkdir -p .claude/skills +cp -r ~/work/anthropic-skills/skills/docx .claude/skills/ +``` + +## 規約遵守の注意点 + +- **プロプライエタリSkillは再配布不可**: リポジトリ(社内含む)に含めない、CIの自動セットアップで公式skillsをclone→コピーする構成も規約違反のおそれ +- **Claude Code / Claude.ai 上での利用に限定**: 他製品への取り込み、派生物作成、Anthropicサービス外での保持は禁止 +- **各自の環境への一時的インストール**は規約上問題なし(`~/.claude/skills/` や `.claude/skills/` へのシンボリックリンク) +- ビルド成果物として配布する場合は必ずライセンスを再確認 + +## NDFプラグインとの関係 + +### NDFが同梱している公式Skill(Apache-2.0のみ) + +- `mcp-builder` → `plugins/ndf/skills/mcp-builder/`(LICENSE.txt同梱) + +### NDFが提供するインストーラ + +- `plugins/ndf/scripts/install-official-skills.sh` → Apache-2.0 / プロプライエタリいずれも個人利用者環境にインストール可能 + +### NDFに独自実装を作らない方針 + +以下は公式Skillの利用を推奨し、NDFには独自版を作りません: +- `docx` / `pptx` / `xlsx` / `pdf`: インストーラで個別配置 +- その他プロプライエタリSkill: 同上 + +## Claude Code側での認識確認 + +インストール後、以下で確認: + +- Claude Codeを再起動 or `/plugin reload` +- `Skill.md` のYAMLフロントマターで自動検出される(スラッシュコマンド `/docx` 等) + +## 参考 + +- 公式リポジトリ: https://github.com/anthropics/skills +- Skills公式ドキュメント: https://docs.claude.com/en/docs/claude-code/skills +- NDFインストーラ: `plugins/ndf/scripts/install-official-skills.sh` +- Skillフロントマター仕様: [docs/claude-code-skills-official-reference.md](./claude-code-skills-official-reference.md) diff --git a/docs/project-overview.md b/docs/project-overview.md index 06923deb..bbed21e6 100644 --- a/docs/project-overview.md +++ b/docs/project-overview.md @@ -7,9 +7,9 @@ Claude Codeプラグインマーケットプレイス(内部用)として、 ## リポジトリ情報 - **リポジトリ名**: ai-plugins -- **オーナー**: devbasex +- **オーナー**: takemi-ohama - **ライセンス**: MIT -- **URL**: https://github.com/devbasex/ai-plugins +- **URL**: https://github.com/takemi-ohama/ai-plugins ## 配布コンポーネント @@ -39,7 +39,7 @@ ai-plugins/ ### マーケットプレイスの追加 ```bash -/plugin marketplace add https://github.com/devbasex/ai-plugins +/plugin marketplace add https://github.com/takemi-ohama/ai-plugins ``` ### プラグインのインストール diff --git a/issues/PLAN09.md b/issues/PLAN09.md new file mode 100644 index 00000000..2482474f --- /dev/null +++ b/issues/PLAN09.md @@ -0,0 +1,265 @@ +# スキル分割改修計画 + +## 背景 + +Progressive Disclosure(段階的読み込み)の目的は**コンテキスト節約**。 +しかし、分割が適切でないと逆効果になる。 + +## 評価基準 + +| 基準 | 説明 | +|-----|------| +| **有効呼び出し回数** | 多くのセッションで使われるか | +| **読み込みファイル数でミッション完結** | SKILL.mdだけで大半のケースが完結できるか | +| **各ファイルサイズ** | 適切なサイズか(大きすぎも小さすぎもNG) | + +**重要**: ミッション達成のために結局全部読む必要があるなら、分割はコンテキスト節約に寄与しない。 + +--- + +## 現状評価 + +### 評価マトリックス + +| スキル | 呼び出し頻度 | SKILL.mdで完結? | 分割評価 | 状態 | +|-------|-------------|----------------|---------|------| +| python-execution | 高 | ○(uvインストールコマンド追加) | 適切 | ✅改善済 | +| docker-container-access | 中 | ○(DinD/DooDで読み分け可能) | 適切 | - | +| skill-development | 低 | △(詳細は必ず読む) | 検討 | P3 | +| qa-security-scan | 低 | ○(概要テーブルでクイックスキャン可能) | 適切 | ✅再評価済 | +| corder-code-templates | 中 | ○(API/コンポーネントで読み分け) | 適切 | - | +| corder-test-generation | 中 | ○(AAA+基本例がSKILL.mdに統合済) | 適切 | ✅再評価済 | +| data-analyst-export | 中 | ○(形式別に読み分け可能) | 適切 | - | +| data-analyst-sql-optimization | 中 | ○(レガシーファイル削除済) | 適切 | ✅改善済 | +| researcher-report-templates | 低 | △(テンプレート確認は毎回必要) | **要改善** | P3 | +| scanner-pdf-analysis | 中 | ○(基本操作はSKILL.mdで完結) | 適切 | - | +| scanner-excel-extraction | 中 | ○(基本操作はSKILL.mdで完結) | 適切 | - | +| markdown-writing | 中 | ○(基本ルールはSKILL.mdで完結) | 適切 | - | +| memory-handling | 高 | ○(分割なし) | 適切 | - | + +--- + +## 改修対象と方針 + +### 1. python-execution **[要改善]** + +**問題**: +- uvセットアップ手順が`01-environment-detection.md`にある +- 初回セットアップ時に必ず読む必要がある +- しかし、一度セットアップすれば不要 + +**改善方針**: +- SKILL.mdに「uvセットアップ簡易版」を追加(インストールコマンドのみ) +- 01-environment-detection.md → 01-uv-setup.md(初回セットアップ専用) +- 02-troubleshooting.md は維持(問題発生時のみ) + +**新構成**: +``` +python-execution/ +├── SKILL.md # 環境検出+実行+uvインストールコマンド(これだけで95%完結) +├── 01-uv-setup.md # uvの詳細セットアップ(初回のみ) +└── 02-troubleshooting.md # トラブル時のみ +``` + +--- + +### 2. qa-security-scan **[要改善]** + +**問題**: +- セキュリティスキャン実行時、ほぼ全チェックリストが必要 +- 分割しても結局全部読むなら意味がない + +**改善方針**: +- **分割をやめる** - 全内容をSKILL.mdに統合 +- または、**用途別に分割**: + - 01-quick-scan.md: クイックスキャン(主要5項目) + - 02-full-scan.md: フルスキャン(OWASP Top 10全項目) + - 03-report-template.md: レポートテンプレート(レポート作成時のみ) + +**新構成(用途別分割案)**: +``` +qa-security-scan/ +├── SKILL.md # 概要+クイックスキャン(主要5項目、これで70%対応) +├── 01-full-owasp.md # OWASP Top 10全項目(フル監査時のみ) +└── 02-report-template.md # レポート作成時のみ +``` + +--- + +### 3. corder-test-generation **[要改善]** + +**問題**: +- テスト生成時、パターンと例の両方を参照することが多い +- 分割しても結局両方読む + +**改善方針**: +- SKILL.mdに主要パターン(AAA)と基本例を統合 +- 詳細なエッジケース集のみ別ファイル + +**新構成**: +``` +corder-test-generation/ +├── SKILL.md # AAA+主要パターン+基本例(これで80%完結) +└── 01-edge-cases.md # 詳細なエッジケース集(特殊ケース時のみ) +``` + +--- + +### 4. data-analyst-sql-optimization **[要改善]** + +**問題**: +- レガシーファイル(examples.md, reference.md)が残っている +- パターン確認は毎回必要 + +**改善方針**: +- レガシーファイルを削除 +- SKILL.mdに主要パターン(N+1、インデックス、JOINの3つ)を統合 +- 詳細パターンのみ別ファイル + +**新構成**: +``` +data-analyst-sql-optimization/ +├── SKILL.md # 主要3パターン+Before/After例(これで80%完結) +└── 01-advanced-patterns.md # サブクエリ、ウィンドウ関数等(特殊ケース時のみ) +``` + +--- + +### 5. researcher-report-templates **[要改善]** + +**問題**: +- レポート作成時、テンプレートと例の両方が必要 +- 分割しても結局両方読む + +**改善方針**: +- SKILL.mdにメインテンプレート(調査レポート)を統合 +- 技術比較テンプレートのみ別ファイル + +**新構成**: +``` +researcher-report-templates/ +├── SKILL.md # 調査レポートテンプレート+例(これで70%完結) +└── 01-comparison-template.md # 技術比較専用(比較レポート時のみ) +``` + +--- + +### 6. skill-development **[検討]** + +**問題**: +- スキル開発時は詳細を読む必要がある +- しかし、呼び出し頻度が低いため影響は小さい + +**方針**: +- 現状維持(低頻度のため優先度低) +- 必要であれば後で改善 + +--- + +## 適切と判断したスキル(変更不要) + +| スキル | 理由 | +|-------|------| +| docker-container-access | DinD環境ならSKILL.mdで完結、DooD時のみ02-dood-access.md | +| corder-code-templates | API作成時は01、コンポーネント作成時は02と読み分け可能 | +| data-analyst-export | CSV/JSON/Excel/Markdownと用途別に読み分け可能 | +| scanner-pdf-analysis | 基本操作はSKILL.mdで完結、高度な例のみ別ファイル | +| scanner-excel-extraction | 基本操作はSKILL.mdで完結、高度な例のみ別ファイル | +| markdown-writing | 基本ルールはSKILL.mdで完結、図表詳細のみ別ファイル | +| memory-handling | 分割なし、87行で適切 | + +--- + +## 実装優先順位 + +| 優先度 | スキル | 理由 | +|-------|-------|------| +| **P1** | python-execution | 高頻度呼び出し、改善効果大 | +| **P1** | qa-security-scan | 構造的問題あり | +| **P2** | corder-test-generation | 中頻度、改善効果中 | +| **P2** | data-analyst-sql-optimization | 中頻度、レガシー削除必要 | +| **P3** | researcher-report-templates | 低頻度 | +| **P3** | skill-development | 低頻度 | + +--- + +## 改修手順 + +### Phase 1: P1スキル改修 ✅ 完了 + +1. ✅ python-execution の再構成 + - SKILL.mdにuvインストールコマンドを追加(95%のケースで完結) + - 01-environment-detection.md → 01-uv-setup.md にリネーム + - 02-troubleshooting.md は維持 + +2. ✅ qa-security-scan の再評価 + - 再評価結果: **現状維持が適切** + - 理由: SKILL.mdの概要テーブルでクイックスキャン可能、フル監査時のみ詳細ファイル参照 + +3. ✅ 動作確認 + +### Phase 2: P2スキル改修 ✅ 完了 + +1. ✅ corder-test-generation の再評価 + - 再評価結果: **現状維持が適切** + - 理由: SKILL.md(74行)でAAAパターン+基本例が完結 + +2. ✅ data-analyst-sql-optimization の再構成 + - レガシーファイル削除: examples.md, reference.md + - SKILL.md(56行)に主要パターンが統合済み + +3. ✅ 動作確認 + +### Phase 3: P3スキル改修(オプション) +1. researcher-report-templates の再構成 +2. skill-development の検討 + +--- + +## 成功指標 + +| 指標 | 目標 | +|-----|------| +| SKILL.mdでのミッション完結率 | 80%以上 | +| 平均読み込みファイル数 | 1.5以下 | +| 各ファイルサイズ | 50-150行 | + +--- + +## 完了サマリー + +### 実施内容(2026-02-05) + +| スキル | 実施内容 | 結果 | +|-------|---------|------| +| python-execution | SKILL.mdにuvインストールコマンド追加、01ファイルリネーム | 95%のケースでSKILL.mdのみで完結 | +| qa-security-scan | 再評価 | 現状維持(概要テーブルでクイックスキャン可能) | +| corder-test-generation | 再評価 | 現状維持(74行でAAA+基本例完結) | +| data-analyst-sql-optimization | レガシーファイル削除(examples.md, reference.md) | 56行で主要パターン完結 | + +### ファイルサイズ確認 + +| スキル | SKILL.md | 補助ファイル | +|-------|----------|-------------| +| python-execution | 93行 | 01: 85行, 02: 112行 | +| qa-security-scan | 64行 | - | +| corder-test-generation | 74行 | - | +| data-analyst-sql-optimization | 56行 | 01: patterns, 02: examples | + +### 成功指標達成状況 + +| 指標 | 目標 | 結果 | +|-----|------|------| +| SKILL.mdでのミッション完結率 | 80%以上 | ✅ 達成見込み | +| 平均読み込みファイル数 | 1.5以下 | ✅ 達成見込み | +| 各ファイルサイズ | 50-150行 | ✅ 達成 | + +--- + +## 備考 + +- 分割の目的は「コンテキスト節約」 +- 「結局全部読む」分割は無意味 +- 「用途別に読み分けできる」分割が有効 +- 「問題発生時のみ読む」分割が有効 +- 「初回のみ読む」分割が有効 diff --git a/issues/PLAN10/01-analysis.md b/issues/PLAN10/01-analysis.md new file mode 100644 index 00000000..aadaac7b --- /dev/null +++ b/issues/PLAN10/01-analysis.md @@ -0,0 +1,272 @@ +# ai-plugins → Kiro CLI 移植調査結果 + +## 調査日時 +2026-02-05 + +## 1. NDFプラグインの機能調査 + +### 1.1 プラグイン構成 + +**plugin.json**: +- バージョン: 2.5.0 +- 9つのカスタムコマンド +- 6つのサブエージェント +- 13個のスキル +- フック機能(SessionStart、Stop) + +### 1.2 MCPサーバー統合 (.mcp.json) + +| MCPサーバー | タイプ | 用途 | 認証 | +|------------|--------|------|------| +| Serena | stdio | セマンティックコード操作 | GOOGLE_API_KEY, ANTHROPIC_API_KEY | +| Notion | http | Notion統合 | NOTION_TOKEN | +| BigQuery | stdio | BigQueryクエリ | GCPサービスアカウント | +| DBHub | stdio | データベース操作 | DSN | +| Chrome DevTools | stdio | ブラウザ自動化 | なし | +| AWS Docs | stdio | AWS文書検索 | なし | +| Codex CLI | stdio | Codex統合 | Codex認証 | + +### 1.3 カスタムコマンド (commands/) + +| コマンド | 機能 | +|---------|------| +| `/ndf:serena` | Serena MCP操作ガイド | +| `/ndf:pr` | PR作成ワークフロー | +| `/ndf:pr-tests` | Test Plan自動実行 | +| `/ndf:fix` | レビュー指摘修正 | +| `/ndf:review` | コードレビュー | +| `/ndf:merged` | マージ後処理 | +| `/ndf:clean` | ブランチクリーンアップ | +| `/ndf:mem-review` | Memory戦略レビュー | +| `/ndf:mem-capture` | Memory記録 | + +### 1.4 サブエージェント (agents/) + +| エージェント | 役割 | ツール | +|------------|------|--------| +| director | タスク統括・指揮者 | 全ツール | +| data-analyst | データ分析 | BigQuery, DBHub | +| corder | コーディング | GitHub, Serena | +| researcher | 調査 | Web検索, AWS Docs | +| scanner | ファイル読み取り | fs_read, PDF/Excel解析 | +| qa | 品質管理 | セキュリティスキャン | + +### 1.5 スキル (skills/) + +| スキル | 機能 | +|--------|------| +| data-analyst-sql-optimization | SQL最適化 | +| data-analyst-export | データエクスポート | +| corder-code-templates | コードテンプレート | +| corder-test-generation | テスト生成 | +| researcher-report-templates | レポートテンプレート | +| scanner-pdf-analysis | PDF解析 | +| scanner-excel-extraction | Excel抽出 | +| qa-security-scan | セキュリティスキャン | +| markdown-writing | Markdown文書作成 | +| memory-handling | Memory戦略 | +| python-execution | Python実行環境判定 | +| docker-container-access | Dockerコンテナアクセス | +| skill-development | Skill開発ガイド | + +### 1.6 フック (hooks/) + +**SessionStart**: +1. NDF Plugin Guideを`CLAUDE.md`に自動注入 +2. Memory戦略を`.serena/memories`に初期化 + +**Stop**: +- Slack通知(セッション終了時) + +## 2. Claude Code Plugin Marketplace仕様 + +### 2.1 マーケットプレイス構造 + +``` +.claude-plugin/ +└── marketplace.json # マーケットプレイス定義 +``` + +**marketplace.json**: +```json +{ + "name": "marketplace-name", + "owner": { "name": "...", "url": "..." }, + "plugins": [ + { + "name": "plugin-name", + "source": "./plugins/plugin-name" + } + ] +} +``` + +### 2.2 プラグイン構造 + +``` +plugins/{plugin-name}/ +├── .claude-plugin/ +│ └── plugin.json # 必須 +├── commands/ # スラッシュコマンド +├── agents/ # サブエージェント +├── skills/ # プロジェクトスキル +├── hooks/ # フック +└── .mcp.json # MCP設定(オプション) +``` + +### 2.3 主要機能 + +| 機能 | Claude Code | 説明 | +|------|-------------|------| +| Marketplace | ✅ | プラグインカタログ | +| Plugin | ✅ | 拡張パッケージ | +| Commands | ✅ | スラッシュコマンド | +| Agents | ✅ | サブエージェント | +| Skills | ✅ | 自動起動機能 | +| Hooks | ✅ | ライフサイクルフック | +| MCP | ✅ | Model Context Protocol | + +## 3. Kiro CLI類似機能調査 + +### 3.1 Kiro CLIの機能 + +| 機能 | Kiro CLI | 説明 | +|------|----------|------| +| Marketplace | ❌ | なし | +| Plugin | ❌ | なし | +| Commands | ✅ | スラッシュコマンド(組み込み) | +| Agents | ✅ | エージェント設定(JSON) | +| Skills | ❌ | なし | +| Hooks | ✅ | コンテキストフック | +| MCP | ✅ | MCP統合 | + +### 3.2 Kiro CLIの組み込みコマンド + +``` +/quit, /clear, /agent, /chat, /context, /code, /editor, /reply, +/compact, /tools, /issue, /logdump, /changelog, /prompts, /hooks, +/usage, /mcp, /model, /experiment, /plan, /todos, /paste, /help +``` + +### 3.3 Kiro CLIのエージェント + +**エージェント設定ファイル**: `.kiro/agents/{agent-name}.json` + +```json +{ + "description": "エージェントの説明", + "tools": ["fs_read", "fs_write", "execute_bash"], + "allowedTools": ["fs_read"], + "toolsSettings": { + "fs_write": { + "allowedPaths": ["~/projects"] + } + }, + "resources": [ + "file://README.md", + "file://docs/**/*.md" + ] +} +``` + +### 3.4 Kiro CLIのフック + +**フック設定**: エージェント設定内の`hooks`フィールド + +```json +{ + "hooks": { + "agentSpawn": [...], + "userPromptSubmit": [...], + "preToolUse": [...], + "postToolUse": [...], + "stop": [...] + } +} +``` + +### 3.5 Kiro CLIのMCP + +**MCP設定**: `.kiro/mcp.json` + +```json +{ + "mcpServers": { + "server-name": { + "command": "...", + "args": [...], + "env": {...} + } + } +} +``` + +### 3.6 Kiro CLIのプロンプト + +**プロンプト**: `.kiro/prompts/{name}.md` + +- `@research`: コードベース調査 +- `@plan`: 実装計画作成 +- `@implement`: 計画実行 +- `@validate`: 実装検証 +- `@commit`: Git コミット + +## 4. 機能マッピング + +| Claude Code機能 | Kiro CLI相当機能 | 移植可否 | 備考 | +|----------------|-----------------|---------|------| +| Marketplace | なし | ❌ | Kiro CLIに概念なし | +| Plugin | なし | ❌ | Kiro CLIに概念なし | +| Commands | スラッシュコマンド | ⚠️ | 組み込みのみ、カスタム不可 | +| Agents | エージェント設定 | ✅ | JSON設定で実現可能 | +| Skills | なし | ⚠️ | プロンプト+エージェントで代替 | +| Hooks | フック | ✅ | 同等機能あり | +| MCP | MCP | ✅ | 同等機能あり | + +### 4.1 移植戦略 + +#### ✅ 直接移植可能 +- **MCP統合**: `.kiro/mcp.json`に設定 +- **フック**: エージェント設定の`hooks`フィールド +- **エージェント**: `.kiro/agents/`にJSON設定 + +#### ⚠️ 代替実装が必要 +- **カスタムコマンド**: プロンプト(`.kiro/prompts/`)で代替 +- **スキル**: プロンプト+エージェント設定で代替 + +#### ❌ 移植不可 +- **Marketplace**: Kiro CLIに概念なし +- **Plugin**: Kiro CLIに概念なし + +## 5. 結論 + +### 5.1 移植 vs 拡張 + +**結論**: **別リポジトリで移植** + +**理由**: +1. Kiro CLIにはMarketplace/Plugin概念がない +2. 同じリポジトリで管理すると混乱を招く +3. Kiro CLI向けの独自構造が必要 + +### 5.2 新リポジトリ名案 + +- `kiro-ndf-config` +- `ndf-kiro` +- `kiro-ndf-agents` + +### 5.3 移植範囲 + +**Phase 1: コア機能** +- MCP統合(7サーバー) +- 基本エージェント(6種類) +- 基本フック(Slack通知) + +**Phase 2: 拡張機能** +- プロンプト(コマンド代替) +- スキル相当のプロンプト + +**Phase 3: ドキュメント** +- インストールガイド +- 使用方法 +- トラブルシューティング diff --git a/issues/PLAN10/02-migration-plan.md b/issues/PLAN10/02-migration-plan.md new file mode 100644 index 00000000..d71ab831 --- /dev/null +++ b/issues/PLAN10/02-migration-plan.md @@ -0,0 +1,376 @@ +# Kiro CLI移植計画 + +## プロジェクト概要 + +**目的**: Claude Code用NDFプラグインをKiro CLI向けに移植し、同等の開発体験を提供する + +**アプローチ**: 別リポジトリで新規プロジェクトとして移植 + +**新リポジトリ名**: `kiro-ndf-config` + +## Phase 1: プロジェクト基盤構築 + +### 1.1 リポジトリ作成 + +**タスク**: +- [ ] GitHubリポジトリ作成: `kiro-ndf-config` +- [ ] 基本ディレクトリ構造作成 +- [ ] README.md作成 +- [ ] LICENSE追加(MIT) +- [ ] .gitignore設定 + +**ディレクトリ構造**: +``` +kiro-ndf-config/ +├── .kiro/ +│ ├── agents/ # エージェント設定 +│ ├── prompts/ # プロンプト(コマンド代替) +│ └── mcp.json # MCP設定 +├── scripts/ # ヘルパースクリプト +├── docs/ # ドキュメント +├── .env.example # 環境変数テンプレート +├── README.md +└── LICENSE +``` + +### 1.2 ドキュメント作成 + +**タスク**: +- [ ] README.md: プロジェクト概要、インストール手順 +- [ ] INSTALL.md: 詳細インストールガイド +- [ ] USAGE.md: 使用方法 +- [ ] AGENTS.md: エージェント説明 +- [ ] MCP.md: MCP設定ガイド + +## Phase 2: MCP統合 + +### 2.1 MCP設定ファイル作成 + +**タスク**: +- [ ] `.kiro/mcp.json`作成 +- [ ] 7つのMCPサーバー設定を移植: + - [ ] Serena MCP + - [ ] Notion MCP + - [ ] BigQuery MCP + - [ ] DBHub MCP + - [ ] Chrome DevTools MCP + - [ ] AWS Docs MCP + - [ ] Codex CLI MCP + +**設定例**: +```json +{ + "mcpServers": { + "serena": { + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide-assistant", + "--enable-web-dashboard", + "False" + ], + "env": { + "SERENA_HOME": "${SERENA_HOME:-.serena}", + "GOOGLE_API_KEY": "${GOOGLE_API_KEY}", + "ANTHROPIC_API_KEY": "${ANTHROPIC_API_KEY}" + } + } + } +} +``` + +### 2.2 環境変数テンプレート + +**タスク**: +- [ ] `.env.example`作成 +- [ ] 各MCPサーバーの必要な環境変数を記載 +- [ ] コメントで取得方法を説明 + +## Phase 3: エージェント移植 + +### 3.1 基本エージェント作成 + +**タスク**: +- [ ] `.kiro/agents/director.json` - タスク統括 +- [ ] `.kiro/agents/data-analyst.json` - データ分析 +- [ ] `.kiro/agents/corder.json` - コーディング +- [ ] `.kiro/agents/researcher.json` - 調査 +- [ ] `.kiro/agents/scanner.json` - ファイル読み取り +- [ ] `.kiro/agents/qa.json` - 品質管理 + +**エージェント設定例** (director.json): +```json +{ + "description": "タスク統括・指揮者エージェント。複雑なタスクを分解し、適切なサブエージェントに委譲します。", + "tools": ["@builtin", "@serena/*", "@github/*"], + "allowedTools": ["fs_read", "fs_write", "execute_bash", "use_subagent"], + "toolsSettings": { + "execute_bash": { + "autoAllowReadonly": true + } + }, + "resources": [ + "file://README.md", + "file://docs/**/*.md" + ], + "hooks": { + "agentSpawn": [ + { + "command": "echo 'Director agent activated'", + "description": "エージェント起動通知" + } + ] + } +} +``` + +### 3.2 エージェント間連携 + +**タスク**: +- [ ] `use_subagent`ツールの活用方法をドキュメント化 +- [ ] エージェント選択ガイドライン作成 +- [ ] サブエージェント呼び出しパターン例 + +## Phase 4: プロンプト作成(コマンド代替) + +### 4.1 開発ワークフロープロンプト + +**タスク**: +- [ ] `.kiro/prompts/pr.md` - PR作成ワークフロー +- [ ] `.kiro/prompts/pr-tests.md` - Test Plan自動実行 +- [ ] `.kiro/prompts/fix.md` - レビュー指摘修正 +- [ ] `.kiro/prompts/review.md` - コードレビュー +- [ ] `.kiro/prompts/merged.md` - マージ後処理 +- [ ] `.kiro/prompts/clean.md` - ブランチクリーンアップ + +**プロンプト例** (pr.md): +```markdown +--- +name: pr +description: Pull Request作成ワークフロー +--- + +# PR作成ワークフロー + +このプロンプトは、Pull Request作成を支援します。 + +## 手順 + +1. 変更内容の確認 +2. コミットメッセージの作成 +3. PRタイトルと説明の生成 +4. テストの実行確認 + +## 使用方法 + +``` +@pr +``` + +## 実行内容 + +[詳細な手順...] +``` + +### 4.2 Memory管理プロンプト + +**タスク**: +- [ ] `.kiro/prompts/mem-review.md` - Memory戦略レビュー +- [ ] `.kiro/prompts/mem-capture.md` - Memory記録 + +### 4.3 Serena操作プロンプト + +**タスク**: +- [ ] `.kiro/prompts/serena.md` - Serena MCP操作ガイド + +## Phase 5: スキル相当機能 + +### 5.1 スキルプロンプト作成 + +**タスク**: +- [ ] `.kiro/prompts/skills/sql-optimization.md` +- [ ] `.kiro/prompts/skills/code-templates.md` +- [ ] `.kiro/prompts/skills/test-generation.md` +- [ ] `.kiro/prompts/skills/pdf-analysis.md` +- [ ] `.kiro/prompts/skills/excel-extraction.md` +- [ ] `.kiro/prompts/skills/security-scan.md` +- [ ] `.kiro/prompts/skills/markdown-writing.md` +- [ ] `.kiro/prompts/skills/python-execution.md` +- [ ] `.kiro/prompts/skills/docker-access.md` + +**スキルプロンプト例**: +```markdown +--- +name: sql-optimization +description: SQL最適化支援 +--- + +# SQL最適化 + +このプロンプトは、SQLクエリの最適化を支援します。 + +## 最適化パターン + +1. インデックス活用 +2. JOIN最適化 +3. サブクエリ改善 + +[詳細...] +``` + +## Phase 6: フック実装 + +### 6.1 Slack通知フック + +**タスク**: +- [ ] `scripts/slack-notify.sh`作成 +- [ ] エージェント設定に`stop`フック追加 +- [ ] Slack Webhook URL設定ガイド + +**フック設定例**: +```json +{ + "hooks": { + "stop": [ + { + "command": "bash ${KIRO_CONFIG_ROOT}/scripts/slack-notify.sh", + "description": "Slack通知送信" + } + ] + } +} +``` + +### 6.2 セットアップフック + +**タスク**: +- [ ] `scripts/setup-memory.sh`作成 +- [ ] エージェント設定に`agentSpawn`フック追加 + +## Phase 7: インストールスクリプト + +### 7.1 自動セットアップスクリプト + +**タスク**: +- [ ] `scripts/install.sh`作成 +- [ ] 以下の処理を自動化: + - [ ] `.kiro/`ディレクトリのコピー + - [ ] `.env`ファイルの作成(`.env.example`から) + - [ ] MCP設定の確認 + - [ ] 必要なツールのインストール確認 + +**インストールスクリプト例**: +```bash +#!/bin/bash +set -e + +echo "Kiro NDF Config インストール開始..." + +# .kiro/ディレクトリをコピー +cp -r .kiro ~/.kiro/ndf-config + +# .envファイル作成 +if [ ! -f .env ]; then + cp .env.example .env + echo ".envファイルを作成しました。必要な環境変数を設定してください。" +fi + +echo "インストール完了!" +``` + +## Phase 8: ドキュメント整備 + +### 8.1 ユーザーガイド + +**タスク**: +- [ ] `docs/getting-started.md` - 初心者向けガイド +- [ ] `docs/agents-guide.md` - エージェント使用ガイド +- [ ] `docs/prompts-guide.md` - プロンプト使用ガイド +- [ ] `docs/mcp-setup.md` - MCP設定詳細 +- [ ] `docs/troubleshooting.md` - トラブルシューティング + +### 8.2 開発者ガイド + +**タスク**: +- [ ] `docs/development.md` - カスタマイズガイド +- [ ] `docs/contributing.md` - コントリビューションガイド + +## Phase 9: テストとデバッグ + +### 9.1 機能テスト + +**タスク**: +- [ ] 各エージェントの動作確認 +- [ ] MCP統合の動作確認 +- [ ] プロンプトの動作確認 +- [ ] フックの動作確認 + +### 9.2 ドキュメントレビュー + +**タスク**: +- [ ] インストール手順の検証 +- [ ] 使用例の検証 +- [ ] トラブルシューティングの検証 + +## Phase 10: リリース + +### 10.1 バージョン管理 + +**タスク**: +- [ ] CHANGELOG.md作成 +- [ ] バージョン1.0.0リリース +- [ ] GitHubリリースノート作成 + +### 10.2 公開 + +**タスク**: +- [ ] README.mdの最終確認 +- [ ] リポジトリの公開設定 +- [ ] コミュニティへの告知 + +## 成功基準 + +### 機能要件 +- ✅ 7つのMCPサーバーが正常に動作 +- ✅ 6つのエージェントが利用可能 +- ✅ 9つのプロンプト(コマンド代替)が動作 +- ✅ Slack通知フックが動作 + +### ドキュメント要件 +- ✅ インストール手順が明確 +- ✅ 使用方法が理解しやすい +- ✅ トラブルシューティングが充実 + +### ユーザー体験 +- ✅ Claude Code NDFプラグインと同等の機能 +- ✅ Kiro CLI固有の利点を活用 +- ✅ 簡単にセットアップ可能 + +## リスクと対策 + +### リスク1: MCP互換性 +**対策**: 各MCPサーバーの動作確認を徹底 + +### リスク2: エージェント設定の複雑さ +**対策**: デフォルト設定を提供、段階的なカスタマイズを推奨 + +### リスク3: ドキュメント不足 +**対策**: 豊富な例とトラブルシューティングを用意 + +## タイムライン + +- **Week 1-2**: Phase 1-3(基盤、MCP、エージェント) +- **Week 3-4**: Phase 4-6(プロンプト、スキル、フック) +- **Week 5**: Phase 7-8(インストール、ドキュメント) +- **Week 6**: Phase 9-10(テスト、リリース) + +## 次のステップ + +1. GitHubリポジトリ作成 +2. 基本ディレクトリ構造の作成 +3. MCP設定ファイルの作成 +4. 最初のエージェント(director)の実装 diff --git a/issues/PLAN10/03-installation-usage.md b/issues/PLAN10/03-installation-usage.md new file mode 100644 index 00000000..65dd8b85 --- /dev/null +++ b/issues/PLAN10/03-installation-usage.md @@ -0,0 +1,424 @@ +# インストール方法と利用方法 + +## インストール方法 + +### 前提条件 + +**必須**: +- Kiro CLI がインストール済み +- Git +- Python 3.10以上(BigQuery MCP用) +- `uvx` がインストール済み(`pip install uv`) + +**オプション**: +- Node.js(DBHub、Chrome DevTools MCP用) +- Codex CLI(Codex CLI MCP用) + +### ステップ1: リポジトリのクローン + +```bash +git clone https://github.com/takemi-ohama/kiro-ndf-config.git +cd kiro-ndf-config +``` + +### ステップ2: 自動インストール + +```bash +./scripts/install.sh +``` + +このスクリプトは以下を実行します: +1. `.kiro/`ディレクトリをホームディレクトリにコピー +2. `.env`ファイルを作成(`.env.example`から) +3. 必要なツールのインストール確認 + +### ステップ3: 環境変数の設定 + +`.env`ファイルを編集し、必要な認証情報を設定します: + +```bash +# Serena MCP (推奨) +SERENA_HOME=.serena +GOOGLE_API_KEY=your_google_api_key +ANTHROPIC_API_KEY=your_anthropic_api_key + +# Notion MCP (オプション) +NOTION_TOKEN=your_notion_token + +# BigQuery MCP (オプション) +BIGQUERY_PROJECT=your_project_id +BIGQUERY_LOCATION=US +BIGQUERY_KEY_FILE=/path/to/service-account-key.json + +# DBHub MCP (オプション) +DSN=your_database_connection_string + +# Slack通知 (オプション) +SLACK_BOT_TOKEN=your_slack_bot_token +SLACK_CHANNEL_ID=your_channel_id +SLACK_USER_MENTION=<@U0123456789> +``` + +### ステップ4: MCP設定の確認 + +```bash +kiro-cli mcp +``` + +7つのMCPサーバーが表示されることを確認します。 + +### ステップ5: エージェントの確認 + +```bash +kiro-cli agent list +``` + +6つのエージェント(director、data-analyst、corder、researcher、scanner、qa)が表示されることを確認します。 + +## 利用方法 + +### 基本的な使い方 + +#### 1. エージェントの起動 + +```bash +# Directorエージェント(タスク統括) +kiro-cli chat --agent director + +# データ分析エージェント +kiro-cli chat --agent data-analyst + +# コーディングエージェント +kiro-cli chat --agent corder + +# 調査エージェント +kiro-cli chat --agent researcher + +# ファイル読み取りエージェント +kiro-cli chat --agent scanner + +# 品質管理エージェント +kiro-cli chat --agent qa +``` + +#### 2. プロンプトの使用 + +プロンプトは`@`で呼び出します: + +```bash +# PR作成ワークフロー +@pr + +# Test Plan自動実行 +@pr-tests + +# レビュー指摘修正 +@fix + +# コードレビュー +@review + +# マージ後処理 +@merged + +# ブランチクリーンアップ +@clean + +# Memory戦略レビュー +@mem-review + +# Memory記録 +@mem-capture + +# Serena MCP操作ガイド +@serena +``` + +#### 3. スキルプロンプトの使用 + +```bash +# SQL最適化 +@sql-optimization + +# コードテンプレート +@code-templates + +# テスト生成 +@test-generation + +# PDF解析 +@pdf-analysis + +# Excel抽出 +@excel-extraction + +# セキュリティスキャン +@security-scan + +# Markdown文書作成 +@markdown-writing + +# Python実行環境判定 +@python-execution + +# Dockerコンテナアクセス +@docker-access +``` + +### エージェント別の使用例 + +#### Directorエージェント(タスク統括) + +複雑なタスクを分解し、適切なサブエージェントに委譲します。 + +```bash +kiro-cli chat --agent director + +> 新しいREST APIを実装してください。認証、データベース接続、テストも含めて。 + +# Directorが以下のように分解: +# 1. Corderエージェント: API実装 +# 2. Data-analystエージェント: データベース設計 +# 3. QAエージェント: テスト作成 +``` + +#### Data-analystエージェント(データ分析) + +BigQuery、DBHubを使用したデータ分析。 + +```bash +kiro-cli chat --agent data-analyst + +> BigQueryでユーザーの行動分析を実行してください + +# BigQuery MCPを使用してクエリ実行 +# 結果の可視化と分析 +``` + +#### Corderエージェント(コーディング) + +GitHub、Serenaを使用したコーディング支援。 + +```bash +kiro-cli chat --agent corder + +> ユーザー認証機能を実装してください + +# Serena MCPでコード構造を理解 +# GitHub MCPでPR作成 +``` + +#### Researcherエージェント(調査) + +Web検索、AWS Docsを使用した調査。 + +```bash +kiro-cli chat --agent researcher + +> AWS Lambdaのベストプラクティスを調査してください + +# AWS Docs MCPで公式ドキュメント検索 +# Web検索で最新情報収集 +``` + +#### Scannerエージェント(ファイル読み取り) + +PDF、Excelファイルの解析。 + +```bash +kiro-cli chat --agent scanner + +> この請求書PDFから金額を抽出してください + +# PDF解析スキルを使用 +# 構造化データとして出力 +``` + +#### QAエージェント(品質管理) + +セキュリティスキャン、コードレビュー。 + +```bash +kiro-cli chat --agent qa + +> このコードのセキュリティ脆弱性をチェックしてください + +# セキュリティスキャンスキルを使用 +# OWASP Top 10チェック +``` + +### ワークフロー例 + +#### PR作成ワークフロー + +```bash +kiro-cli chat --agent corder + +> @pr + +# 1. 変更内容の確認 +# 2. コミットメッセージの作成 +# 3. PRタイトルと説明の生成 +# 4. GitHub MCPでPR作成 +``` + +#### レビュー対応ワークフロー + +```bash +kiro-cli chat --agent corder + +> @fix + +# 1. PRのレビューコメント取得 +# 2. 指摘事項の修正 +# 3. コミット +# 4. レビュー対応コメント +``` + +#### マージ後処理ワークフロー + +```bash +kiro-cli chat --agent director + +> @merged + +# 1. マージ確認 +# 2. ローカルブランチの削除 +# 3. リモートブランチの削除 +# 4. mainブランチの更新 +``` + +### MCP統合の活用 + +#### Serena MCP(セマンティックコード操作) + +```bash +kiro-cli chat --agent corder + +> Serenaを使ってこのファイルのシンボル一覧を取得してください + +# Serena MCPのget_symbols_overviewを使用 +``` + +#### Notion MCP(Notion統合) + +```bash +kiro-cli chat --agent researcher + +> Notionのプロジェクトページを更新してください + +# Notion MCPでページ更新 +``` + +#### BigQuery MCP(BigQueryクエリ) + +```bash +kiro-cli chat --agent data-analyst + +> BigQueryでユーザーテーブルから集計してください + +# BigQuery MCPでクエリ実行 +``` + +### フックの活用 + +#### Slack通知 + +エージェント終了時に自動的にSlack通知が送信されます。 + +```bash +# .envファイルでSlack設定 +SLACK_BOT_TOKEN=xoxb-... +SLACK_CHANNEL_ID=C0123456789 +SLACK_USER_MENTION=<@U0123456789> + +# エージェント終了時に自動通知 +kiro-cli chat --agent director +> タスク完了 +> /quit + +# Slackに通知が送信される +``` + +## トラブルシューティング + +### MCPサーバーが起動しない + +```bash +# MCP設定の確認 +kiro-cli mcp + +# ログの確認 +kiro-cli logdump + +# 環境変数の確認 +cat .env +``` + +### エージェントが見つからない + +```bash +# エージェント一覧の確認 +kiro-cli agent list + +# エージェント設定ファイルの確認 +ls ~/.kiro/agents/ +``` + +### プロンプトが動作しない + +```bash +# プロンプト一覧の確認 +kiro-cli prompts + +# プロンプトファイルの確認 +ls ~/.kiro/prompts/ +``` + +## アップデート + +```bash +cd kiro-ndf-config +git pull +./scripts/install.sh +``` + +## アンインストール + +```bash +# エージェント設定の削除 +rm -rf ~/.kiro/agents/director.json +rm -rf ~/.kiro/agents/data-analyst.json +rm -rf ~/.kiro/agents/corder.json +rm -rf ~/.kiro/agents/researcher.json +rm -rf ~/.kiro/agents/scanner.json +rm -rf ~/.kiro/agents/qa.json + +# プロンプトの削除 +rm -rf ~/.kiro/prompts/pr.md +rm -rf ~/.kiro/prompts/pr-tests.md +rm -rf ~/.kiro/prompts/fix.md +rm -rf ~/.kiro/prompts/review.md +rm -rf ~/.kiro/prompts/merged.md +rm -rf ~/.kiro/prompts/clean.md +rm -rf ~/.kiro/prompts/mem-review.md +rm -rf ~/.kiro/prompts/mem-capture.md +rm -rf ~/.kiro/prompts/serena.md + +# MCP設定の削除 +rm -rf ~/.kiro/mcp.json +``` + +## サポート + +問題が発生した場合: +1. [トラブルシューティングガイド](docs/troubleshooting.md)を確認 +2. [GitHubイシュー](https://github.com/takemi-ohama/kiro-ndf-config/issues)を作成 +3. [ドキュメント](docs/)を参照 + +## 参考リンク + +- [Kiro CLI公式ドキュメント](https://kiro.dev/docs/cli/) +- [MCP仕様](https://modelcontextprotocol.io) +- [Serena MCP](https://github.com/oraios/serena) +- [元のNDFプラグイン](https://github.com/takemi-ohama/ai-plugins/tree/main/plugins/ndf) diff --git a/issues/PLAN10/04-feature-comparison.md b/issues/PLAN10/04-feature-comparison.md new file mode 100644 index 00000000..cf9dea25 --- /dev/null +++ b/issues/PLAN10/04-feature-comparison.md @@ -0,0 +1,216 @@ +# 機能比較表 + +## Claude Code NDFプラグイン vs Kiro CLI移植版 + +| 機能カテゴリ | Claude Code NDF | Kiro CLI移植版 | 実装方法 | 備考 | +|------------|----------------|---------------|---------|------| +| **MCP統合** | ✅ 7サーバー | ✅ 7サーバー | `.kiro/mcp.json` | 同等 | +| **エージェント** | ✅ 6種類 | ✅ 6種類 | `.kiro/agents/*.json` | 同等 | +| **カスタムコマンド** | ✅ 9コマンド | ⚠️ プロンプト | `.kiro/prompts/*.md` | 代替実装 | +| **スキル** | ✅ 13スキル | ⚠️ プロンプト | `.kiro/prompts/skills/*.md` | 代替実装 | +| **フック** | ✅ SessionStart, Stop | ✅ agentSpawn, stop | エージェント設定 | 同等 | +| **Slack通知** | ✅ 自動 | ✅ 自動 | stopフック | 同等 | +| **Marketplace** | ✅ あり | ❌ なし | - | Kiro CLIに概念なし | +| **Plugin** | ✅ あり | ❌ なし | - | Kiro CLIに概念なし | + +## 詳細機能比較 + +### MCP統合 + +| MCPサーバー | Claude Code | Kiro CLI | 設定方法 | 互換性 | +|-----------|-------------|----------|---------|--------| +| Serena | ✅ | ✅ | `.kiro/mcp.json` | 100% | +| Notion | ✅ | ✅ | `.kiro/mcp.json` | 100% | +| BigQuery | ✅ | ✅ | `.kiro/mcp.json` | 100% | +| DBHub | ✅ | ✅ | `.kiro/mcp.json` | 100% | +| Chrome DevTools | ✅ | ✅ | `.kiro/mcp.json` | 100% | +| AWS Docs | ✅ | ✅ | `.kiro/mcp.json` | 100% | +| Codex CLI | ✅ | ✅ | `.kiro/mcp.json` | 100% | + +### エージェント + +| エージェント | Claude Code | Kiro CLI | 実装方法 | 機能差異 | +|------------|-------------|----------|---------|---------| +| Director | ✅ | ✅ | `.kiro/agents/director.json` | なし | +| Data-analyst | ✅ | ✅ | `.kiro/agents/data-analyst.json` | なし | +| Corder | ✅ | ✅ | `.kiro/agents/corder.json` | なし | +| Researcher | ✅ | ✅ | `.kiro/agents/researcher.json` | なし | +| Scanner | ✅ | ✅ | `.kiro/agents/scanner.json` | なし | +| QA | ✅ | ✅ | `.kiro/agents/qa.json` | なし | + +### カスタムコマンド → プロンプト + +| コマンド | Claude Code | Kiro CLI | 実装方法 | UX差異 | +|---------|-------------|----------|---------|--------| +| `/ndf:pr` | ✅ | ⚠️ `@pr` | `.kiro/prompts/pr.md` | `/`→`@` | +| `/ndf:pr-tests` | ✅ | ⚠️ `@pr-tests` | `.kiro/prompts/pr-tests.md` | `/`→`@` | +| `/ndf:fix` | ✅ | ⚠️ `@fix` | `.kiro/prompts/fix.md` | `/`→`@` | +| `/ndf:review` | ✅ | ⚠️ `@review` | `.kiro/prompts/review.md` | `/`→`@` | +| `/ndf:merged` | ✅ | ⚠️ `@merged` | `.kiro/prompts/merged.md` | `/`→`@` | +| `/ndf:clean` | ✅ | ⚠️ `@clean` | `.kiro/prompts/clean.md` | `/`→`@` | +| `/ndf:mem-review` | ✅ | ⚠️ `@mem-review` | `.kiro/prompts/mem-review.md` | `/`→`@` | +| `/ndf:mem-capture` | ✅ | ⚠️ `@mem-capture` | `.kiro/prompts/mem-capture.md` | `/`→`@` | +| `/ndf:serena` | ✅ | ⚠️ `@serena` | `.kiro/prompts/serena.md` | `/`→`@` | + +**UX差異の説明**: +- Claude Code: `/ndf:pr`(スラッシュコマンド) +- Kiro CLI: `@pr`(プロンプト) +- 機能的には同等だが、呼び出し方法が異なる + +### スキル → プロンプト + +| スキル | Claude Code | Kiro CLI | 実装方法 | 自動起動 | +|--------|-------------|----------|---------|---------| +| SQL最適化 | ✅ 自動 | ⚠️ 手動 | `.kiro/prompts/skills/sql-optimization.md` | ❌ | +| コードテンプレート | ✅ 自動 | ⚠️ 手動 | `.kiro/prompts/skills/code-templates.md` | ❌ | +| テスト生成 | ✅ 自動 | ⚠️ 手動 | `.kiro/prompts/skills/test-generation.md` | ❌ | +| PDF解析 | ✅ 自動 | ⚠️ 手動 | `.kiro/prompts/skills/pdf-analysis.md` | ❌ | +| Excel抽出 | ✅ 自動 | ⚠️ 手動 | `.kiro/prompts/skills/excel-extraction.md` | ❌ | +| セキュリティスキャン | ✅ 自動 | ⚠️ 手動 | `.kiro/prompts/skills/security-scan.md` | ❌ | +| Markdown文書作成 | ✅ 自動 | ⚠️ 手動 | `.kiro/prompts/skills/markdown-writing.md` | ❌ | +| Python実行環境判定 | ✅ 自動 | ⚠️ 手動 | `.kiro/prompts/skills/python-execution.md` | ❌ | +| Dockerコンテナアクセス | ✅ 自動 | ⚠️ 手動 | `.kiro/prompts/skills/docker-access.md` | ❌ | + +**自動起動の差異**: +- Claude Code: スキルは特定のキーワードで自動起動 +- Kiro CLI: プロンプトは明示的に`@skill-name`で呼び出し +- 機能的には同等だが、UXが異なる + +### フック + +| フック | Claude Code | Kiro CLI | 実装方法 | 互換性 | +|--------|-------------|----------|---------|--------| +| SessionStart | ✅ | ✅ agentSpawn | エージェント設定 | 同等 | +| Stop | ✅ | ✅ stop | エージェント設定 | 同等 | +| PreToolUse | ✅ | ✅ preToolUse | エージェント設定 | 同等 | +| PostToolUse | ✅ | ✅ postToolUse | エージェント設定 | 同等 | + +### Slack通知 + +| 機能 | Claude Code | Kiro CLI | 実装方法 | 互換性 | +|------|-------------|----------|---------|--------| +| セッション終了通知 | ✅ | ✅ | stopフック | 100% | +| AI要約生成 | ✅ | ✅ | スクリプト | 100% | +| メンション | ✅ | ✅ | 環境変数 | 100% | + +## ユーザー体験の違い + +### コマンド実行 + +**Claude Code**: +``` +/ndf:pr +``` + +**Kiro CLI**: +``` +@pr +``` + +### スキル起動 + +**Claude Code**: +``` +# 自動起動(キーワード検出) +> SQLクエリを最適化してください +# → SQL最適化スキルが自動起動 +``` + +**Kiro CLI**: +``` +# 明示的呼び出し +@sql-optimization +> SQLクエリを最適化してください +``` + +### エージェント切り替え + +**Claude Code**: +``` +# サブエージェント呼び出し +/agent data-analyst +``` + +**Kiro CLI**: +``` +# エージェント切り替え +/agent data-analyst + +# またはサブエージェント使用 +> use_subagent tool +``` + +## 移植による利点 + +### Kiro CLI固有の利点 + +| 機能 | 説明 | Claude Codeにない利点 | +|------|------|---------------------| +| `/code` | LSP統合 | コードインテリジェンス | +| `/knowledge` | ナレッジベース | 永続的な知識管理 | +| `/todos` | TODOリスト | タスク管理 | +| `/checkpoint` | チェックポイント | 状態管理 | +| `/tangent` | タンジェントモード | 会話分岐 | +| AWS統合 | `use_aws`ツール | AWS CLI統合 | + +### 統合の可能性 + +Kiro CLI移植版では、以下の統合が可能: + +1. **コードインテリジェンス + Serena MCP** + - LSPとSerenaの組み合わせで強力なコード理解 + +2. **ナレッジベース + プロジェクトドキュメント** + - `/knowledge`でプロジェクト知識を永続化 + +3. **TODOリスト + タスク管理** + - `/todos`でタスク追跡 + +4. **AWS統合 + AWS Docs MCP** + - `use_aws`とAWS Docs MCPの組み合わせ + +## 移植の制約 + +### 実装できない機能 + +| 機能 | 理由 | 代替案 | +|------|------|--------| +| Marketplace | Kiro CLIに概念なし | Gitリポジトリで配布 | +| Plugin | Kiro CLIに概念なし | 設定ファイルで配布 | +| スキル自動起動 | Kiro CLIに機能なし | プロンプトで明示的呼び出し | +| カスタムスラッシュコマンド | Kiro CLIに機能なし | プロンプトで代替 | + +### UXの違い + +| 項目 | Claude Code | Kiro CLI | 影響 | +|------|-------------|----------|------| +| コマンド呼び出し | `/ndf:command` | `@command` | 軽微 | +| スキル起動 | 自動 | 手動 | 中程度 | +| プラグインインストール | `/plugin install` | `git clone` + `./install.sh` | 中程度 | +| 更新 | `/plugin update` | `git pull` + `./install.sh` | 軽微 | + +## 推奨事項 + +### ユーザーへの推奨 + +1. **Claude Codeユーザー**: + - NDFプラグインを使用(ネイティブ体験) + +2. **Kiro CLIユーザー**: + - Kiro NDF Configを使用(同等機能) + - Kiro CLI固有機能も活用 + +3. **両方使用**: + - 環境に応じて使い分け + - 設定は別々に管理 + +### 開発者への推奨 + +1. **新機能追加時**: + - 両方のプラットフォームで実装を検討 + +2. **ドキュメント**: + - プラットフォーム別のガイドを提供 + +3. **互換性**: + - 可能な限り同等の体験を提供 diff --git a/issues/PLAN10/README.md b/issues/PLAN10/README.md new file mode 100644 index 00000000..ec7f0db2 --- /dev/null +++ b/issues/PLAN10/README.md @@ -0,0 +1,195 @@ +# Kiro CLI移植計画 - サマリー + +## 調査結果 + +### NDFプラグインの構成 +- **MCP統合**: 7サーバー(Serena、Notion、BigQuery、DBHub、Chrome DevTools、AWS Docs、Codex CLI) +- **カスタムコマンド**: 9コマンド(PR作成、テスト実行、レビュー、修正、マージ、クリーンアップ、Memory管理) +- **サブエージェント**: 6種類(director、data-analyst、corder、researcher、scanner、qa) +- **スキル**: 13個(SQL最適化、コードテンプレート、テスト生成、PDF解析、Excel抽出、セキュリティスキャン等) +- **フック**: SessionStart、Stop(Slack通知) + +### Kiro CLIの類似機能 +- **MCP統合**: ✅ 同等機能あり(`.kiro/mcp.json`) +- **エージェント**: ✅ 同等機能あり(`.kiro/agents/*.json`) +- **カスタムコマンド**: ⚠️ プロンプト(`.kiro/prompts/*.md`)で代替 +- **スキル**: ⚠️ プロンプトで代替(自動起動は不可) +- **フック**: ✅ 同等機能あり(エージェント設定内) +- **Marketplace/Plugin**: ❌ 概念なし + +## 移植戦略 + +### 結論: 別リポジトリで移植 + +**新リポジトリ名**: `kiro-ndf-config` + +**理由**: +1. Kiro CLIにはMarketplace/Plugin概念がない +2. 同じリポジトリで管理すると混乱を招く +3. Kiro CLI向けの独自構造が必要 + +### 機能マッピング + +| Claude Code機能 | Kiro CLI実装 | 互換性 | +|----------------|-------------|--------| +| MCP統合 | `.kiro/mcp.json` | 100% | +| エージェント | `.kiro/agents/*.json` | 100% | +| カスタムコマンド | `.kiro/prompts/*.md` | 代替(`/`→`@`) | +| スキル | `.kiro/prompts/skills/*.md` | 代替(手動起動) | +| フック | エージェント設定 | 100% | + +## 実装計画 + +### Phase 1: プロジェクト基盤(Week 1-2) +- GitHubリポジトリ作成 +- ディレクトリ構造作成 +- 基本ドキュメント作成 + +### Phase 2: MCP統合(Week 1-2) +- `.kiro/mcp.json`作成 +- 7つのMCPサーバー設定 +- `.env.example`作成 + +### Phase 3: エージェント移植(Week 1-2) +- 6つのエージェント設定作成 +- エージェント間連携ドキュメント + +### Phase 4: プロンプト作成(Week 3-4) +- 9つの開発ワークフロープロンプト +- 13個のスキルプロンプト + +### Phase 5: フック実装(Week 3-4) +- Slack通知スクリプト +- セットアップスクリプト + +### Phase 6: インストールスクリプト(Week 5) +- 自動セットアップスクリプト +- 環境確認スクリプト + +### Phase 7: ドキュメント整備(Week 5) +- ユーザーガイド +- 開発者ガイド +- トラブルシューティング + +### Phase 8: テストとリリース(Week 6) +- 機能テスト +- ドキュメントレビュー +- v1.0.0リリース + +## インストール方法(予定) + +```bash +# リポジトリのクローン +git clone https://github.com/takemi-ohama/kiro-ndf-config.git +cd kiro-ndf-config + +# 自動インストール +./scripts/install.sh + +# 環境変数の設定 +vi .env + +# 確認 +kiro-cli mcp +kiro-cli agent list +``` + +## 利用方法(予定) + +### エージェント起動 +```bash +kiro-cli chat --agent director +kiro-cli chat --agent data-analyst +kiro-cli chat --agent corder +``` + +### プロンプト使用 +```bash +@pr # PR作成 +@review # コードレビュー +@fix # レビュー修正 +@sql-optimization # SQL最適化 +``` + +### MCP活用 +```bash +# Serena MCPでコード操作 +> Serenaを使ってシンボル一覧を取得 + +# BigQuery MCPでデータ分析 +> BigQueryでユーザー集計 +``` + +## ユーザー体験の違い + +### コマンド呼び出し +- **Claude Code**: `/ndf:pr` +- **Kiro CLI**: `@pr` + +### スキル起動 +- **Claude Code**: 自動起動(キーワード検出) +- **Kiro CLI**: 明示的呼び出し(`@skill-name`) + +### プラグインインストール +- **Claude Code**: `/plugin install ndf@ai-plugins` +- **Kiro CLI**: `git clone` + `./install.sh` + +## Kiro CLI固有の利点 + +移植版では、Kiro CLI固有の機能も活用可能: + +1. **コードインテリジェンス** (`/code`): LSP統合 +2. **ナレッジベース** (`/knowledge`): 永続的な知識管理 +3. **TODOリスト** (`/todos`): タスク管理 +4. **チェックポイント** (`/checkpoint`): 状態管理 +5. **タンジェントモード** (`/tangent`): 会話分岐 +6. **AWS統合** (`use_aws`): AWS CLI統合 + +## 成功基準 + +### 機能要件 +- ✅ 7つのMCPサーバーが正常に動作 +- ✅ 6つのエージェントが利用可能 +- ✅ 9つのプロンプト(コマンド代替)が動作 +- ✅ 13個のスキルプロンプトが動作 +- ✅ Slack通知フックが動作 + +### ドキュメント要件 +- ✅ インストール手順が明確 +- ✅ 使用方法が理解しやすい +- ✅ トラブルシューティングが充実 + +### ユーザー体験 +- ✅ Claude Code NDFプラグインと同等の機能 +- ✅ Kiro CLI固有の利点を活用 +- ✅ 簡単にセットアップ可能 + +## リスクと対策 + +### リスク1: MCP互換性 +**対策**: 各MCPサーバーの動作確認を徹底 + +### リスク2: エージェント設定の複雑さ +**対策**: デフォルト設定を提供、段階的なカスタマイズを推奨 + +### リスク3: ドキュメント不足 +**対策**: 豊富な例とトラブルシューティングを用意 + +### リスク4: UXの違い +**対策**: 明確なドキュメントと移行ガイド + +## 次のステップ + +1. ✅ 調査完了 +2. ✅ 移植計画作成 +3. ⏭️ GitHubリポジトリ作成 +4. ⏭️ 基本ディレクトリ構造の作成 +5. ⏭️ MCP設定ファイルの作成 +6. ⏭️ 最初のエージェント(director)の実装 + +## 参考資料 + +- [調査結果](./01-analysis.md) +- [移植計画](./02-migration-plan.md) +- [インストール・利用方法](./03-installation-usage.md) +- [機能比較表](./04-feature-comparison.md) diff --git a/issues/PLAN14.md b/issues/PLAN14.md new file mode 100644 index 00000000..8be1c905 --- /dev/null +++ b/issues/PLAN14.md @@ -0,0 +1,286 @@ +# PLAN14: NDFプラグイン Kiro CLI対応 企画・設計書 + +## 1. 概要 + +NDFプラグイン(v3.1.0)のSkills・Hooks・MCPをKiro CLIでも利用可能にする。 +Claude Code向けの既存構造はそのまま維持し、Kiro CLI用の設定ファイルを追加する。 + +## 2. 現状分析 + +### 2.1 NDFプラグイン構成(Claude Code向け) + +| 要素 | 場所 | 数 | +|------|------|-----| +| Skills | `plugins/ndf/skills/*/SKILL.md` | 24個 | +| Agents | `plugins/ndf/agents/*.md` | 6個 | +| Hooks | `plugins/ndf/hooks/hooks.json` | 2個(SessionStart, Stop) | +| MCP | `plugins/ndf/.mcp.json` | 1個(Codex CLI) | +| Scripts | `plugins/ndf/scripts/slack-notify.js` | 1個 | + +### 2.2 Kiro CLI既存設定 + +| ファイル | 内容 | +|---------|------| +| `KIRO.md` | 開発ガイドライン(コード探索方法等) | +| `.kiro/agents/default.json` | resources: `file://AGENTS.md`, `file://README.md` のみ | + +### 2.3 Claude Code vs Kiro CLI 仕様比較 + +| 機能 | Claude Code | Kiro CLI | +|------|------------|----------| +| Skills定義場所 | `plugin.json` → `skills/*/SKILL.md` | agent JSON → `resources` に `skill://` パス | +| Skillフロントマター | `name`, `description`, `disable-model-invocation`, `user-invocable`, `allowed-tools` 等 | `name`, `description` のみ(他フィールドは無視される) | +| Skillロード | プラグインインストール時に自動 | `skill://` でメタデータ起動時ロード、本文オンデマンド | +| Hook定義場所 | `hooks/hooks.json` | agent JSON → `hooks` フィールド | +| Hookトリガー | `SessionStart`, `Stop` | `agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop` | +| Hook入力 | トリガーにより異なる | JSON via STDIN(`hook_event_name`, `cwd`, `assistant_response`等) | +| MCP定義場所 | `.mcp.json` | agent JSON → `mcpServers` フィールド | +| エージェント | `agents/*.md`(plugin.jsonで登録) | `.kiro/agents/*.json`(ファイル名がエージェント名) | + +## 3. 移植方針 + +### 3.1 Skills(24個) + +SKILL.mdファイルはClaude CodeとKiro CLIで共通フォーマット(YAMLフロントマター `name` + `description`)のため、**ファイル自体の変更は不要**。 + +Kiro CLI側では `.kiro/agents/default.json` の `resources` に `skill://` パスを追加する。 + +**分類と対応方針:** + +| 分類 | Skills | Kiro対応 | +|------|--------|---------| +| ワークフロー系 | pr, pr-tests, fix, review, merged, clean, cleanup, deepwiki-transfer, knowledge-reorg | ○ skill://で登録。`disable-model-invocation`はKiroでは無視されるが、SKILL.md本文の手順に従えば同等動作 | +| モデル起動型 | corder-code-templates, corder-test-generation, data-analyst-export, data-analyst-sql-optimization, docker-container-access, git-gh-operations, google-auth, markdown-writing, python-execution, qa-security-scan, researcher-report-templates, scanner-pdf-analysis, skill-development | ○ skill://で登録。descriptionのTriggersキーワードでオンデマンドロード | +| ポリシー注入 | ndf-policies | ○ `file://` で常時ロード(Kiroには`user-invocable: false`相当がないため) | + +### 3.2 Hooks + +| Claude Code Hook | Kiro CLI対応 | 方針 | +|-----------------|-------------|------| +| `SessionStart` → CLAUDE.ndf.md検出警告 | `agentSpawn` | ○ 同じシェルコマンドを`agentSpawn`フックに設定 | +| `Stop` → slack-notify.js | `stop` | △ 要改修。Kiro CLIのstopフックはSTDINに`assistant_response`を含むJSONを送る。Claude Codeの`transcript_path`ベースとは異なる | + +**slack-notify.js改修方針:** + +Kiro CLIのstopフックは `{"hook_event_name":"stop","cwd":"...","assistant_response":"..."}` をSTDINで送る。 +現行のslack-notify.jsは `transcript_path` からファイルを読んで要約生成する方式。 + +対応案: +1. STDINのJSONを解析し、`assistant_response` があればそれを直接要約に使用 +2. `transcript_path` があれば従来通りファイルから読む(Claude Code互換) +3. どちらもなければフォールバックメッセージ + +これにより1つのスクリプトで両方のCLIに対応可能。 + +### 3.3 MCP(Codex CLI) + +agent JSONの `mcpServers` フィールドに移植。構造はほぼ同一。 + +```json +"mcpServers": { + "codex": { + "command": "codex", + "args": ["mcp-server"], + "env": {} + } +} +``` + +### 3.4 エージェント(6個) + +Claude Codeのサブエージェント(`agents/*.md`)はKiro CLIでは直接対応する仕組みがない。 +Kiro CLIのエージェントは `.kiro/agents/*.json` で定義するが、Claude Codeの「サブエージェント呼び出し」とは異なる概念。 + +対応案: +- 各エージェントのMDファイルを `skill://` としてロードし、知識として参照可能にする +- 必要に応じて専門エージェントを `.kiro/agents/` に個別JSON定義(将来課題) + +## 4. 実装計画 + +### Phase 1: Skills + MCP + Hooks(コア機能) + +#### 4.1 `.kiro/agents/default.json` の更新 + +```json +{ + "description": "NDF統合開発エージェント(Kiro CLI用)", + "resources": [ + "file://AGENTS.md", + "file://README.md", + "file://plugins/ndf/skills/ndf-policies/SKILL.md", + "skill://plugins/ndf/skills/pr/SKILL.md", + "skill://plugins/ndf/skills/pr-tests/SKILL.md", + "skill://plugins/ndf/skills/fix/SKILL.md", + "skill://plugins/ndf/skills/review/SKILL.md", + "skill://plugins/ndf/skills/merged/SKILL.md", + "skill://plugins/ndf/skills/clean/SKILL.md", + "skill://plugins/ndf/skills/cleanup/SKILL.md", + "skill://plugins/ndf/skills/deepwiki-transfer/SKILL.md", + "skill://plugins/ndf/skills/knowledge-reorg/SKILL.md", + "skill://plugins/ndf/skills/corder-code-templates/SKILL.md", + "skill://plugins/ndf/skills/corder-test-generation/SKILL.md", + "skill://plugins/ndf/skills/data-analyst-export/SKILL.md", + "skill://plugins/ndf/skills/data-analyst-sql-optimization/SKILL.md", + "skill://plugins/ndf/skills/docker-container-access/SKILL.md", + "skill://plugins/ndf/skills/git-gh-operations/SKILL.md", + "skill://plugins/ndf/skills/google-auth/SKILL.md", + "skill://plugins/ndf/skills/markdown-writing/SKILL.md", + "skill://plugins/ndf/skills/python-execution/SKILL.md", + "skill://plugins/ndf/skills/qa-security-scan/SKILL.md", + "skill://plugins/ndf/skills/researcher-report-templates/SKILL.md", + "skill://plugins/ndf/skills/scanner-pdf-analysis/SKILL.md", + "skill://plugins/ndf/skills/skill-development/SKILL.md" + ], + "hooks": { + "agentSpawn": [ + { + "command": "if [ -f \"${PWD}/CLAUDE.ndf.md\" ] || [ -f \"$HOME/.claude/CLAUDE.ndf.md\" ]; then echo '[NDF] CLAUDE.ndf.md が検出されました。廃止済みです。cleanup を実行して削除してください。'; fi" + } + ], + "stop": [ + { + "command": "node plugins/ndf/scripts/slack-notify.js session_end", + "timeout_ms": 70000 + } + ] + }, + "mcpServers": { + "codex": { + "command": "codex", + "args": ["mcp-server"], + "env": {} + } + } +} +``` + +#### 4.2 slack-notify.js の改修 + +`readHookInput()` 関数にKiro CLI対応を追加: + +```javascript +// 既存: transcript_pathベース(Claude Code) +// 追加: assistant_responseベース(Kiro CLI) +async function readHookInput() { + const input = await readStdin(); + const parsed = safeJsonParse(input); + + if (parsed?.hook_event_name === 'stop' && parsed?.assistant_response) { + // Kiro CLI: assistant_responseを直接使用 + return { + assistantResponse: parsed.assistant_response, + transcriptPath: null, + stopHookActive: true + }; + } + + // Claude Code: 従来のtranscript_pathベース + return { + assistantResponse: null, + transcriptPath: parsed?.transcript_path || null, + stopHookActive: !!parsed?.stop_hook_active + }; +} +``` + +`generateSummary()` にも分岐を追加し、`assistantResponse` がある場合はファイル読み込みをスキップして直接要約プロンプトに渡す。 + +### Phase 2: インストーラースクリプト + +#### 4.3 `scripts/install-kiro.sh`(実装済み) + +`plugin.json` のskills一覧から `.kiro/agents/default.json` を動的に生成するインストーラー。 + +**使い方:** + +```bash +# 基本(Skills + agentSpawnフックのみ) +bash scripts/install-kiro.sh + +# Slack通知も有効化 +bash scripts/install-kiro.sh --with-slack + +# 全部入り(Slack + Codex MCP) +bash scripts/install-kiro.sh --with-slack --with-codex +``` + +**動作内容:** + +1. `plugins/ndf/.claude-plugin/plugin.json` からskills一覧を読み取り +2. 各skillの `SKILL.md` 存在確認(なければスキップ) +3. `ndf-policies` → `file://`(常時ロード)、他 → `skill://`(オンデマンド)で登録 +4. `agentSpawn` フック(CLAUDE.ndf.md廃止警告)を設定 +5. `--with-slack` 指定時: `stop` フック(slack-notify.js)を追加 +6. `--with-codex` 指定時: `mcpServers` にCodex CLIを追加 +7. 既存の `default.json` は `.bak` にバックアップ +8. 生成後 python3 があればJSON整形 + +**設計判断:** + +| 判断 | 理由 | +|------|------| +| plugin.jsonから動的生成 | skillの追加・削除時にインストーラー修正不要 | +| Slack/Codexはオプトイン | 環境依存のため。未設定で起動エラーを防ぐ | +| `name` フィールド必須 | Kiro CLIのagent configスキーマ要件(`kiro-cli agent validate` で検証済み) | + +### Phase 3: インストールマニュアル + +#### 4.4 Kiro CLI向けインストール手順(README.mdまたは別ドキュメントに追記) + +```markdown +## Kiro CLIでの利用 + +### 前提条件 +- Kiro CLI がインストール済み +- Node.js(slack-notify用、オプション) +- codex CLI(MCP用、オプション) + +### セットアップ + +1. リポジトリをクローン +2. インストーラーを実行: + bash scripts/install-kiro.sh # 基本 + bash scripts/install-kiro.sh --with-slack # Slack通知あり + bash scripts/install-kiro.sh --with-slack --with-codex # 全部入り +3. Slack通知を使う場合は `.env` に以下を設定: + - SLACK_CHANNEL_ID + - SLACK_BOT_TOKEN + - SLACK_USER_MENTION(オプション) +4. Kiro CLIを起動:kiro-cli chat + +### 利用方法 +- スキルはdescriptionのキーワードに基づいて自動的に参照されます +- ワークフロー系スキル(pr, fix, review等)は手動で指示してください + 例: 「prスキルの手順に従ってPRを作成して」 + +### 再インストール・更新 +plugin.jsonにskillが追加された場合、再度インストーラーを実行してください。 +既存設定は .bak にバックアップされます。 +``` + +## 5. 制約事項・注意点 + +| 項目 | 詳細 | +|------|------| +| `disable-model-invocation` | Kiro CLIでは未サポート。ワークフロー系スキルもモデルが自動参照する可能性あり | +| `user-invocable: false` | Kiro CLIでは未サポート。ndf-policiesは`file://`で常時ロードして代替 | +| `allowed-tools` | Kiro CLIのskillフロントマターでは未サポート。agent JSONの`tools`/`allowedTools`で制御 | +| `context: fork` | Kiro CLIでは未サポート。サブエージェント実行は`/spawn`や`subagent`ツールで代替 | +| サブエージェント | Claude Codeの`agents/*.md`はKiro CLIのエージェントシステムとは異なる。将来課題 | +| `${CLAUDE_PLUGIN_ROOT}` | Kiro CLIでは使えない。スクリプトパスは相対パスまたは絶対パスで指定 | +| stopフック入力形式 | Claude Code: transcript_path / Kiro CLI: assistant_response。スクリプト側で両対応必要 | + +## 6. ファイル変更一覧 + +| ファイル | 変更内容 | +|---------|---------| +| `scripts/install-kiro.sh` | 新規作成。plugin.jsonからdefault.jsonを動的生成するインストーラー | +| `.kiro/agents/default.json` | インストーラーにより生成。Skills/Hooks/MCP設定 | +| `plugins/ndf/scripts/slack-notify.js` | Kiro CLI stopフック入力形式に対応 | +| `KIRO.md` または `plugins/ndf/README.md` | Kiro CLIインストール手順を追記 | + +## 7. 今後の拡張(将来課題) + +- 専門エージェント(director, data-analyst等)のKiro CLI用JSON定義 +- `skill://` のglob対応(`skill://plugins/ndf/skills/**/SKILL.md`)による設定簡素化 +- Kiro CLI固有機能(`/plan`, `/spawn`)を活用した新ワークフロー diff --git a/issues/PLAN15.md b/issues/PLAN15.md new file mode 100644 index 00000000..d62865e2 --- /dev/null +++ b/issues/PLAN15.md @@ -0,0 +1,153 @@ +# PLAN15: playwright-scenario-test v0.3.0 — OSS リリース品質化 + +## 関連リンク + +- 親 PR: [#55 feat(ndf): playwright-scenario-test ... v4.1.0](https://github.com/takemi-ohama/ai-plugins/pull/55) +- 親 PR の自己レビュー: [comment #3142670254](https://github.com/takemi-ohama/ai-plugins/pull/55#issuecomment-3142670254) + +## 概要 + +PR #55 で残った以下を 1 PR で全対応し、playwright-scenario-test を **OSS リリース品質** まで引き上げる。 + +| 項目 | 由来 | +|------|------| +| Maj-7: `playwright_executor.py` 責務分離 | 自己レビュー Major | +| Min-2: `_slugify` 衝突 | 自己レビュー Minor | +| Min-4: HAR upload 用スクリプト不在 | 自己レビュー Minor | +| pytest-playwright + locator-first 移行 | PR #55 description (v0.3.0) | +| web-first assertion (`expect`) 統一 | PR #55 description (v0.3.0) | +| docs の "v0.3.0 以降" 記述解消 | PR #55 description | +| 録画→YAML 自動変換 | PR #55 description (v0.4.0 → 前倒し) | +| a11y / CWV を runner に組込 | PR #55 description (v0.4.0 → 前倒し) | + +**後方互換は不要** (誰も使っていない開発中段階)。古い実装は躊躇なく削除し、最終形のみを残す。 + +## 設計方針 (no back-compat) + +1. **testcase YAML スキーマを最終形へ刷新**: 旧 `path` ベース step を削除し、`goto` / `click` / `fill` / `expect` の **明示的 step kind** に統一 +2. **runner は pytest-playwright ベース**: `scenario-test` CLI は pytest を呼ぶ薄いラッパに縮退 +3. **assertion は `expect()` のみ**: 文字列 match / `body_check` の正規表現は `expect(locator).not_to_contain_text(...)` などへ移行 +4. **evidence (trace / HAR / video) 収集は単一モジュール**: `evidence.py` で集中管理、scripts は `upload_evidence.py` に統合 +5. **deprecated/dead code は削除**: `trace_link.py` shim は残さず削除、過去の互換層も持たない +6. **a11y / CWV 自動付加**: page_role に応じて axe-core / Core Web Vitals を runner が自動実行し report に含める + +## 修正対象 (主要ファイル) + +### 削除 + +- `scripts/trace_link.py` (`upload_evidence.py` に吸収) +- `scenario_test/nav_helpers.py` の旧 `path` ベース helper (`navigate_post`, `find_click_target`, `detect_body_errors`, `slug_for`) — locator + expect で置換 +- `scenario_test/curl_executor.py` の `_curl()` で使う path-only step (curl タイプは残すが step 表現を新スキーマに合わせる) + +### 新規 + +- `scenario_test/evidence.py` — `EvidenceCollectors` dataclass + listener / tracing / HAR 集中管理 +- `scenario_test/locator_steps.py` — YAML step kind → Playwright Locator 操作の dispatcher +- `scenario_test/a11y.py` — axe-core 実行 + 結果集計 (page_role 自動判定) +- `scenario_test/cwv.py` — Core Web Vitals 計測ラッパ (LCP/CLS/TTFB/INP-proxy) +- `scripts/upload_evidence.py` — trace/HAR/video 共通アップロード +- `scripts/record_to_yaml.py` — Playwright codegen 出力を YAML testcase へ変換 +- `tests/test_evidence.py` +- `tests/test_locator_steps.py` +- `tests/test_a11y.py` +- `tests/test_cwv.py` +- `tests/test_upload_evidence.py` +- `tests/test_record_to_yaml.py` +- `tests/test_default_test_id.py` + +### 大幅改訂 + +- `scenario_test/playwright_executor.py` (678 → 約 350 行目標。pytest-playwright 連携 + locator dispatcher 委譲) +- `scenario_test/testcase.py` (新 step kind スキーマ + バリデーション) +- `scripts/generate_test_plan.py` (`_default_test_id` で URL path 全体 + sha1[:6] 衝突回避、`_yaml_dump` 撤去して `pyyaml` の `safe_dump` を直接利用) +- `templates/testcase-*.yaml.template` (全 6 ファイル新スキーマで書き直し) +- `templates/config.example.yaml` (a11y/CWV セクション追加) +- `SKILL.md` (新ランナー / 新 step kind / 新 scripts へ全面刷新) +- `docs/01-methodology.md` 〜 `05-bug-report.md` (旧 helper 言及を locator-first へ更新) +- `docs/04-playwright-mapping.md` (`expect_aria_snapshot` を実装済みとして書き直し) +- `pyproject.toml` (version 0.2.0 → 0.3.0、`pytest-playwright` を dev に追加) +- `plugins/ndf/.claude-plugin/plugin.json` (4.1.0 → 4.2.0) +- `plugins/ndf/CLAUDE.md` (v4.2.0 開発履歴追記) + +## タスク分解 + +### Task 1: 新スキーマ確立 (testcase.py + locator_steps.py) + +- `TestCase.steps` を `list[Step]` (union of `GotoStep / ClickStep / FillStep / ExpectStep / ExtractStep`) に変更 +- `locator_steps.py` で各 step kind を Locator 操作に dispatch (例: `ClickStep(role="button", name="保存")` → `page.get_by_role("button", name="保存").click()`) +- 旧 `NavStep` (`path` only) は削除。step 不正時は ValueError でエラー +- `KNOWN_PAGE_ROLES` 検証は維持 +- `tests/test_locator_steps.py` で各 step → Locator 構築を smoke (Playwright 不要なテスト含む) + +### Task 2: evidence.py へ集中管理 + +- `EvidenceCollectors` dataclass: `trace_path`, `har_path`, `video_path`, `console_errors`, `page_errors`, `axe_violations`, `cwv_metrics` +- `EvidenceCollectors.attach(context, page, config, tc, log_lines)` で listener / tracing / HAR を一括 setup +- `EvidenceCollectors.finalize()` で trace.stop / a11y / CWV 実行 +- `playwright_executor` から evidence 関連コードを除去 (約 200 行削減) + +### Task 3: a11y.py / cwv.py 実装 + +- `a11y.py`: `axe-playwright-python` を呼び出し WCAG 2.0/2.1/2.2 違反を集計 + - page_role が `lp / list / form / dashboard / cart / checkout / settings` のとき自動実行 + - violations を `EvidenceCollectors.axe_violations` に格納し、report に表示 +- `cwv.py`: `check_cwv.py` (既存) のロジックを module 化し runner から呼び出せるように + - LCP / CLS / TTFB / longest_task を計測 + - page_role が `lp / list / dashboard` のとき自動実行 +- どちらも threshold を `config.yaml` で上書き可能 + +### Task 4: pytest-playwright ベース runner + +- `playwright_executor.py` を pytest-playwright fixture (`page`, `browser_context_args`) に再構築 +- `scenario-test` CLI は `pytest` を `subprocess.run` で呼ぶ薄いラッパ +- 並列は `pytest-xdist` (`-n {workers}`) で実装 +- 録画 / trace は `browser_context_args` で設定 +- `expect()` のみを assertion に使用、自前 `body_check` は `expect_no_text` step kind で代替 + +### Task 5: scripts 整理 + +- `upload_evidence.py` (新規): `--kind {trace,har,video,any}` を受ける統合 uploader +- `trace_link.py` 削除 +- `record_to_yaml.py` (新規): Playwright codegen の Python 出力を読み、新 step kind YAML へ変換 +- `record_scenario.py` は `record_to_yaml.py` 呼び出しのみに簡略化 + +### Task 6: テンプレート + docs 全面更新 + +- `templates/testcase-*.yaml.template` を 6 ファイル全部新スキーマで書き直し +- `templates/config.example.yaml` に `a11y`, `cwv` セクション追加 +- `SKILL.md` を新ランナー前提でクイックスタートから書き直し +- `docs/01〜05` の旧 helper 言及を locator-first へ更新 +- `docs/04-playwright-mapping.md` の `aria_snapshot` を実装済みとして書き直し +- `docs/05-bug-report.md` の `generate_bug_report.py` を実装済みとして書き直す (Task 5 にスクリプト追加) + +### Task 7: バージョン bump + 開発履歴 + +- `pyproject.toml`: `version = "0.3.0"` + `dev` extras に `pytest-playwright>=0.5`, `pytest-xdist>=3.0` +- `plugins/ndf/.claude-plugin/plugin.json`: `4.1.0` → `4.2.0` +- `plugins/ndf/CLAUDE.md` の開発履歴に v4.2.0 セクションを追加 (本 PR の概要) +- 古い `i09.md` 等の参照ノートで古くなった部分があれば追記 + +## 影響範囲 + +- **互換性破壊**: あり (testcase YAML スキーマ変更、`trace_link.py` 削除、`scenario-test` CLI の内部実装が pytest 委譲) +- **依存追加**: `pytest-playwright>=0.5` (dev), `pytest-xdist>=3.0` (dev), `axe-playwright-python>=0.1.4` (a11y extras はすでに追加済み) +- **ユーザ影響**: 利用者ゼロ (本人検証段階) のため気にしない + +## テスト計画 + +- [ ] `uv run pytest` (新規 + 既存) **全 pass** (目標 100+ ケース) +- [ ] `uv run scenario-test --help` smoke +- [ ] 新スキーマの testcase YAML を最低 3 件 (auth / form / list role) 用意し、テスト用ローカル HTTP サーバ (Python `http.server`) に対して実行成功 +- [ ] a11y 違反を意図的に持つ HTML を fixture 化し、`a11y.py` が拾うことを確認 +- [ ] CWV しきい値を超える遅延 fixture で `cwv.py` が FAIL を返すことを確認 +- [ ] `upload_evidence.py --kind any` が拡張子から正しく分岐 +- [ ] `record_to_yaml.py` で Playwright codegen の Python 出力 → YAML 変換 round-trip +- [ ] `playwright_executor.py` 行数 678 → 約 350 行を確認 +- [ ] `grep -r "v0.3.0 以降\|v0.4.0 以降\|TODO\|FIXME" plugins/ndf/skills/playwright-scenario-test/` で 0 hit (リリース品質チェック) +- [ ] `docs/` の旧 helper 言及 (`navigate_post`, `find_click_target`, `detect_body_errors`) が 0 hit + +## 進め方 + +タスクは依存順に Task 1 → 7 で進める。Task 4 (pytest-playwright runner) は最大規模なので、Task 1〜3 完了後に着手する。 + +各タスク完了ごとに `uv run pytest` を回し、リグレッションがないことを確認する。 diff --git a/issues/PLAN17.md b/issues/PLAN17.md new file mode 100644 index 00000000..a8a4649f --- /dev/null +++ b/issues/PLAN17.md @@ -0,0 +1,289 @@ +# PLAN17: playwright-scenario-test v0.3.0 — pure pytest-playwright 完全移行 + +> ## 🚨 引継ぎメモ (2026-04-26 セッション切替時点) +> +> ### 現在の状態 +> +> - **PR #56 (v0.2.5 transition) はマージ済み** (mergeCommit `d288f92`、2026-04-26T04:04:26Z) +> - 削除済 feature ブランチ: `feature/scenario-test-v0.3.0-oss-quality` +> - 現在のブランチ: **`main`** (`origin/main` と同期済み) +> - 関連 PR の経緯はすべて `plugins/ndf/CLAUDE.md` の v4.1.1 セクションに記録済み +> - 本 PLAN17 は **これから実装する** (まだコード変更ゼロ) +> +> ### 未着手のタスク (本 PLAN17 の Task 1〜8) +> +> 何も着手していない。下記「タスク分解」節を上から順に実行してください。 +> +> ### 着手前にやること +> +> 1. `git -C /work/ai-plugins checkout -b feature/scenario-test-v0.3.0-pytest-native` で新ブランチ作成 +> 2. `cd /work/ai-plugins/plugins/ndf/skills/playwright-scenario-test` +> 3. `uv sync` で依存解決確認 (現状 v0.2.5 = locator-first DSL 中間版) +> 4. `uv run pytest` で **126 件 全 pass** することを確認 (出発点) +> +> ### v0.2.5 から v0.3.0 への影響範囲 (削除予定の DSL 層) +> +> 以下を削除/全面書き換えすることを忘れない: +> +> - `scenario_test/testcase.py` の `Step` / `LocatorSpec` / `KNOWN_STEP_KINDS` / `discover_testcases` / `filter_testcases` / `parse_filter` (testcase.py 自体は残しても良いが、`Step` 系は削除) +> - `scenario_test/locator_steps.py` (全削除) +> - `scenario_test/runner.py` (全削除) +> - `scenario_test/cli.py` (全削除 or `pytest` invoker 薄ラッパに縮退) +> - `scenario_test/playwright_executor.py` (login 部分のみ fixture へ移植して全廃) +> - `scenario_test/report.py` (pytest plugin の `pytest_terminal_summary` hook で再実装) +> - `scripts/record_to_yaml.py` (codegen Python をそのまま test ファイルに使うため不要) +> - `scripts/generate_test_plan.py` (pytest 雛形生成版に置換) +> - `templates/testcase-*.yaml.template` 6 ファイル (pytest テスト雛形に置換) +> - `templates/config.example.yaml` (`scenario.config.yaml` ベースに変更) +> - `tests/test_step_schema.py` / `test_locator_steps.py` / `test_record_to_yaml.py` / `test_filter_and_slug.py` / `test_templates.py` (削除して新フィクスチャ向けに書き直し) +> +> ### 残す物 (carry forward, v0.2.5 から) +> +> - `scenario_test/a11y.py` (`scan_page` / `is_available` / `should_auto_scan`) +> - `scenario_test/cwv.py` (`measure_page` / `judge` / `passed`) +> - `scenario_test/evidence.py` の listener / tolerated patterns ロジック (fixture 内に再構成) +> - `scenario_test/hud.py` (HUD overlay JS 一式) +> - `scenario_test/video.py` (webm → mp4 変換) +> - `scripts/upload_evidence.py` (Drive 連携 CLI) +> - `scripts/run_a11y_scan.py` / `check_cwv.py` / `record_scenario.py` (CLI 単発) +> - `scripts/build_gdoc_with_drive_links.py`, `gdrive_upload_dir.py`, `_drive_auth.py`, `upload_md_as_gdoc.py` +> - `docs/` 配下 (方法論ドキュメント、checklists) +> +> ### バージョン bump 予定 +> +> - `pyproject.toml`: `0.2.5` → **`0.3.0`** +> - `plugins/ndf/.claude-plugin/plugin.json`: `4.1.1` → **`4.2.0`** +> - `plugins/ndf/CLAUDE.md` に v4.2.0 セクションを追加 +> +> ### 着手順序の推奨 (本 PLAN17 の段階的実装より具体化) +> +> Phase 1 (Task 1-2): pytest plugin 骨組み + auth fixture を作って、最小 1 テスト (`def test_login(page, ndf_role_admin): page.goto("/")`) が pytest 経由で通ることを確認 +> → ここで一旦 commit (旧 DSL は残したまま新 fixture と両立) +> +> Phase 2 (Task 3-4): evidence + a11y/CWV autouse hook 追加 +> → axe-core 違反を含むダミー HTML で fixture が機能することを確認 +> +> Phase 3 (Task 5-6): HUD overlay + report.md + Drive 連携 を fixture/hook で実装 +> → ここで end-to-end のスモークが完成 +> +> Phase 4 (Task 7): templates / docs / SKILL.md / pyproject.toml / plugin.json を一気に書き直し +> +> Phase 5 (Task 8): **旧 DSL を一括削除**。最後にまとめて削除すると PR の見通しが効く +> +> ### 検証 +> +> 各 Phase 完了ごとに `uv run pytest` を実行し、リグレッションがないことを確認。Phase 5 まで完了したら最終的に `100+ 件 (smoke + unit) 全 pass` を目指す。 +> +> ### 関連リンク +> +> - 親 PR (v0.2.5 transition): https://github.com/takemi-ohama/ai-plugins/pull/56 +> - mergeCommit: `d288f92` +> +> --- + +## 関連リンク + +- 親 PR (v0.2.5 transition): [#56 feat(ndf): playwright-scenario-test ...](https://github.com/takemi-ohama/pull/56) +- 関連プラン: [issues/PLAN15.md](./PLAN15.md) (v0.2.5) +- 廃止: ~~issues/PLAN16.md~~ (Codex レビューで「自前 DSL より pytest 直接利用が OSS 品質として優れる」と判断、PLAN17 に置換) + +## 概要 + +playwright-scenario-test を **自前 YAML DSL → pure pytest-playwright** に全面移行し、OSS リリース品質に到達させる。本リポジトリで投資した locator-first DSL / runner / dispatcher は捨て、pytest エコシステムの恩恵を最大化する。 + +| 項目 | 由来 | +|------|------| +| `scenario_test/` の DSL 層 (testcase.py / locator_steps.py / runner.py / cli.py) を全廃 | Codex 第二意見レビュー (PR #56) | +| pytest-playwright fixture 上で利用者が直接 `def test_xxx(page): ...` を書く形に移行 | OSS quality + IDE 統合 + ecosystem | +| HUD 字幕 / 動画 / Drive / report.md / a11y / CWV / 認証 ヘルパは pytest plugin + fixture として再実装 | 既存資産の再利用 | + +## 採用判断の根拠 + +Codex レビュー (PR #56) で指摘された **Major 7 + Minor 4** のうち、**6 件は pytest-native への移行で自動解決** する: + +| Codex 指摘 | 自前 DSL | pure pytest | +|---|---|---| +| Major-3 step failure 後の続行 | `continue_on_failure` schema 拡張 | **pytest 標準: assertion で test 関数即終了** | +| Major-5 CWV silent PASS | SKIP/UNKNOWN 状態を自前で設計 | **`pytest.skip()` / `pytest.xfail()`** | +| Major-6 trace_relpath が結果モデルに無い | dataclass 拡張 + report.py 修正 | **pytest-playwright 標準 artifact** | +| Major-7 lifecycle 抱え込み | executor を pure 化する大改修 | **fixture として最初から分離** | +| Major-1 record_to_yaml 順序非保証 | `ast.NodeVisitor` で書き直し | **不要: codegen の Python そのまま使う** | +| Major-2 record_to_yaml chain 欠落 | locator chain 表現を schema 拡張 | **不要: codegen 出力をそのまま利用** | + +加えて IDE 統合 (VS Code Test Explorer / JetBrains)、`pytest-html` / `allure-pytest`、`pytest-xdist`、`to_have_screenshot()` (visual regression) などのエコシステムが直接使える。 + +## 設計方針 + +### 利用者は通常の pytest テストを書く + +```python +# tests/test_admin_dashboard.py +import pytest +from playwright.sync_api import Page, expect + +@pytest.mark.page_role("dashboard") +@pytest.mark.role("admin") +def test_admin_kpi_view(page: Page, ndf_role_admin): + page.goto("/admin/dashboard") + expect(page.get_by_role("heading", name="売上サマリ")).to_be_visible() + page.get_by_role("link", name="ユーザ管理").click() + expect(page).to_have_url(lambda u: "/admin/users" in u) +``` + +NDF 提供物は (1) **fixture 群**, (2) **pytest plugin**, (3) **テンプレート**, (4) **CLI script (Drive 連携 / a11y 単発)** に縮退。 + +### 提供物の責務分離 + +| 提供物 | 役割 | +|---|---| +| `scenario_test/pytest_plugin.py` | pytest entry point (fixture / hook / report.md / Drive 連携 / a11y/CWV autouse / HUD overlay) | +| `scenario_test/fixtures/auth.py` | `ndf_config` / `ndf_role_` (login 済み page を返す) | +| `scenario_test/fixtures/evidence.py` | `ndf_evidence` (HAR / trace artifact 統合) | +| `scenario_test/fixtures/a11y.py` | `ndf_a11y_scan` (axe-core, autouse condition: page_role marker) | +| `scenario_test/fixtures/cwv.py` | `ndf_cwv_measure` (Core Web Vitals, autouse condition: page_role marker) | +| `scenario_test/hud.py` | 既存維持 (HUD overlay JS) | +| `scripts/upload_evidence.py` | 既存維持 (CLI: trace/HAR/video Drive アップ) | +| `scripts/run_a11y_scan.py` | 既存維持 (CLI: 単発スキャン) | +| `scripts/check_cwv.py` | 既存維持 (CLI: 単発計測) | +| `templates/conftest.py.template` | 利用者の `tests/conftest.py` 雛形 | +| `templates/test_*.py.template` | 役割別 (auth / list / form / edit) のテスト雛形 | + +## 削除する物 (v0.2.5 で投資した DSL 層) + +- `scenario_test/testcase.py` の `Step` / `LocatorSpec` / `KNOWN_STEP_KINDS` / `discover_testcases` / `filter_testcases` / `parse_filter` +- `scenario_test/locator_steps.py` 全体 +- `scenario_test/runner.py` 全体 +- `scenario_test/cli.py` 全体 (またはごく薄い `pytest` invoker に縮退) +- `scenario_test/playwright_executor.py` 全体 (login 部分のみ fixture へ移植) +- `scenario_test/report.py` 全体 (pytest plugin の terminal_summary hook + 独自 writer に再実装) +- `scripts/record_to_yaml.py` 全体 (codegen Python をそのまま `tests/test_*.py` として保存する) +- `scripts/generate_test_plan.py` 全体 (pytest 雛形生成スクリプトに置換) +- `templates/testcase-*.yaml.template` 6 ファイル +- `templates/config.example.yaml` + +## 残す物 (v0.2.5 から carry forward) + +- `scenario_test/a11y.py` (`scan_page` / `is_available` / `should_auto_scan`) +- `scenario_test/cwv.py` (`measure_page` / `judge` / `passed`) +- `scenario_test/evidence.py` (listener / tolerated patterns ロジックを fixture 内に再構成) +- `scenario_test/hud.py` (HUD overlay JS 一式) +- `scenario_test/video.py` (webm → mp4 変換) +- `scripts/upload_evidence.py` (Drive 連携) +- `scripts/run_a11y_scan.py` (CLI 単発) +- `scripts/check_cwv.py` (CLI 単発) +- `scripts/record_scenario.py` (Playwright codegen 起動) +- `scripts/build_gdoc_with_drive_links.py`, `gdrive_upload_dir.py` (Drive 連携) +- `docs/` 配下 (方法論ドキュメント、checklists) + +## 利用者プロジェクトの構成 + +``` +my-e2e/ +├── pyproject.toml # [tool.pytest.ini_options] markers + ndf-config 設定 +├── conftest.py # NDF plugin の自動 discover (`pytest -p ndf_plugin`) +├── scenario.config.yaml # base_url / roles / a11y/CWV 設定 +└── tests/ + ├── test_admin_dashboard.py + ├── test_user_form.py + └── ... +``` + +利用者が書く Python は通常の pytest テスト。NDF が提供する markers / fixtures を使って role / page_role / a11y を表現する。 + +## タスク分解 + +### Task 1: pytest plugin 骨組み + +- **対象**: `scenario_test/pytest_plugin.py` (新規), `pyproject.toml` (`[project.entry-points."pytest11"]`) +- **変更内容**: + - `pytest_addoption(parser)`: `--ndf-config` / `--ndf-out-dir` / `--ndf-no-evidence` 等 + - `pytest_configure(config)`: `ndf_config` を読み込み Session に保存 + - markers の登録: `page_role(*roles)` / `role(role_id)` / `phase(num)` / `priority(level)` + - `pytest_runtest_setup(item)` で marker 検査 + +### Task 2: 認証 fixture (`fixtures/auth.py`) + +- **対象**: `scenario_test/fixtures/auth.py` (新規), `scenario_test/pytest_plugin.py` +- **変更内容**: + - `ndf_config` fixture: `Config.load(...)` を session scope で + - 各 role に対し `ndf_role_` fixture を動的生成 (login 済み storage_state を function scope で渡す) + - storage_state の caching (1 session 内で同じ role はログインを 1 回に減らす) + +### Task 3: evidence fixture + autouse hook + +- **対象**: `scenario_test/fixtures/evidence.py` (新規), `scenario_test/pytest_plugin.py` +- **変更内容**: + - `ndf_evidence` fixture: HAR / trace 設定を `browser_context_args` に inject + - `pytest_runtest_makereport(item, call)` で FAIL 時に trace 保存パスを confirm + - tolerated_console_errors / tolerated_page_errors を listener として attach (現 `evidence.py` ロジック流用) + +### Task 4: a11y / CWV を marker autouse 化 + +- **対象**: `scenario_test/fixtures/a11y.py` (新規), `scenario_test/fixtures/cwv.py` (新規) +- **変更内容**: + - `@pytest.mark.page_role("form")` が付与された test の終了直前に axe-core を自動実行 + - 同様に CWV を計測 + - 違反は `pytest.fail()` (default) または `pytest.skip()` で SKIP 扱い (config 切替) + +### Task 5: HUD overlay を `browser_context_args` で注入 + +- **対象**: `scenario_test/pytest_plugin.py` +- **変更内容**: + - `browser_context_args` fixture を override し、`hud.HUD_INIT_SCRIPT` を `add_init_script` で全 page に inject + - 字幕の更新 API (`set_caption`) を fixture 経由で公開 → 利用者がオプションで叩ける + - default は HUD なしで OK (動画必要なときだけ `--ndf-hud` で有効化) + +### Task 6: report.md 生成 + Drive 連携 + +- **対象**: `scenario_test/pytest_plugin.py`, `scenario_test/report.py` (再実装、薄く) +- **変更内容**: + - `pytest_terminal_summary(terminalreporter, exitstatus, config)` で全 test の result を収集 + - 既存 report.md と同等のフォーマットで `reports//report.md` を生成 + - `--ndf-drive-folder=` 指定時は `pytest_sessionfinish` で `upload_evidence.py` の関数を直接呼び、Drive アップロード + リンク差し込み + +### Task 7: テンプレート / docs / scripts 整理 + +- **対象**: `templates/conftest.py.template`, `templates/test_*.py.template` 4-5 ファイル, `templates/scenario.config.yaml`, `scripts/generate_test_plan.py` (pytest 雛形生成版に置換), `SKILL.md` 全面書き直し, `docs/04-playwright-mapping.md` 更新, `docs/05-bug-report.md` 更新, `pyproject.toml` (version 0.2.5 → 0.3.0), `plugins/ndf/.claude-plugin/plugin.json` (4.1.1 → 4.2.0), `plugins/ndf/CLAUDE.md` v4.2.0 セクション +- **変更内容**: + - 利用者向け: pytest 直書き例 / `pytest -m "page_role:form"` / `pytest -n 4` / `pytest --html=` 等の標準パイプライン + - SKILL.md は「pytest 採用 + NDF が提供する fixture/marker」を中心に書き直す + - docs は locator-first 表現を pytest コードでそのまま例示 (DSL 言及を全削除) + +### Task 8: 旧 DSL 削除 + テスト全置換 + +- **対象**: 上記「削除する物」リストを実行、`tests/` も新フィクスチャ向けに書き直し +- **変更内容**: + - 単体テストは `pure 関数` (a11y.judge / cwv.judge / detect_kind / detect_mime / hud constants 等) のみ残す + - 統合テストとして tmp http server + pytest-playwright で `tests/integration/test_smoke.py` を 1 件追加 (本来 OSS だと CI を望むため) + - 削除対象テスト: `test_step_schema.py` / `test_locator_steps.py` / `test_record_to_yaml.py` / `test_filter_and_slug.py` (parse_filter / slugify 部分は廃止) / `test_templates.py` (templates 自体が変わるため作り直し) + +## 影響範囲 + +- **互換性**: v0.2.5 → v0.3.0 で完全な breaking change。利用者は YAML を書き直す必要あり (誰も使っていない前提なので問題なし) +- **依存追加 (main)**: `pytest>=8.0`, `pytest-playwright>=0.5`, `pytest-xdist>=3.0` +- **依存削除**: なし (既存依存はすべて流用) +- **CI**: pytest 標準なので GitHub Actions で簡単に回せる (`uv run pytest -n auto`) + +## テスト計画 + +- [ ] `uv run pytest tests/unit/` で pure 関数テスト (a11y.judge / cwv.judge / detect_mime / hud / video) が **全 pass** +- [ ] `uv run pytest tests/integration/test_smoke.py` で local http server (`http.server` で 1 ページ起動) に対する end-to-end (login → click → expect) が pass +- [ ] `pytest -p ndf.pytest_plugin -n 4 tests/` で並列 4 worker で smoke tests が pass +- [ ] `pytest --ndf-drive-folder=` でテスト後に Drive 連携が動作 (手動確認) +- [ ] `pytest -m "page_role(form)"` で form テストのみ実行 +- [ ] FAIL ケースで axe-core 違反が `pytest --html` レポートにも出る +- [ ] `playwright codegen → tests/test_.py` への手順が SKILL.md に明記、実例で動作確認 + +## 段階的実装 + +1. **Phase 1**: Task 1 + 2 (plugin 骨組み + auth fixture) — 単一 test がログイン + assertion で動くこと +2. **Phase 2**: Task 3 + 4 (evidence + a11y/CWV autouse) — failed test で trace/axe が自動保存されること +3. **Phase 3**: Task 5 (HUD) + Task 6 (report.md + Drive) — エンドツーエンドで本物のレポート生成 +4. **Phase 4**: Task 7 (templates + docs + version) — リリース直前 +5. **Phase 5**: Task 8 (旧 DSL 削除) — 最後にまとめて削除し、PR を分かりやすく + +## v0.4.0 以降 (本 PR 範囲外) + +- pytest-html / allure-pytest 連携 (PR option として) +- Visual regression (`expect(page).to_have_screenshot()`) サポート +- bug report 自動生成 (Codex 指摘の旧 PLAN16 Task 4) — pytest hook で容易に追加可能 diff --git a/issues/PLAN18.md b/issues/PLAN18.md new file mode 100644 index 00000000..640fbd2f --- /dev/null +++ b/issues/PLAN18.md @@ -0,0 +1,288 @@ +# NDF plugin / playwright-scenario-test に `body_check` 機能の復活を依頼 + +- 起票日: 2026-04-27 +- 対象 Skill: `ndf:playwright-scenario-test` (v0.3.0+, pytest-playwright ベース) +- Skill 物理パス: `/home/ubuntu/.claude/plugins/cache/ai-plugins/ndf/4.2.0/skills/playwright-scenario-test/` +- 関連 Issue / PR: [uttaro-dev2/uttarov2-doc#56](https://github.com/uttaro-dev2/uttarov2-doc/pull/56) (prd-standby 動作確認テスト pytest 移植) +- 旧版 (検出ロジックあり): `scripts/prd-standby-test/` (自前 YAML runner) — 2026-04-25 報告書 [`prd-standby-vulnerability-scan-build-2026-04-25.md`](../reports/prd-standby-vulnerability-scan-build-2026-04-25.md) +- 新版 (検出ロジックなし): `scripts/prd-standby-test-pytest/` (pytest-playwright Skill 移植版) + +## 要約 + +playwright-scenario-test v0.2.x (自前 YAML runner) には **ページ本文の特定パターン検査 (`body_check`)** があり、Uttaro (PHP 7.2 → 8.4 移行中) でフロントに漏れる `Fatal error` / `Uncaught` / `STRICT:` などの PHP エラーを **テスト失敗として検出** していた。v0.3.0 で pytest-playwright 化された際にこの検査が落ち、**console.error / pageerror のみ**に置き換わった。PHP のサーバ側生成 HTML に混入したエラー文字列はブラウザ console には出ないため、**現行 Skill では実不具合を見逃す**。 + +実際に prd-standby (https://e-chusya.com:20444) で 2 件の現存不具合を旧版は検出しており、本 PR (uttarov2-doc#56) の pytest 移植版では検出できないことを確認した。 + +## 背景 + +### v0.2.x の `body_check` (旧 `scripts/prd-standby-test/config.yaml`) + +旧 config.yaml には以下の宣言があり、各ナビゲーション完了後にレスポンス本文を検査して FAIL 判定していた: + +```yaml +# --- ページ本文エラー検出 (PHP プロジェクト固有) ---------------------- +body_check: + fatal_patterns: + - "Fatal error" + - "Uncaught" + - "Parse error" + warning_patterns: + # ページ先頭 300 文字に出ていれば「ページが壊れた」とみなす + - "STRICT:" + - "Warning:" + - "Notice:" + - "Deprecated:" + not_found_strings: + - "File not found" +``` + +判定ロジック (旧 runner の挙動): + +| パターン群 | 検査範囲 | 判定 | +|---|---|---| +| `fatal_patterns` | レスポンス本文全体 | 1 つでも含まれれば FAIL | +| `warning_patterns` | 本文の **先頭 300 文字のみ** | 1 つでも含まれれば FAIL (本文中の説明文や入力例の "Notice:" 等は許容、ページ最上段への漏れだけ拾う) | +| `not_found_strings` | レスポンス本文全体 | 1 つでも含まれれば FAIL | + +### v0.3.0 (pytest-playwright) で残った検査 + +`scenario_test/fixtures/evidence.py` の `NdfEvidence` は以下を `page.on(...)` listener で収集: + +- `console.error` (= ブラウザ devtools の error log) +- `pageerror` (= unhandled JavaScript exception) + +許容パターンは `tolerated_console_errors` / `tolerated_page_errors` (config.yaml) で正規表現指定可能。 + +→ これらは **JavaScript ランタイム由来のエラー** しか拾わない。**PHP がサーバ側で HTML 本文に出力したエラー文字列**はブラウザから見れば単なる文字なので、console にも pageerror にも上がらない。結果として旧版で検出していた本文混入系不具合がスルーされる。 + +## 実機エビデンス (prd-standby 2026-04-27 検査結果) + +各ロールでログイン後、各画面を直接 GET してレスポンス HTML 中の PHP パターン出現を確認した。検査スクリプト: 別記 (再現コードは末尾参照)。 + +### 検出された不具合 2 件 + +| # | ロール | URL | 検出箇所 | 検出パターン | +|---:|---|---|---|---| +| 1 | system (管理者) | `/system/user.php` (患者一覧) | ページ先頭 1000 文字以内 | `STRICT:` | +| 2 | clinic (クリニック代表) | `/clinic/ClinicUttaroSettingEdit.php` (Uttaro設定) | ページ先頭 1000 文字以内 | `Fatal error`, `Uncaught` | + +スキャナ出力 (該当部分のみ抜粋): + +``` +=== system (post-login: https://e-chusya.com:20444/system/TopPage.php) === +[system] /system/TopPage.php clean +[system] /system/clinic.php clean +[system] /system/user.php HIT(head): ['STRICT:'] +[system] /system/VaccineMaster.php clean +[system] /system/Counting.php clean +[system] /system/CertificateCount.php clean +[system] /system/MotherChildNotebook.php clean + +=== clinic (post-login: https://e-chusya.com:20444/clinic/Reserve/Calendar.php) === +[clinic] /clinic/Reserve/Calendar.php clean +[clinic] /clinic/ClinicPatient.php clean +[clinic] /clinic/ClinicInfoEdit.php clean +[clinic] /clinic/ClinicUttaroSettingEdit.php HIT(head): ['Fatal error', 'Uncaught'] +[clinic] /clinic/Stock.php clean +[clinic] /clinic/Notice.php clean +[clinic] /clinic/CertificateCount.php clean +``` + +### 旧版 (scripts/prd-standby-test, 2026-04-25 実行) での同等検出 + +`scripts/prd-standby-test/reports/20260425-125122/report.md` 抜粋: + +``` +| `TC-10` | 10 | system | FAIL | 13/14 | 65.6s | 管理者 - 全機能シナリオ ... | +| 6 | [03] 患者一覧 | FAIL | status=200 / ct=text/html / final_url=...system/user.php / + PHP STRICT: 警告がページ先頭に漏れています | +``` + +旧版 runner は `body_check.warning_patterns: ["STRICT:"]` を先頭 300 文字に対して当て、`/system/user.php` の `STRICT:` 漏れを **テスト失敗として明示的に表示** していた。新 Skill (pytest-playwright 移植版) で同 URL を `page.goto` した結果は `200 OK` でテスト PASS となり、不具合は検出されない。 + +→ **検出能力が後退した = 移植版に乗せ換えると本番事故を見逃す可能性がある**。 + +## 依頼内容 (Ask) + +NDF plugin の `playwright-scenario-test` Skill に **body_check 相当の機能** を復活させて頂きたい。具体的には以下: + +### 1. config.yaml への schema 追加 + +```yaml +# --- ページ本文エラー検出 (PHP / SSR プロジェクト向け) --------------- +body_check: + enabled: true + fatal_patterns: # 全文検索 (どこに出ても致命) + - "Fatal error" + - "Uncaught" + - "Parse error" + warning_patterns: # 先頭 N 文字検索 (本文中の説明文は許容) + - "STRICT:" + - "Warning:" + - "Notice:" + - "Deprecated:" + warning_head_bytes: 300 # warning_patterns の検査範囲 + not_found_patterns: # 全文検索 + - "File not found" + fail_on_match: true # false で warning 出力のみ (情報収集モード) +``` + +### 2. fixture / API 設計案 + +(A) **autouse fixture で全 page を監視** — Network listener で `response` イベントを取って HTML 本文を検査。利用者は何もしなくても全 navigation で自動チェック。Playwright の `page.on("response", ...)` が使える: + +```python +@pytest.fixture(autouse=True) +def _ndf_body_check(page, ndf_config): + if not ndf_config.body_check.enabled: + yield; return + violations = [] + def _on_resp(resp): + ctype = resp.headers.get("content-type", "") + if not ctype.startswith("text/html"): return + try: body = resp.text() + except Exception: return + # fatal/warning/not_found 判定 → violations に積む + page.on("response", _on_resp) + yield + if violations and ndf_config.body_check.fail_on_match: + pytest.fail("body_check violations: " + str(violations[:5])) +``` + +(B) **明示的ヘルパ fixture** — `ndf_body_check(page)` を user code から呼ぶ形。autouse より明示性高いが利用側コードが増える: + +```python +def test_xxx(page: Page, ndf_role_admin, ndf_body_check): + page.goto(...) + ndf_body_check(page) # 1 ナビゲーションごと +``` + +(C) **pytest marker** — `@pytest.mark.body_check` が付いた test だけ autouse 化。明示性と利便性のバランス。 + +→ おすすめは **(A) autouse + config 経由 enable/disable** + **(C) marker で個別 opt-out**。autouse なので既存テスト 0 行修正で動き、必要なら `@pytest.mark.no_body_check` で skip。 + +### 3. report.md への反映 + +- nodeid 行に `body_check.violations` 数カラムを追加 +- 検出時の詳細セクション (URL / パターン / 該当箇所スニペット) を report.md に書き出す +- evidence ディレクトリに `body_check.jsonl` (1 navigation = 1 line) で生のヒット情報を保存 + +### 4. 互換性 / マイグレーション + +- 既存利用者向けには `body_check.enabled: false` (default) で **opt-in 機能**にする — 突然 PASS していたテストが FAIL し始めるのを防ぐ +- 旧 v0.2.x 利用者向けに `scripts/migrate_body_check.py` のような config converter があるとなお良い + +## 当面の workaround (uttaro 側) + +uttarov2-doc#56 では本機能が無いため、project 側 `conftest.py` に同等のヘルパを書いて `page.goto` 後に明示的に呼ぶ形で代替する予定。コード提案は以下: + +```python +# scripts/prd-standby-test-pytest/conftest.py (workaround until plugin supports) +_PHP_FATAL_PATTERNS = ("Fatal error", "Uncaught", "Parse error") +_PHP_WARNING_PATTERNS = ("STRICT:", "Warning:", "Notice:", "Deprecated:") +_NOT_FOUND_STRINGS = ("File not found",) +_WARNING_HEAD_BYTES = 300 + + +@pytest.fixture +def assert_no_php_leak(): + def _check(page: Page) -> None: + body = page.content() + head = body[:_WARNING_HEAD_BYTES] + for pat in _PHP_FATAL_PATTERNS: + assert pat not in body, f"{page.url}: PHP fatal '{pat}'" + for pat in _PHP_WARNING_PATTERNS: + assert pat not in head, f"{page.url}: PHP warning '{pat}' on head" + for s in _NOT_FOUND_STRINGS: + assert s not in body, f"{page.url}: '{s}'" + return _check + + +@pytest.fixture +def nav(ndf_config, assert_no_php_leak): + def _nav(page: Page, path: str, *, delay_ms: int | None = None) -> None: + page.goto(f"{ndf_config.base_url}{path}", wait_until="domcontentloaded") + assert_no_php_leak(page) + page.wait_for_timeout( + ndf_config.playwright.step_delay_ms if delay_ms is None else delay_ms + ) + return _nav +``` + +→ test 関数は `nav(page, "/clinic/ClinicUttaroSettingEdit.php")` で 1 行ずつ呼ぶ。 plugin が body_check を提供したら、本 workaround は廃棄して plugin 機能に置き換える。 + +## 再現スクリプト (PHP パターン scan) + +```python +# 1 度実行すれば旧版 body_check 相当の HIT 一覧が得られる +import time +from playwright.sync_api import sync_playwright + +PATTERNS = ["Fatal error", "Uncaught", "Parse error", + "STRICT:", "Warning:", "Notice:", "Deprecated:"] +PATHS = { + "system": ["/system/TopPage.php","/system/clinic.php","/system/user.php", + "/system/VaccineMaster.php","/system/Counting.php", + "/system/CertificateCount.php","/system/MotherChildNotebook.php"], + "clinic": ["/clinic/Reserve/Calendar.php","/clinic/ClinicPatient.php", + "/clinic/ClinicInfoEdit.php","/clinic/ClinicUttaroSettingEdit.php", + "/clinic/Stock.php","/clinic/Notice.php","/clinic/CertificateCount.php"], + "user": ["/user/mypage.php","/user/profile.php","/user/history.php", + "/user/history_healthcheck.php","/user/ClinicList.php"], +} +BASE = "https://e-chusya.com:20444" + +def goto_with_retry(page, url, retries=3): + for i in range(retries): + try: + page.goto(url, wait_until="domcontentloaded", timeout=30000) + return True + except Exception: + time.sleep(2) + return False + +with sync_playwright() as p: + for role, login_path, fields, paths in [ + ("system", "/system/login.php", + {"LoginID":"sysstaff0@mail.jp","Password":"qsexefm27uda"}, + PATHS["system"]), + ("clinic", "/clinic/login.php", + {"ClinicAccountID":"77363","Password":"qsexefm27uda"}, + PATHS["clinic"]), + ("user", "/user/login.php", + {"LoginID":"user247135@mail.jp","Password":"qsexefm27uda"}, + PATHS["user"]), + ]: + kwargs = {"ignore_https_errors": True} + if role == "system": + kwargs["http_credentials"] = {"username":"testuser_202604", + "password":"testuser_202604"} + b = p.chromium.launch(headless=True) + ctx = b.new_context(**kwargs) + page = ctx.new_page() + page.goto(BASE+login_path, wait_until="domcontentloaded") + for k,v in fields.items(): + page.locator(f'input[name="{k}"]').fill(v) + with page.expect_navigation(wait_until="domcontentloaded"): + try: + page.locator('form input[type="image"]').first.click(timeout=2000) + except Exception: + page.locator('input[name="Password"]').press("Enter") + for path in paths: + if not goto_with_retry(page, BASE+path): + print(f"[{role}] {path} GOTO FAILED"); continue + body = page.content(); head = body[:1000] + hits = [pat for pat in PATTERNS if pat in head] \ + or [pat for pat in PATTERNS if pat in body] + print(f"[{role}] {path} {hits or 'clean'}") + b.close() +``` + +## 関連リンク + +- 移植 PR (検出能力後退の現場): [uttaro-dev2/uttarov2-doc#56](https://github.com/uttaro-dev2/uttarov2-doc/pull/56) +- 旧版 (body_check 動作実績あり): [`scripts/prd-standby-test/`](../scripts/prd-standby-test/) + - config schema: [`scripts/prd-standby-test/config.example.yaml`](../scripts/prd-standby-test/config.example.yaml) 81-96 行 + - 実行レポート: `scripts/prd-standby-test/reports/20260425-125122/report.md` +- 新 Skill SKILL.md: `/home/ubuntu/.claude/plugins/cache/ai-plugins/ndf/4.2.0/skills/playwright-scenario-test/SKILL.md` +- 関連 console/pageerror 機構: `scenario_test/fixtures/evidence.py` (250 行〜) diff --git a/issues/PLAN19.md b/issues/PLAN19.md new file mode 100644 index 00000000..d57ff5db --- /dev/null +++ b/issues/PLAN19.md @@ -0,0 +1,484 @@ +# PLAN19: playwright-scenario-test の Skill 非依存化(プロジェクト自己完結化) + +- 起票日: 2026-04-29 +- 対象 Skill: `ndf:playwright-scenario-test` (現行 v0.4.0) +- 対象パス: `plugins/ndf/skills/playwright-scenario-test/` +- 関連: PLAN17 (pure pytest-playwright 完全移行) / PLAN18 (body_check 復活) + +## 背景・課題 + +現行 v0.4.0 の構造では、**テスト実行は Skill ディレクトリ配下の uv プロジェクトに依存**している。 +具体的には: + +- `scenario_test/` パッケージ本体(pytest plugin / fixtures / a11y / cwv / body_check / report、 + v0.5.0 で `playwright_kit` にリネーム予定)は Skill ディレクトリにのみ存在 +- `pyproject.toml` / `uv.lock` も Skill ディレクトリ側にあり、`[project.entry-points."pytest11"]` + で pytest plugin を登録している +- 利用者は SKILL.md の手順に従い、**Skill ディレクトリで `uv sync` してから利用者プロジェクトで + pytest を呼ぶ**運用 — 結果として: + 1. Skill が消えるとテストが動かない(CI / 別マシン / Skill 非導入のメンバー環境で破綻) + 2. uv プロジェクトが Skill 側にあるため、利用者プロジェクトの依存と分離されており再現性が低い + 3. Drive 連携 / a11y / CWV 等の補助スクリプトもパス参照が Skill ディレクトリ前提 + +これにより、テスト**作成時**だけでなく**実行時・メンテ時**まで Skill の存在に縛られている。 +本来 Skill は「テストを書き始めるためのスキャフォルダ」であり、初期化後は利用者プロジェクト +単体で動作するべき。 + +## ゴール + +1. **必要なスクリプト・実行環境(uv)をすべて利用者プロジェクトにコピー**して自己完結させる +2. **実行用バッチファイル(sh / bat)を 1 本実行するだけ**でシナリオテストが走る形にする +3. Skill が削除された環境(マシン移行 / CI / 別開発者環境)でも継続して動作する + +## 設計方針 + +### 1. プロジェクト配置構造(init 後) + +利用者プロジェクト直下に `scenario-test/` ディレクトリ(**default 名、変更可能**)を 1 つだけ +作成し、**テスト・設定・レポート・ランチャを含むすべてをその中に集約**する。利用者プロジェクト +直下を汚さない(ルートに散らばらない)。 + +``` +your-app/ ← 利用者プロジェクト(ルートに余計なファイルを置かない) +└── scenario-test/ ← all-in-one ランタイム(名前変更可) + ├── playwright_kit/ ← Python パッケージ本体(旧 scenario_test、リネーム) + │ ├── pytest_plugin.py + │ ├── fixtures/ + │ ├── accessibility.py ← 旧 a11y.py(axe-core ランナー) + │ ├── web_vitals.py ← 旧 cwv.py(LCP/CLS/TTFB 計測) + │ ├── body_check.py ← PHP/SSR エラー検出 + │ ├── overlay.py ← 旧 hud.py(赤丸カーソル+字幕) + │ ├── pytest_report.py + │ └── ... + ├── scripts/ ← CLI 補助スクリプト(classify_page_role / a11y / cwv / drive 等) + ├── tests/ ← 利用者が書く pytest テスト + │ ├── conftest.py + │ └── test_*.py + ├── reports/ ← 実行結果(gitignore 推奨) + ├── scenario.config.yaml ← 利用者の設定(base_url / roles / a11y / CWV 等) + ├── run.sh ← Linux/Mac 用ワンコマンドランチャ + ├── run.bat ← Windows 用ワンコマンドランチャ + ├── pyproject.toml ← runtime 用に整形済(testpaths=tests) + ├── uv.lock ← lock 同梱で再現性確保 + ├── README.md ← init 後の使い方(Skill なしでも読める) + └── .gitignore ← .venv / __pycache__ / reports/ +``` + +#### 1.2 package 名 / fixture prefix の整理 (v0.5.0 で実施・破壊的変更) + +**目的**: 本改修の主眼は **NDF Skill から切り離してプロジェクト単独で動作させること**。 +これに合わせて、NDF 由来の名前空間 (`scenario_test` 由来 / `ndf_*` 由来) を**汎用的な +`playwright_kit` (`pwk_*`) 系**に置き換える。 + +**Layer 別 rename 表**: + +| Layer | 旧 | 新 | +|---|---|---| +| Skill 名 | `playwright-scenario-test` | (変更なし) | +| ランタイムディレクトリ名 (default) | `scenario-test/` | (変更なし、名前変更可) | +| Python パッケージ名 | `scenario_test` | **`playwright_kit`** | +| pytest entry-point ID | `ndf-scenario-test` | **`playwright-kit`** | +| import path | `from scenario_test.X import Y` | `from playwright_kit.X import Y` | +| pytest_plugins 列挙 | `"scenario_test.fixtures.auth"` etc | `"playwright_kit.fixtures.auth"` etc | + +**fixture / CLI option / env var の rename 表** (`ndf_*` / `--ndf-*` / `NDF_*` を除去): + +| 種別 | 旧 | 新 | +|---|---|---| +| session fixture | `ndf_config` | `pwk_config` | +| dynamic fixture | `ndf_role_` | `pwk_role_` | +| function fixture | `ndf_evidence` | `pwk_evidence` | +| function fixture | `ndf_out_dir` | `pwk_out_dir` | +| helper fixture | `ndf_a11y_scan` | `pwk_accessibility_scan` (Phase 0a で `a11y` → `accessibility`) | +| helper fixture | `ndf_cwv_measure` | `pwk_web_vitals_measure` (Phase 0a で `cwv` → `web_vitals`) | +| helper fixture | `ndf_body_check_scan` | `pwk_body_check_scan` | +| CLI option | `--ndf-config` | `--pwk-config` | +| CLI option | `--ndf-out-dir` | `--pwk-out-dir` | +| CLI option | `--ndf-no-evidence` | `--pwk-no-evidence` | +| CLI option | `--ndf-hud` | `--pwk-overlay` (Phase 0a で `hud` → `overlay`) | +| CLI option | `--ndf-drive-folder` | `--pwk-drive-folder` | +| pytest group | `parser.getgroup("ndf", ...)` | `parser.getgroup("pwk", ...)` | +| env var | `NDF_CONFIG` | `PWK_CONFIG` | +| 内部クラス | `NdfTestEntry` | `PwkTestEntry` | + +**変更しないもの** (汎用名 / pytest 標準寄りのもの): + +- `@pytest.mark.page_role` / `role` / `phase` / `priority` / `no_body_check` markers + — 既に NDF 中立。プロジェクト固有名ではないのでそのまま +- `scenario.config.yaml` ファイル名 — 利用者が編集する設定ファイル +- `pyproject.toml` の `[tool.pytest.ini_options]` 設定群 + +採用理由: + +- **all-in-one**: 1 ディレクトリ移動でシナリオテスト一式が完結。利用者プロジェクトのルートを + 汚染しない。複数のテストランタイムを別名 (`e2e/` / `regression/` 等) で同居させやすい +- **コピーで持ち込む**(git submodule / pip install パッケージは採用しない) + - Skill 非依存・オフライン動作・後から手動編集可能 +- **`pyproject.toml` を runtime 内に置く**ことで利用者の Python プロジェクトの依存と分離 + - `cd scenario-test && uv sync` で独立した `.venv` が得られる + - `[tool.pytest.ini_options].testpaths = ["tests"]` を維持し、相対パス `./tests` がそのまま効く +- pytest plugin は **runtime/.venv 内に editable install** で entry-point を効かせる + (現行 v0.4.0 と挙動完全一致) +- 利用者の通常の pytest ランナー(IDE 統合等)からも `cd scenario-test && pytest` で動く + +#### 1.1 ランタイム実行とディレクトリ名のカスタマイズ + +利用者は以下のいずれかで実行する: + +```bash +# A) ランタイムディレクトリに入って実行 +cd scenario-test && ./run.sh + +# B) プロジェクトルートから相対パスで実行(推奨:CWD = ランタイム内に統一される) +./scenario-test/run.sh +``` + +ランチャは `$(dirname "${BASH_SOURCE[0]}")` で自身の位置を解決するため、どちらでも +CWD を `scenario-test/` 内に固定して pytest を起動する → `tests/` / `reports/` / +`scenario.config.yaml` がすべて相対パスで参照可能。 + +ランタイムディレクトリ名は **init 時の `--runtime-dir ` で変更可能**: + +```bash +# default 名で init +scripts/init_project.sh /path/to/your-app +# → your-app/scenario-test/ 一式 + +# 任意名で init(既存ディレクトリと衝突回避 / 複数ランタイム共存) +scripts/init_project.sh /path/to/your-app --runtime-dir e2e +# → your-app/e2e/ 一式 (中の run.sh / pyproject.toml は同じ) + +# 複数ランタイム共存例 +scripts/init_project.sh /path/to/your-app --runtime-dir e2e-prod +scripts/init_project.sh /path/to/your-app --runtime-dir e2e-staging +# → your-app/e2e-prod/run.sh / your-app/e2e-staging/run.sh をそれぞれ独立に実行 +``` + +ランチャ自身は **ディレクトリ名に依存しない**(自分の位置から相対で動く)ため、 +リネームや複数共存に追加の設定は不要。env による上書きや `.scenario-test.env` のような +中央設定ファイルは all-in-one 構成では不要なので**削除**する。 + +### 2. ワンコマンドランチャ(run.sh / run.bat) + +ランタイムディレクトリ内に置かれる `run.sh` は以下を順に実行する。 + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# このスクリプト自身が置かれているディレクトリ = ランタイムルート +RUNTIME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# 1) uv の存在確認 +command -v uv >/dev/null 2>&1 || { + echo "uv が見つかりません。https://docs.astral.sh/uv/ を参照してインストールしてください。" + exit 1 +} + +# 2) ランタイムの依存セットアップ(.venv が無ければ作る) +if [[ ! -d "$RUNTIME_DIR/.venv" ]]; then + echo "[init] uv sync ($RUNTIME_DIR) ..." + (cd "$RUNTIME_DIR" && uv sync) + echo "[init] playwright install chromium ..." + (cd "$RUNTIME_DIR" && uv run playwright install chromium) +fi + +# 3) pytest 実行(CWD = ランタイム内、tests/ / reports/ / scenario.config.yaml を相対参照) +cd "$RUNTIME_DIR" +uv run pytest \ + --pwk-config="${PWK_CONFIG:-./scenario.config.yaml}" \ + "$@" +``` + +ポイント: + +- **CWD をランタイム内に固定**するため、利用者プロジェクトのどこから呼んでも挙動が同じ + (`./scenario-test/run.sh` でも `cd scenario-test && ./run.sh` でも結果一致) +- 初回のみ `uv sync` + `playwright install chromium` を自動実行 +- 出力先 `reports/` はランタイム内に作られる(pytest plugin の `--ndf-out-dir` default を活かす) +- `$@` で追加引数(`-k name` / `-m page_role` / `--ndf-drive-folder=...` 等)を素通し +- `.bat` 版も同等の処理を Windows シェルで提供(`%~dp0` でスクリプト自身の位置を解決) + +### 3. 初期化スクリプト(Skill 内 `scripts/init_project.sh`) + +Skill 側に初期化スクリプトを 1 本だけ追加し、Claude Code セッション中で +`./init_project.sh /path/to/your-app [--runtime-dir ]` を呼ぶことで上記構造を作る。 + +```bash +#!/usr/bin/env bash +# 使い方: scripts/init_project.sh [--runtime-dir ] [--dry-run] +set -euo pipefail + +PROJECT_ROOT="" +RUNTIME_DIR_NAME="scenario-test" # default +DRY_RUN=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --runtime-dir) RUNTIME_DIR_NAME="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + -*) echo "unknown option: $1"; exit 1 ;; + *) PROJECT_ROOT="$1"; shift ;; + esac +done +[[ -n "$PROJECT_ROOT" ]] || { echo "PROJECT_ROOT は必須です"; exit 1; } + +SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)" +RUNTIME_DIR="$PROJECT_ROOT/$RUNTIME_DIR_NAME" + +mkdir -p "$RUNTIME_DIR/tests" + +# 1) ランタイム本体をコピー(.venv / .pytest_cache / reports は除外、tests は雛形を別途) +rsync -a \ + --exclude='.venv' --exclude='__pycache__' --exclude='.pytest_cache' \ + --exclude='reports' --exclude='tests' \ + "$SKILL_DIR/playwright_kit" "$SKILL_DIR/scripts" \ + "$SKILL_DIR/uv.lock" \ + "$RUNTIME_DIR/" + +# 2) runtime 用 pyproject / ランチャ / .gitignore / README をコピー +cp "$SKILL_DIR/templates/pyproject.toml.runtime" "$RUNTIME_DIR/pyproject.toml" +cp "$SKILL_DIR/templates/run.sh" "$RUNTIME_DIR/run.sh" +cp "$SKILL_DIR/templates/run.bat" "$RUNTIME_DIR/run.bat" +cp "$SKILL_DIR/templates/runtime-gitignore" "$RUNTIME_DIR/.gitignore" +cp "$SKILL_DIR/templates/runtime-README.md" "$RUNTIME_DIR/README.md" +chmod +x "$RUNTIME_DIR/run.sh" + +# 3) 利用者編集物(既存があれば上書きしない) +[[ -f "$RUNTIME_DIR/scenario.config.yaml" ]] || \ + cp "$SKILL_DIR/templates/scenario.config.yaml" "$RUNTIME_DIR/scenario.config.yaml" +[[ -f "$RUNTIME_DIR/tests/conftest.py" ]] || \ + cp "$SKILL_DIR/templates/conftest.py.template" "$RUNTIME_DIR/tests/conftest.py" +# テスト雛形(初回のみコピー) +for tmpl in test_auth test_list test_form test_dashboard; do + [[ -f "$RUNTIME_DIR/tests/${tmpl}.py" ]] || \ + cp "$SKILL_DIR/templates/${tmpl}.py.template" "$RUNTIME_DIR/tests/${tmpl}.py" +done + +# 4) 初回 uv sync + playwright install +(cd "$RUNTIME_DIR" && uv sync && uv run playwright install chromium) + +echo "[done] init 完了。次回以降は ./$RUNTIME_DIR_NAME/run.sh で実行できます。" +``` + +`init_project.bat` も同等処理(`xcopy` ベース、`--runtime-dir` 引数対応)で用意。 + +### 4. `pyproject.toml` の整形(コピー版) + +現行 `pyproject.toml` から **Skill 開発時のみ必要な要素を排除**してコピー版を生成する。 + +- `[tool.pytest.ini_options].testpaths = ["tests"]` は **そのまま維持** + → all-in-one 構成では `tests/` がランタイム直下にあるため相対パスがそのまま効く +- `[tool.pytest.ini_options].addopts = "-q"` 等の Skill 開発用設定は利用者向けに調整 + (`-q` は維持してよい) +- `name = "playwright-scenario-test"` を `name = "playwright_kit"` へ rename + (Python パッケージ名と一致させ、利用者プロジェクトと混同しないため) +- `[project.optional-dependencies].dev` セクションを削除(Skill 開発時のみ必要) +- hatch wheel 設定 を `[tool.hatch.build.targets.wheel] packages = ["playwright_kit"]` に更新 +- `[project.entry-points."pytest11"]` を `playwright-kit = "playwright_kit.pytest_plugin"` に更新 + → `uv sync` 後の editable install で plugin が自動 discover される + +Skill リポジトリに **`templates/pyproject.toml.runtime`** として整形済みコピーを置き、 +init 時はそれを `/pyproject.toml` として配置する(現行 `pyproject.toml` は Skill 開発用 +として残す)。 + +### 5. SKILL.md の改訂 + +現行 SKILL.md は「Skill ディレクトリで uv sync」前提の手順だが、以下に改める: + +```markdown +## クイックスタート + +1) このディレクトリで `scripts/init_project.sh /path/to/your-app` を実行 + (Windows は `scripts\init_project.bat`) + オプション: `--runtime-dir ` で配置先ディレクトリ名を変更可能 +2) `your-app/scenario-test/scenario.config.yaml` の base_url / roles を編集 +3) `your-app/scenario-test/tests/test_*.py` を書く(init 時に雛形が配置される) +4) `your-app/scenario-test/run.sh` を実行(プロジェクトルートからは `./scenario-test/run.sh`) + +→ 以降このディレクトリ(Skill)は不要。`your-app/scenario-test/` 単体で完結する。 +``` + +「Skill 単体で uv sync するモード」は **Skill 自身の開発・テスト用**として残し、 +利用者向けセクションからは削除する。 + +## 実装タスク + +### Phase 0a: ドメイン用語の整理(cryptic な略語を平易な英語に) + +現状、モジュール名 / fixture / config / CLI に略語が無造作に混じっており、ドメイン +非専門家には意味が伝わらない。以下方針で整理する。 + +**整理対象と新名**: + +| 現名 | 新名 | 理由 | +|---|---|---| +| `scenario_test/a11y.py` | `accessibility.py` | a11y は WCAG/axe ドメイン業界用語 | +| `scenario_test/cwv.py` | `web_vitals.py` | CWV (Core Web Vitals) を平易に | +| `scenario_test/hud.py` | `overlay.py` | HUD = Heads-Up Display は造語的 | +| config key `a11y:` | `accessibility:` | YAML を読む利用者向け | +| config key `cwv:` | `web_vitals:` | 同上 | +| fixture `pwk_a11y_scan` | `pwk_accessibility_scan` | コードリーダ向け | +| fixture `pwk_cwv_measure` | `pwk_web_vitals_measure` | 同上 | +| CLI `--pwk-hud` | `--pwk-overlay` | 同上 | + +**保持するもの** (W3C / 業界標準の正式略号): + +- `LCP` (Largest Contentful Paint) +- `CLS` (Cumulative Layout Shift) +- `TTFB` (Time To First Byte) +- `longest_task` (Long Tasks API) +- `HAR` (HTTP Archive、ファイル形式名) +- `axe-core` (a11y 検査ライブラリの実名) + +これらは **データフィールド名・外部仕様名としてそのまま**使う。ただし初出箇所の docstring と +SKILL.md の用語集で**正式名称と意味を必ず併記**する。 + +**用語集の追加** (`SKILL.md` 上部に新規セクション): + +```markdown +## 用語集 + +| 略語 / 用語 | 正式名 / 意味 | +|---|---| +| accessibility (旧 a11y) | Web アクセシビリティ。WCAG 準拠を axe-core で機械検査 | +| web vitals (旧 CWV) | Google が定義する「ユーザ体感パフォーマンス指標」群 | +| LCP | Largest Contentful Paint — 最大コンテンツ描画時間 (体感ロード速度) | +| CLS | Cumulative Layout Shift — 累積レイアウトずれ量 (視覚的安定性) | +| TTFB | Time To First Byte — 初バイト到達時間 (サーバ応答速さ) | +| longest_task | Long Tasks API で観測した最長タスクのミリ秒値 | +| HAR | HTTP Archive — ネットワーク通信ログのファイル形式 | +| trace | Playwright Trace — DOM スナップショット + 操作ログ + 動画の zip | +| overlay (旧 HUD) | テスト中に画面に重ねる赤丸カーソル + 字幕表示 | +| body_check | サーバが HTML 本文に出力した PHP/SSR エラー文字列の検出 | +``` + +**実装タスク**: + +- [ ] `a11y.py` → `accessibility.py`、`cwv.py` → `web_vitals.py`、`hud.py` → `overlay.py` + ファイルリネーム +- [ ] パッケージ内・テスト内・docs 内の import / 言及を一括置換 +- [ ] config schema (`scenario.config.yaml`) の `a11y:` → `accessibility:`、`cwv:` → `web_vitals:` + に変更 +- [ ] config dataclass の属性名 (`Config.a11y`, `Config.cwv`) を `accessibility` / `web_vitals` に +- [ ] fixture `pwk_a11y_scan` / `pwk_cwv_measure` を `pwk_accessibility_scan` / + `pwk_web_vitals_measure` に rename (Phase 0 の `pwk_*` 化と同タイミング) +- [ ] CLI `--pwk-hud` を `--pwk-overlay` に rename +- [ ] templates/scenario.config.yaml のキー名 / コメントを新名に更新 +- [ ] SKILL.md 上部に「用語集」セクションを追加 (上記表) +- [ ] 各 fixture / モジュールの docstring に正式名 (Largest Contentful Paint 等) を併記 +- [ ] docs/01-methodology.md などで「a11y」「CWV」と書いていた箇所を新名 + 略号併記に統一 + +### Phase 0: パッケージ・名前空間のリネーム (`scenario_test` → `playwright_kit`, `ndf_*` → `pwk_*`) +**Python パッケージ rename**: +- [ ] `plugins/ndf/skills/playwright-scenario-test/scenario_test/` を `playwright_kit/` にリネーム +- [ ] パッケージ内 `from scenario_test.X import Y` を `from playwright_kit.X import Y` へ全置換 +- [ ] `pytest_plugin.py` の `pytest_plugins = ["scenario_test.fixtures.X", ...]` を + `["playwright_kit.fixtures.X", ...]` へ更新 +- [ ] `pyproject.toml` (Skill 開発用) の以下を更新: + - `[project.entry-points."pytest11"] playwright-kit = "playwright_kit.pytest_plugin"` + - `[tool.hatch.build.targets.wheel] packages = ["playwright_kit"]` + - `name = "playwright_kit"` +- [ ] Skill 自身の `tests/` 配下の import 修正 (`from scenario_test.X` → `from playwright_kit.X`) + +**fixture / CLI option / env / クラス名 rename** (`ndf_*` 系を全て `pwk_*` に): +- [ ] fixture 名: `ndf_config` / `ndf_role_` / `ndf_evidence` / `ndf_out_dir` / + `ndf_a11y_scan` / `ndf_cwv_measure` / `ndf_body_check_scan` を `pwk_*` に rename +- [ ] CLI option: `--ndf-config` / `--ndf-out-dir` / `--ndf-no-evidence` / `--ndf-hud` / + `--ndf-drive-folder` を `--pwk-*` に rename +- [ ] pytest group 名: `parser.getgroup("ndf", ...)` → `parser.getgroup("pwk", ...)` +- [ ] env var: `NDF_CONFIG` を `PWK_CONFIG` に rename (`run.sh` のフォールバックも) +- [ ] 内部クラス: `NdfTestEntry` → `PwkTestEntry` +- [ ] templates 内のテスト雛形 (`test_auth.py.template` 等) で fixture 名を新名に更新 +- [ ] `templates/conftest.py.template` 内の例コメントを新名に更新 + +**ドキュメント更新**: +- [ ] docs/ 配下の言及(`scenario_test/` / `ndf_*` 表記)を新名に更新 +- [ ] SKILL.md の構造図 / fixture 表 / CLI option 表 / 全コード例を新名に更新 +- [ ] `uv sync` で 147 件の既存テストが pass することを確認 + +### Phase 1: ランタイムコピー版の整形 +- [ ] `templates/pyproject.toml.runtime` を新規作成(name=`playwright_kit`、entry-point / + hatch wheel 設定を `playwright_kit` に、dev 用設定削除、 + `[tool.pytest.ini_options].testpaths = ["tests"]` 維持) +- [ ] `templates/runtime-gitignore` を新規作成(`.venv` / `__pycache__` / `reports/`) +- [ ] `templates/runtime-README.md` を新規作成(Skill 無し環境向け最低限の使い方) + +### Phase 2: ランチャテンプレート +- [ ] `templates/run.sh` を新規作成(自身の位置から RUNTIME_DIR を解決 → uv sync → pytest) +- [ ] `templates/run.bat` を新規作成(Windows 同等処理、`%~dp0` でスクリプト位置解決) +- [ ] 両ランチャに `--help` を実装し追加引数の使い方を表示 +- [ ] `cd && ./run.sh` でも `.//run.sh` でも同じ挙動になる確認 + +### Phase 3: 初期化スクリプト +- [ ] `scripts/init_project.sh` を新規作成(rsync ベース、all-in-one 配置) +- [ ] `scripts/init_project.bat` を新規作成(Windows / xcopy ベース) +- [ ] `--runtime-dir ` 引数でランタイムディレクトリ名を変更可能に +- [ ] init スクリプトの dry-run モード(`--dry-run` で予定差分のみ表示) +- [ ] init スクリプトの冪等性確認(既存 `scenario.config.yaml` / `tests/conftest.py` / + `tests/test_*.py` を上書きしない) +- [ ] 同一プロジェクトに複数ランタイム名で init された場合も独立して動く確認 + (`scenario-test/` と `e2e/` を共存) + +### Phase 4: SKILL.md 改訂 +- [ ] クイックスタートを「init → run-scenario-test.sh」フローに書き換え +- [ ] 「Skill ディレクトリで uv sync する旧運用」節を削除(または開発者向け節に縮小) +- [ ] 「init 後は Skill 不要」を明示 +- [ ] templates/ 配置物の説明を更新(runtime テンプレート群の追加) + +### Phase 5: docs/ 更新 +- [ ] `docs/06-pytest-playwright.md` の「実行方法」節を `run-scenario-test.sh` ベースに変更 +- [ ] `docs/README.md` のディレクトリ図を更新(init 後構造) + +### Phase 6: 検証 +- [ ] **Skill が無い擬似環境**で動作検証: + 1. 一時ディレクトリに init 実行(default 名 + 任意名 `--runtime-dir e2e`) + 2. Skill ディレクトリ自体を別名にリネーム or 削除 + 3. `./scenario-test/run.sh` および `./e2e/run.sh` が完走することを確認 +- [ ] init 後に出力される `reports/` がランタイム内 (`scenario-test/reports/`) に作られることを確認 +- [ ] templates/test_*.py.template 由来のテストで動画 / a11y / CWV / body_check が正常に出力されることを確認 +- [ ] `--ndf-drive-folder=` の Drive アップロードが scripts/_drive_auth.py / + gdrive_upload_dir.py 経由で機能することを確認 +- [ ] 複数ランタイム共存(`scenario-test/` + `e2e/`)が干渉せず動く確認 +- [ ] Windows (WSL 以外) で `.bat` ランチャの動作確認 + +### Phase 7: バージョン更新 +- [ ] `playwright-scenario-test/pyproject.toml` を `version = "0.5.0"` に +- [ ] `plugins/ndf/.claude-plugin/plugin.json` を `4.4.0` に +- [ ] `plugins/ndf/CLAUDE.md` の開発履歴に v4.4.0 / v0.5.0 の節を追加(破壊的変更を明記) + +## 互換性方針 + +本 Skill は開発中につき**後方互換は考慮しない**。旧 API (`scenario_test` / `ndf_*` / +`--ndf-*` / `NDF_*` / 旧 Skill ディレクトリで `uv sync` する運用) は **完全に削除** する。 +旧バージョンを利用していたコードがあれば手動で書き換える。 + +## リスクと対策 + +| リスク | 対策 | +|---|---| +| 利用者プロジェクトに `scenario-test/` を git 管理させると差分が肥大化 | `scenario-test/.venv` / `__pycache__` を gitignore。コアコードは数 KB 程度なので commit してよい設計(再現性のため逆に推奨)| +| Skill 更新時にコピー済みランタイムが取り残される | 開発中につき再 init で上書き運用。安定後にあらためて update 戦略を検討 | +| pytest plugin の entry-point 解決が editable install 依存 | 初回 `uv sync` 完了で editable install が効く構成(現行と同じ)。万一壊れたら `tests/conftest.py` に `pytest_plugins = ["playwright_kit.pytest_plugin"]` を追加するフォールバックを SKILL.md に明記 | +| `playwright install chromium` がオフライン環境で失敗 | init 時にエラーログを表示しつつ続行。利用者は事前に `PLAYWRIGHT_BROWSERS_PATH` を共有キャッシュへ向ける運用 | +| Windows `.bat` ランチャの差異 | WSL 推奨を SKILL.md に明記。最低限 `.bat` で uv 経由 pytest が回る程度に留める | + +## 完了の定義 + +- [ ] **Skill ディレクトリを `mv` で隠した状態**でも `./scenario-test/run.sh` が完走する +- [ ] init 後、利用者プロジェクトには **`scenario-test/` ディレクトリが 1 つ追加されるだけ** + で他のルートファイルが増えない(all-in-one が成立) +- [ ] `scenario-test/` の中に `playwright_kit/` / `scripts/` / `tests/` / `reports/` / + `scenario.config.yaml` / `run.sh` / `run.bat` / `pyproject.toml` / `uv.lock` が揃っている +- [ ] `--runtime-dir e2e` で init した場合、`your-app/e2e/` 配下に同じ構造が作られ + `./e2e/run.sh` で実行できる +- [ ] SKILL.md / docs/ が新フローに沿って更新されている +- [ ] バージョンが `v0.5.0` / プラグイン `v4.4.0` に上がっている +- [ ] 既存テストスイート(147 件)が引き続き pass する + +## 参考 + +- 現行構造: `plugins/ndf/skills/playwright-scenario-test/` +- 関連 Plan: PLAN17 (v0.3.0 pure pytest 化) / PLAN18 (v0.4.0 body_check 復活) +- pytest entry-point: `pyproject.toml` `[project.entry-points."pytest11"]` +- uv project: https://docs.astral.sh/uv/concepts/projects/ diff --git a/issues/i09.md b/issues/i09.md new file mode 100644 index 00000000..ff9bc8f3 --- /dev/null +++ b/issues/i09.md @@ -0,0 +1,73 @@ +# 開発AIエージェント向け指示書 + +* このドキュメントはClaude Codeなどの開発AIエージェント向けの指示書です。 +* チャットで「docs/cmd01.mdのx番を実行してください」と言われたらこのファイルを読み、見出しに書いてある番号の内容を実行してください。 +* 頻繁に書き換わるので、指示があるたびに読み込みなおしてください。 +* 返答やドキュメントはすべて日本語で。 + +# 1. Agent SkillとSerena MCP Memoryを効率的に活用するためのSkillとMemory +* 以下のChatGPTが考察したAgent SkillとSerena MCPの使い分けと記憶戦略についてのドキュメントがあります。 + * issues/report03.md + * issues/report04.md + * issues/report05.md +* この指針に従ってndfプラグインに記憶のためのカスタムコマンドとSkillを追加してください。 +* Serena MCP Meoryへの記憶戦略の書き込みはカスタムコマンドとスキルが担います。 + * PluginにSerena MCP Memoryを反映することができないため + +# 2. ndf のMCPサーバ群を変更 +* plugins/ndf/CLAUDE.ndf.md +* serena, context7, codexのみを登録してください +* notion, awslabs.aws-docs, bigquery, dbhub, chrome-devtools はndf pluginからは除外し、それぞれをインストールするpluginをこのMarketplace上に作成してください。 +* ndfのドキュメントをこの変更に合わせて修正してください。 + + +# 3. python実行時に参照するskill +* plugins/ndf/skills +* python実行時のskillを追加してください。 + * pythonを実行しようとする場合に参照する以下のような内容のskill + * pythonはpython3, pythonの他、uvでの実行を前提とする環境があることに注意する + * pythonコードを実行する前に、環境やプロジェクトがどの実行方法を前提としているかを調査すること + * プロジェクト内にpyproject.tomlや.venvがあればuv環境での実行を優先する + * README.mdやCLAUDE.mdにpython実行方法についての例があればそれに従う + * uvを使う場合の事前準備方法を案内 + +# 4. dockerコンテナアクセス時に参照するskill +* plugins/ndf/skills +* コンテナ(Docker)利用時に参照するskillを追加してください。 + * dockerコマンドやcurl、playwright mcpやchrome dev toolsでコンテナにアクセスする場合に参照する以下のような内容のskill + * ローカル環境は、開発用コンテナ上で起動された環境である可能性があります。 + * このため、この環境でdockerコンテナを起動すると、DinD、DooDのいずれかの形式で起動される可能性があります + * DinDとDooDではコンテナへのアクセス方法が異なるので、自身の環境がどういったものかをまず確定する必要があります。 + * DinD環境の場合、localhostで他のコンテナにアクセスできます + * DooD環境の場合、localhostでは接続できず、dockerコンテナ名でアクセスすることになります + * curlなどの参考例をつけておいてください + * また、DooD環境の場合、bind mountはホスト側の環境に接続しようとするため、ローカル環境内にアクセスできません。 + * できるだけcompose.ymlやdocker run時のmountは避け、必要なファイルはDockerfileでCOPYするようにしてください + * どうしても必要な場合、volumeを作成するなどして必要なファイルをコピーしてから目的のコンテナにmountする必要があります。 + +# 5. Agent Skill開発のためのベストプラクティスSkill +* Claude CodeのAgent Skillsのベストプラクティスを調査し、Skillとして登録してください +* 特にコンテキスト節約のための多段階読み込みなどに対応した書き方、Toolの登録方法について重点的に調査して盛り込んでください +* plugins/ndf/skills のskillとして登録してください + +# 6. plugins/ndf/skills/skill-development に従ってすべてのスキルをリファクタリング +* plugins/ndf/skills のすべてのスキルを、plugins/ndf/skills/skill-developmentに従って最適化してください + +# 7. fix修正 +plugins/ndf/commands/fix.md +* ## 実行の4.以降の作業を以下のように追加します。 + * いずれもgithub mcpまたはghコマンドを利用 + * 修正内容について、PRにSummaryコメントを追加 + * 修正内容を指摘したreviewerとcopilotにreviewを再レビューを依頼 + * 対応したコードコメントに個別に返信する + * 対応が完全に完了したコードコメントは「Resolve Conversation」とする +* 作業完了報告の報告テンプレートが少し量が多いので、内容を減らしてください + +# 8. fix時のテンプレート +plugins/ndf/commands/fix.md +* 報告テンプレートを改善します + * issues/template09.md を参考にPRにのコメントとしてふさわしい形にしてください + +# 9. mcpを整理 +* ndfからnotion, aws-docs, bigquery, dbhub, chrome-devtools は削除してください。 +* これらのツールを個別にインストールするpluginを追加してください。 diff --git a/issues/i10.md b/issues/i10.md new file mode 100644 index 00000000..33afeede --- /dev/null +++ b/issues/i10.md @@ -0,0 +1,21 @@ +# 開発AIエージェント向け指示書 + +* このドキュメントはClaude Codeなどの開発AIエージェント向けの指示書です。 +* チャットで「docs/cmd01.mdのx番を実行してください」と言われたらこのファイルを読み、見出しに書いてある番号の内容を実行してください。 +* 頻繁に書き換わるので、指示があるたびに読み込みなおしてください。 +* 返答やドキュメントはすべて日本語で。 + +# 1. ai-pluginsのkiro cliへの移植 +* Claude Code用に開発されたai-plguinsをkiro用に移植します +* このプロジェクトのndf pluginについて調査してください + * skill, mcp, hooks, カスタムコマンド、 sub agentが含まれているはずです。 +* Claude のplugin marketplaceの仕様を調査してください +* ai-pluginsとndf pluginで実装されている機能について、kiro cliの類似の機能を調査してください +* kiro cliでこのmarketplace、またはその中のpluginをどのように移植したらよいか、検討してください。 + * 存在しない機能についてはできるだけ似たようなユーザ体験になるのが望ましいです +* 移植(または拡張)計画を建ててください + * 同じリポジトリで管理可能なら拡張、別のリポジトリを作成した方がよさそうなら移植とします。 + * 移植(拡張)計画をissues/PLAN10/ に出力してください + * 想定されるインストール方法と利用方法もまとめてissues/PLAN10/に出力してください + + diff --git a/issues/i11.md b/issues/i11.md new file mode 100644 index 00000000..1fe75138 --- /dev/null +++ b/issues/i11.md @@ -0,0 +1,273 @@ +# 指示書:Claude Code Plugin + +## Redash MCP マルチ環境設定プラグイン(任意 suffix 対応) + +## 1. 目的 + +Claude Code の Plugin として `mcp-redash` +[https://github.com/suthio/redash-mcp](https://github.com/suthio/redash-mcp) +を追加する。 + +本プラグインは **Redash MCP を1つ以上、任意の suffix で追加可能**とし、 + +* デフォルトは **suffix なし(`redash`)を1つだけ自動有効化** +* 追加の Redash(`dev`, `stg`, `prod2`, `sandbox` など)は + **Skill をスラッシュコマンドとして実行したときだけ追加** + +することで、 + +* 1つだけ使うプロジェクトでは `/mcp` が汚れない +* 複数環境を扱うプロジェクトでも柔軟に増やせる +* URL / API_KEY はすべて **プロジェクト側環境変数で設定可能** + +という状態を実現する。 + +--- + +## 2. 基本設計方針(重要) + +### ✔ MCPの表示制御 + +* **Plugin 同梱 MCP は1つだけ** +* 追加 MCP は **project `.mcp.json` に明示的に追加されたものだけ** +* したがって `/mcp` には「本当に使うものだけ」が表示される + +### ✔ Skill = スラッシュコマンド + +* 最新 Claude Code では **Skill は `/skill-name` として直接呼び出せる** +* すべての操作(add / remove / list / status)は **Skill で実装** +* `commands/` は使わない + +--- + +## 3. MCP / env 命名ルール + +### 3.1 デフォルト(suffixなし・1つ目) + +| 項目 | 値 | +| ------- | ---------------- | +| MCP名 | `redash` | +| URL | `REDASH_URL` | +| API Key | `REDASH_API_KEY` | + +※ **suffixなしは特別扱い** +→ 1つだけ使うプロジェクトではこれだけ設定すればよい + +--- + +### 3.2 suffix 付き(任意個) + +suffix を `X` とした場合: + +| 項目 | 値 | +| ------- | ------------------ | +| MCP名 | `redash-X` | +| URL | `REDASH_X_URL` | +| API Key | `REDASH_X_API_KEY` | + +#### 例 + +* `/redash-add dev` + + * MCP: `redash-dev` + * env: `REDASH_DEV_URL`, `REDASH_DEV_API_KEY` +* `/redash-add stg` + + * MCP: `redash-stg` + * env: `REDASH_STG_URL`, `REDASH_STG_API_KEY` +* `/redash-add sandbox` + + * MCP: `redash-sandbox` + * env: `REDASH_SANDBOX_URL`, `REDASH_SANDBOX_API_KEY` + +--- + +## 4. Plugin 同梱 MCP(必須) + +### `.mcp.json`(plugin ルート) + +**必ず `redash` 1つだけを定義すること。** + +```json +{ + "mcpServers": { + "redash": { + "command": "npx", + "args": ["-y", "@suthio/redash-mcp"], + "env": { + "REDASH_URL": "${REDASH_URL}", + "REDASH_API_KEY": "${REDASH_API_KEY}" + } + } + } +} +``` + +* dev / stg / その他 suffix は **ここに書かない** +* これにより Plugin インストール直後の `/mcp` には `redash` しか出ない + +--- + +## 5. Skill(スラッシュコマンド)仕様 + +### 共通事項 + +* Skill は `skills/` 配下に配置 +* `disable-model-invocation: true` を必ず指定 +* 実処理は Node.js スクリプトを呼び出す + +--- + +### 5.1 `/redash-add ` + +#### 目的 + +任意 suffix の Redash MCP を **project `.mcp.json` に追加** + +#### 振る舞い + +* `` が空 or `default` の場合 → エラー(`redash` は追加不可) +* 既に存在する場合 → 何も変更せず成功メッセージ +* `.mcp.json` が無い場合 → 新規作成 +* JSON が壊れている場合 → エラーで中断(上書きしない) + +#### 生成される MCP 定義(例:suffix = dev) + +```json +"redash-dev": { + "command": "npx", + "args": ["-y", "@suthio/redash-mcp"], + "env": { + "REDASH_URL": "${REDASH_DEV_URL}", + "REDASH_API_KEY": "${REDASH_DEV_API_KEY}" + } +} +``` + +--- + +### 5.2 `/redash-remove ` + +#### 目的 + +指定 suffix の MCP を project `.mcp.json` から削除 + +#### 振る舞い + +* 存在しない場合 → 何もせず成功扱い +* `suffix = default` or 空 → エラー(`redash` は削除不可) + +--- + +### 5.3 `/redash-list` + +#### 目的 + +現在有効な Redash MCP を一覧表示 + +#### 表示内容(例) + +``` +redash (plugin bundled) +redash-dev (project) +redash-stg (project) +``` + +--- + +### 5.4 `/redash-status` + +#### 目的 + +設定状況の詳細確認 + +* 有効な MCP 一覧 +* 各 MCP が必要とする env 変数名 +* env が未設定と思われるものがあれば警告(値は表示しない) + +--- + +## 6. Skill frontmatter 例 + +```markdown +--- +name: redash-add +description: 任意 suffix の Redash MCP を追加する +disable-model-invocation: true +arguments: + - name: suffix + description: dev, stg, sandbox など +--- +``` + +※ Claude Code の最新仕様では、この Skill を + +``` +/redash-add dev +``` + +のように **スラッシュコマンドとして直接実行可能**。 + +--- + +## 7. JSON 編集スクリプト要件 + +* Node.js で実装(必須) +* 役割 + + 1. project root の `.mcp.json` を探す(cwd 前提でOK) + 2. JSON parse + 3. `mcpServers` の add / remove / list + 4. 整形して保存(2 spaces) +* 破損 JSON は絶対に自動修復しない + +--- + +## 8. Skill(ドキュメント用) + +`skills/redash-guide.md` を作成し、以下を記載: + +* 基本は `REDASH_` だけ設定すればOK +* 複数環境を使いたい場合の例 +* suffix 命名ルール +* `/redash-add` → env 設定 → `/mcp` 確認 の流れ +* 不要になったら `/redash-remove` + +--- + +## 9. 成果物一覧(必須) + +* `.mcp.json`(plugin bundled / redashのみ) +* `skills/` + + * `redash-add.md` + * `redash-remove.md` + * `redash-list.md` + * `redash-status.md` + * `redash-guide.md` +* `scripts/redash-mcp-config.js` +* `.env.sample` +* `README.md` + +--- + +## 10. 受け入れ基準 + +1. Plugin導入直後 `/mcp` に `redash` しか出ない +2. `/redash-add dev` 実行後に `redash-dev` が出る +3. `/redash-add foo` が問題なく動く +4. `/redash-remove dev` 後に `/mcp` から消える +5. suffix をいくつ追加しても衝突しない +6. env 未設定でも **追加はできるが警告が出る** + +--- + +## 11. 設計意図(README に明記) + +> 本プラグインは +> 「1つだけ使う場合は最小設定」 +> 「必要になったら suffix 付きで増やす」 +> という運用を前提にしている。 +> +> そのため MCP 定義は **必要になるまで作られない**。 + diff --git a/issues/i12.md b/issues/i12.md new file mode 100644 index 00000000..121a37d3 --- /dev/null +++ b/issues/i12.md @@ -0,0 +1,13 @@ +# 開発AIエージェント向け指示書 + +* このドキュメントはClaude Codeなどの開発AIエージェント向けの指示書です。 +* チャットで「docs/cmd01.mdのx番を実行してください」と言われたらこのファイルを読み、見出しに書いてある番号の内容を実行してください。 +* 頻繁に書き換わるので、指示があるたびに読み込みなおしてください。 +* 返答やドキュメントはすべて日本語で。 + +# 1. dbhub mcpのリポジトリをhttps://github.com/takemi-ohama/dbhubに変更 +* 本家dbhubにないSSH keep-aliveの機能を追加したかったので、リポジトリをforkしました。 + * https://github.com/takemi-ohama/dbhub +* ndf:mcp-dbhub は当面こちらのリポジトリを利用するように変更してください。 +* 本家にmergeされたら戻します。 + diff --git a/issues/i13.md b/issues/i13.md new file mode 100644 index 00000000..0bb7b051 --- /dev/null +++ b/issues/i13.md @@ -0,0 +1,11 @@ +# 開発AIエージェント向け指示書 + +* このドキュメントはClaude Codeなどの開発AIエージェント向けの指示書です。 +* チャットで「docs/cmd01.mdのx番を実行してください」と言われたらこのファイルを読み、見出しに書いてある番号の内容を実行してください。 +* 頻繁に書き換わるので、指示があるたびに読み込みなおしてください。 +* 返答やドキュメントはすべて日本語で。 + +# 1. AGENTS.mdやSkillsを整理するためのSkill +* issues/memory_replaning.md を読んでください。 +* この発想を活かすためにAGENTS.mdやSkillを整理するためのAgent Skillをndf pluginに追加してください +* Skillはスラッシュコマンドで起動するタイプとしてください。 diff --git a/issues/i14.md b/issues/i14.md new file mode 100644 index 00000000..eb149926 --- /dev/null +++ b/issues/i14.md @@ -0,0 +1,16 @@ +# 開発AIエージェント向け指示書 + +* このドキュメントはClaude Codeなどの開発AIエージェント向けの指示書です。 +* チャットで「docs/cmd01.mdのx番を実行してください」と言われたらこのファイルを読み、見出しに書いてある番号の内容を実行してください。 +* 頻繁に書き換わるので、指示があるたびに読み込みなおしてください。 +* 返答やドキュメントはすべて日本語で。 + + +# 1. kiro対応 +* このpluginのskillをaws kiro cliでも利用できるようにします。 +* ndf plugin 内のskillが対象です +* hookも可能であれば移植したい。特にslack-notify +* kiro向けのインストールマニュアルも必要 +* claude code向けの仕組みは可能な限りそのままで +* 企画・設計書をissues/PLAN14.md に出力 + diff --git a/issues/i15.md b/issues/i15.md new file mode 100644 index 00000000..54bf6a6a --- /dev/null +++ b/issues/i15.md @@ -0,0 +1,438 @@ +# PR #57 Codex 指摘 8 件 修正計画 + +## ステータス +- 作成日: 2026-04-26 +- 最終更新: 2026-04-26 +- 現在のフェーズ: 計画策定完了 / 実装着手前 +- 進捗: 0/6 commit +- ブランチ: `feature/scenario-test-v0.3.0-pytest-native` (origin/main から 7 commits ahead) +- 対象 PR: https://github.com/takemi-ohama/pull/57 +- ベースライン pytest: **61 passed in 0.14s** (確認済) + +## 概要 + +PR #57 (`playwright-scenario-test v0.3.0 — pure pytest-playwright`) に投稿された +Codex CLI 第二意見レビューの指摘 (Major 5 / Minor 3、計 8 件) を 6 commit に分けて +修正する。pytest 全 pass を維持しつつ、最終的に PR コメント + PR description 更新で完了とする。 + +CI は無いため検証は **ローカル `uv run --extra dev pytest`** のみ。 +Major-5 で追加するテストは Playwright 実機を必要としない範囲に絞る。 + +## 作業ディレクトリ +- skill ルート: `/work/ai-plugins/plugins/ndf/skills/playwright-scenario-test/` +- pytest 実行: `cd && uv run --extra dev pytest` + +## Commit 計画 (6 件) + +### Commit 1: Minor 6+7 — config.py 軽微修正 + +**ファイル**: `scenario_test/config.py` + +#### Minor 6: `step_delay_ms` の dataclass / from_raw 不一致 (line 64 vs 89) + +- 現状: dataclass default `1800`、`PlaywrightConfig.from_raw()` の fallback `1500` +- 対応: `from_raw()` 冒頭で `base = cls()` を作り、各 `raw.get(...)` の fallback に + `base.` を使う方式に統一。1280/720 の数値も `base.viewport_width` 等で参照すると、 + この dataclass を真実の源 (single source of truth) にできる。 +- 注意: 既存テスト `test_evidence_fixture.py` は `PlaywrightConfig.defaults()` 経由で + 使われているのみで、`from_raw` の数値を直接 assert してはいないので副作用なし。 + +#### Minor 7: 空 YAML で TypeError (line 183-185) + +- 現状: `yaml.safe_load(fp)` が `None` を返すと `_from_dict()` の `raw["target"]` で + `TypeError: 'NoneType' object is not subscriptable` になる。 +- 対応: `Config.load()` 内で `safe_load` 直後に `isinstance(raw, dict)` チェック。 + 違えば `ValueError("scenario.config.yaml の中身が空または辞書ではありません: ")` + を raise。 +- テスト: `tests/test_config_basic_auth.py` に近い既存ファイルで pass しているはず。 + 追加で 1 件 (空 YAML → ValueError) を test_config_basic_auth.py に書き加える。 + +#### コミットメッセージ案 +``` +fix(ndf): playwright-scenario-test config の dataclass 既定値整合と空 YAML エラーハンドリング + +- PlaywrightConfig.from_raw() の fallback を dataclass 既定値 (cls()) に揃え、 + step_delay_ms の 1800 vs 1500 不一致を解消 (Codex Minor 6) +- Config.load() で yaml.safe_load() 結果が dict でない場合に ValueError を raise し、 + 空 YAML での TypeError を防ぐ (Codex Minor 7) +- 空 YAML テストケースを追加 (test_config_basic_auth.py) +``` + +### Commit 2: Major 4 — YAML の ${ENV_VAR} 展開 + +**ファイル**: `scenario_test/config.py` / `templates/scenario.config.yaml` / `SKILL.md` / `docs/` + +#### 仕様 +- `Config.load()` で `yaml.safe_load()` の後、再帰的に dict / list / str を walk し、 + 文字列値の中に `${VAR}` / `${VAR:-default}` パターンがあれば `os.environ` から展開。 +- 未定義かつ default も無い場合は `ValueError` (`scenario.config.yaml の ${VAR} が + 未定義 (env を設定するか default を指定してください)`)。 +- 実装は `_expand_env(value: Any) -> Any` 純関数として外出しし、ユニットテストしやすくする。 + +```python +import os, re +_ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}") + +def _expand_env_in_str(s: str) -> str: + def repl(m): + 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 指定または env 設定が必要)") + return default + return val + return _ENV_RE.sub(repl, s) + +def _expand_env(value): + 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 +``` + +#### template 更新 + +`templates/scenario.config.yaml` の `roles.*.login.fields` のサンプルを env 参照に: +```yaml +fields: + LoginID: ${ADMIN_LOGIN_ID} + Password: ${ADMIN_PASSWORD} +``` +`target.basic_auth` も同様に `${BASIC_AUTH_USER}` 等を例として示す。 +コメントで「env を `.env` または shell で export する」運用を明記。 + +#### ドキュメント更新 +- `SKILL.md` の「制約 / 注意」節に「資格情報は YAML 直書きではなく `${ENV_VAR}` 展開を推奨」を追加。 +- `docs/06-pytest-playwright.md` または `docs/README.md` のいずれか自然な箇所に同旨を追記 + (どちらが適切かは実装時に確認)。 + +#### テスト +`tests/test_config_basic_auth.py` に `_expand_env` の純関数テストを 3〜4 件追加: +- `${VAR}` 展開 +- `${VAR:-default}` で env 無し → default +- `${UNDEFINED}` (default 無し) → ValueError +- 再帰展開 (list 内の dict 内の str) + +#### コミットメッセージ案 +``` +feat(ndf): playwright-scenario-test config に ${ENV_VAR} / ${VAR:-default} 展開を追加 + +- scenario_test/config.py に _expand_env を追加し、Config.load() で再帰展開 +- templates/scenario.config.yaml の認証情報サンプルを env 参照ベースに変更 +- SKILL.md / docs に YAML 直書き禁止の注意を追記 +- tests/test_config_basic_auth.py に env 展開の純関数テストを追加 +- リポジトリポリシー「認証情報は環境変数で管理」と整合 (Codex Major 4) +``` + +### Commit 3: Major 1+2 — HAR 設計を function-scope context に揃え、case_dir を nodeid + worker + sha1 に変更 + +**ファイル**: `scenario_test/fixtures/evidence.py` + +#### 設計選択 (function-scope vs session) + +**選択**: function-scope (1 test = 1 HAR)。理由: +- `NdfEvidence.har_path` / `confirm_har()` / `pytest_runtest_makereport` の + `rep.user_properties` 付与 / `report.md` 失敗詳細セクション / Drive upload は + すべて test ごとに HAR が独立している前提で書かれている。 +- session 1 HAR にすると「FAIL test の周辺だけ抽出」が利用者側で困難になる。 +- pytest-playwright の `browser_context_args` は dict fixture なので、 + function scope に上書き可能 (公式 docs 確認済: scope を function に変えても + fixture は問題なく動く)。 + +#### 実装変更 + +1. `browser_context_args` fixture を `scope="function"` に変更: + - `request.node` から `case_dir` を計算するため `request` を引数に取る + - `case_dir / "request.har"` を `record_har_path` に inject + - session 共通 HAR (`session.har`) は廃止 + +2. `_safe_slug` を新ロジックに置換 (Major 2): + ```python + import hashlib, os + def _safe_case_slug(node: pytest.Item | "Node") -> str: + """nodeid + xdist worker から衝突しない安全な slug を作る。""" + 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" + ``` + 既存の `_safe_slug(name, fallback)` は後方互換のため残し、内部で + 新関数 `_safe_case_slug(node)` を呼ぶ形にしてもよい + (test_evidence_fixture.py の `test_safe_slug` を壊さないように)。 + → 実装方針: `_safe_slug` 単体は既存仕様 (str → str) のまま維持し、 + evidence fixture 内で使う slug 生成だけ新関数に切替える。 + +3. `ndf_evidence` fixture の `case_dir` 計算を新 slug 関数経由に: + ```python + case_dir = ndf_out_dir / _safe_case_slug(request.node) + ``` + +4. `confirm_har()` のロジックは現状でほぼ OK だが、HAR write は context.close() + 時に flush されるので、`stop_tracing` の後 (= context teardown と同タイミング) に + 呼ぶ必要がある。pytest-playwright の `context` fixture は yield 後にクローズするため、 + `ndf_evidence` の finally 句で `confirm_har()` を呼ぶだけでは HAR 書き込み完了前の + 可能性がある。 + - 対策: `pytest_runtest_makereport` の `teardown` phase で `ev.confirm_har()` を + 再度呼ぶ。または `ndf_evidence` の yield を context teardown 後に終わるよう、 + finalizer 順序を `request.addfinalizer` で制御する。 + - 簡易策: `confirm_har()` を makereport の `when=="teardown"` 時に追加で呼ぶ。 + ※ ただし HAR ファイルは context が close されるまで書かれないため、 + teardown の最後 (context fixture finalizer の後) に呼ぶ必要があり、 + pytest-playwright の context fixture と evidence fixture の依存順を工夫する必要がある。 + - 安全策: `ndf_evidence` の yield 後の finally では `confirm_har()` を呼ばず、 + `pytest_runtest_makereport(when="teardown")` で `ev.har_path.exists()` を再チェックして + `har_relpath` を更新する hook 側の責務に変える。 + +#### テスト追加 +`tests/test_evidence_fixture.py` に: +- `_safe_case_slug` の純関数テスト (parametrize、xdist worker env 有無) +- 同じ nodeid + 同じ worker は同じ slug (idempotent) +- 異なる nodeid は異なる slug +- xdist worker が変われば slug も変わる + +実 fixture (browser_context_args の function scope 化) は Playwright を要するため +ここでは pytester 経由で「browser_context_args fixture の scope が function」を +inspect する軽いテストに留める (Major-5 commit でカバーする)。 + +#### コミットメッセージ案 +``` +fix(ndf): playwright-scenario-test の HAR 収集を function-scope に変更し case_dir 衝突を解消 + +- browser_context_args を session → function scope に変更し、test ごとに + record_har_path = case_dir/request.har を inject (Codex Major 1) +- session.har 共有を廃止。NdfEvidence.confirm_har() がほぼ常に None を返す不整合を解消 +- _safe_case_slug() を新設し、nodeid + PYTEST_XDIST_WORKER + sha1[:6] suffix で + slug を生成。parametrize / 同名関数 / xdist 並列での trace.zip / request.har 上書きを + 防止 (Codex Major 2) +- pytest_runtest_makereport の teardown phase で confirm_har() を再評価し、 + context teardown 後に書き込まれる HAR を取りこぼさない +- _safe_case_slug の純関数テストを追加 (test_evidence_fixture.py) + +設計選択 (function-scope vs session): NdfEvidence / report.md / Drive upload が +test ごとの独立 HAR を前提に書かれているため、function-scope を採用。 +session 1 HAR は FAIL 周辺だけの抽出が困難なため不採用。 +``` + +### Commit 4: Major 3 — xfailed/xpassed の集約 + +**ファイル**: `scenario_test/pytest_plugin.py` / `scenario_test/pytest_report.py` + +#### 変更点 +1. `_collect_entries(terminalreporter)` の outcome ループに `"xfailed"` / `"xpassed"` を追加。 +2. `terminalreporter.stats` の rep の `wasxfail` / outcome の扱いを確認: + - pytest 内部では `xfailed` の rep は `outcome == "skipped"` + `wasxfail` 属性が付く形式 + (バージョン依存)、または `stats["xfailed"]` に直接入る。 + - `terminalreporter.stats.get("xfailed", [])` / `get("xpassed", [])` を直接見るのが確実。 +3. `pytest_report.NdfTestEntry.outcome` の値として `"xfailed"` / `"xpassed"` を受け入れる + ことは既に `status_label` で実装済 (line 43-50)。 +4. `render_markdown` のヘッダ集計に `xfailed` / `xpassed` を追加し、 + `XFAIL N / XPASS M` を表示する。 +5. `all_pass` の判定を `passed + xfailed == total` に変更 + (xfailed は期待通りの fail なので OK 扱いにする — `NdfTestEntry.ok` は既に + `outcome in ("passed", "xfailed")` を返すのでそれに揃える)。 + +#### テスト追加 +`tests/test_pytest_report.py` に: +- xfailed / xpassed entry を含む render_markdown の集計テスト +- ヘッダに `XFAIL`/`XPASS` カウントが出ること +- xfailed のみのテストで `全PASS` 判定にならないこと + (xpassed は意図せず pass したので注意喚起、xfailed は期待通り fail なので OK 扱い、 + とする方針を docstring に明記) + +#### コミットメッセージ案 +``` +fix(ndf): playwright-scenario-test report に xfailed / xpassed を集約 + +- _collect_entries() で terminalreporter.stats の "xfailed" / "xpassed" も走査 + (Codex Major 3) +- render_markdown のヘッダ集計に XFAIL / XPASS を追加 +- ok 判定 (NdfTestEntry.ok) の挙動に合わせて all_pass を passed + xfailed で判定 +- xfailed/xpassed を含む render テストを test_pytest_report.py に追加 +``` + +### Commit 5: Major 5 — pytester ベースの統合テスト 3 件 + +**ファイル**: `tests/` (新規 or 既存に追記) + +Playwright 実機を起動しない範囲で、以下 3 件を pytester で追加: + +#### Test (a): role fixture の初回ログイン + 2 回目 cache hit +**新規**: `tests/test_auth_cache.py` + +`_login_and_get_storage_state` を `monkeypatch` で fake (例: `lambda **kwargs: {"cookies": [...], "origins": []}`) に差し替え、`_StorageStateCache` が: +- 1 個目の test では `_login_and_get_storage_state` を 1 回呼ぶ +- 2 個目の test では呼ばない (cache hit) + +を確認する。pytester 内でカウンタ用 module attribute を使い、callcount を assert。 + +実装ヒント: `pytester.makepyfile` で内部 conftest.py + 2 つの test 関数を作り、 +`monkeypatch.setattr("scenario_test.fixtures.auth._login_and_get_storage_state", fake)` を +`pytester.runpytest_subprocess` の前に setattr すると subprocess には伝わらないので、 +代わりに pytester で書く conftest.py の中で `monkeypatch` するか、 +`scenario_test/fixtures/auth.py` の `_login_and_get_storage_state` を fake に差し替えた +fixture override を pytester 内 conftest.py に書く方が確実。 + +#### Test (b): makereport が ndf_har / ndf_trace を user_properties に乗せる +**新規 or 既存追記**: `tests/test_pytest_plugin_bootstrap.py` に追記、 +または `tests/test_makereport_user_properties.py` を新設。 + +pytester で: +1. fake conftest.py を書き、`ndf_evidence` fixture override で + `NdfEvidence` インスタンスを返し、`har_relpath="request.har"`、`trace_relpath="trace.zip"` + を直接セットしておく +2. 1 件 dummy test 関数を実行 (`page` fixture を要求しないテスト関数で、 + evidence を `request.node._ndf_evidence` に setattr する) +3. `terminalreporter` を解析して、最終的に書き出された report.md (out_dir + `--ndf-out-dir=...`) に `request.har` / `trace.zip` が出るかを assert + +または、より軽く: +- `pytest_runtest_makereport` を直接呼び出して rep.user_properties を確認する + 単体テスト (Playwright 不要、`item._ndf_evidence` を SimpleNamespace で渡す)。 + → こちらを採用 (より単純で安定)。 + +#### Test (c): terminal_summary が成果物パスを report.md に埋め込む +**新規 or 既存追記**: `tests/test_pytest_terminal_summary.py` を新設、 +または `test_pytest_plugin_bootstrap.py` に追記。 + +pytester で: +1. tmp の `--ndf-out-dir=` を指定 +2. dummy test を 1 件実行 (assertion error にする → failed 1 件) +3. fake `_ndf_evidence` を `request.node` に attach する fixture を提供 + (har/trace path をテンポラリファイルとして用意し touch しておく) +4. session 終了後 `/report.md` が生成され、failure section に + `request.har` / `trace.zip` のパスが埋め込まれていることを assert + +simpler 方針: `pytest_terminal_summary` を直接呼び出すユニットテスト +(`terminalreporter` を mock し `stats` を組み立て) でカバーする。 + +#### コミットメッセージ案 +``` +test(ndf): playwright-scenario-test に hook と auth キャッシュの統合テストを追加 + +- test_auth_cache.py: _login_and_get_storage_state の cache hit/miss を + monkeypatch で検証 (1 回目で 1 回呼ばれ、2 回目は呼ばれない) +- test_makereport_user_properties.py: pytest_runtest_makereport が + ndf_har / ndf_trace / ndf_console_errors / ndf_page_errors を rep.user_properties に + 乗せることを単体テスト +- test_pytest_terminal_summary.py: terminalreporter mock で report.md に + HAR / trace パスが埋め込まれることを検証 +- いずれも Playwright 実機を起動しない範囲で実装 (CI 不在のため軽量化) (Codex Major 5) +``` + +### Commit 6: Minor 8 — Drive upload の機微情報注意喚起 + +**ファイル**: `scenario_test/pytest_plugin.py` / `SKILL.md` + +#### 変更点 +1. `pytest_addoption` の `--ndf-drive-folder` の help を: + ``` + "Drive アップロード先フォルダ ID (terminal_summary 後に upload 実行)。" + "trace.zip / *.har / 動画には機微情報 (URL / Cookie / localStorage / 操作履歴) " + "が含まれる可能性があります。private folder + 信頼できる共有相手のみに限定してください。" + ``` +2. `SKILL.md` の「制約 / 注意」節を: + ``` + - **トレース / HAR / 動画は機微情報を含む**: + - HAR には URL のクエリ文字列・Cookie・Authorization ヘッダ等が記録されます + - trace.zip には localStorage / 操作履歴 / DOM スナップショットが含まれます + - `--ndf-drive-folder=` は **private folder** を指定し、共有相手を限定してください + - allowlist 機能 (`--ndf-upload-types`) は v0.4.0 以降で検討 + ``` +3. PR description の `## やらないこと (本 PR 範囲外)` に + `allowlist 機能 (--ndf-upload-types)` を v0.4.0 として記載 (commit ではなく + `gh pr edit` で行う)。 + +#### コミットメッセージ案 +``` +docs(ndf): playwright-scenario-test の Drive upload 機微情報リスクを help と SKILL.md に明記 + +- pytest_addoption の --ndf-drive-folder help に「trace/HAR は機微情報を含む可能性。 + private folder 推奨」警告を追加 +- SKILL.md 制約節に HAR / trace / 動画の含有情報と private folder 推奨を明記 +- allowlist 機能 (--ndf-upload-types) を v0.4.0 への TODO として PR description に記載予定 + (Codex Minor 8) +``` + +## 各コミット後の検証 + +```bash +cd /work/ai-plugins/plugins/ndf/skills/playwright-scenario-test +uv run --extra dev pytest -q +``` + +期待値: ベースライン 61 pass + 各コミットでの追加テスト分 +- Commit 1: +1 (空 YAML) = **62** +- Commit 2: +4 (env 展開) = **66** +- Commit 3: +3〜4 (slug 衝突) = **69〜70** +- Commit 4: +3 (xfail/xpass render) = **72〜73** +- Commit 5: +3 (統合テスト) = **75〜76** +- Commit 6: テスト追加なし = **75〜76** (現状仕様の 64+ という条件は満たす) + +最終目標: **75 件以上の pass、failures 0**。 +ミッション要件 "現状 61 pass + 新規 3 件で **64+ pass**" を上回って完了する。 + +## PR 完了処理 (実装完了後) + +### Push +全コミット完了後 `git push origin feature/scenario-test-v0.3.0-pytest-native`。 + +### PR コメント投稿 (`gh pr comment 57 --body-file `) + +テンプレート: +```markdown +## Codex 指摘 8 件 修正完了 + +レビュー (review id `PRR_kwDOREg9uc748eqJ`) の指摘 8 件 (Major 5 + Minor 3) に対応しました。 + +### 対応サマリ + +| # | 重要度 | 指摘 | 対応 commit | 主な変更ファイル | +|---|---|---|---|---| +| 1 | Major | HAR 収集の実装とレポート前提が食い違い、har_relpath が常に None | `` | scenario_test/fixtures/evidence.py | +| 2 | Major | case_dir の slug 衝突 (parametrize / 同名関数 / xdist) | `` | scenario_test/fixtures/evidence.py | +| 3 | Major | xfail / xpass を集約していない | `` | scenario_test/pytest_plugin.py / pytest_report.py | +| 4 | Major | 認証情報を YAML に平文で持つ前提 | `` | scenario_test/config.py / templates/scenario.config.yaml / SKILL.md | +| 5 | Major | hook と auth キャッシュの統合テストが不足 | `` | tests/test_auth_cache.py / test_makereport_user_properties.py / test_pytest_terminal_summary.py | +| 6 | Minor | step_delay_ms の dataclass / from_raw 不一致 | `` | scenario_test/config.py | +| 7 | Minor | 空 YAML で TypeError | `` | scenario_test/config.py | +| 8 | Minor | Drive upload の機微情報注意喚起が弱い | `` | scenario_test/pytest_plugin.py / SKILL.md | + +### pytest 結果 +``` +<実行ログ末尾> +``` + +### 残課題 / 任意検証 +- (あれば追記) + +@takemi-ohama 再レビューをお願いします。 +※ 自 PR のため Resolve Conversation は対象 review が **comment** 形式であり不要です。 +``` + +### PR description 更新 (`gh pr edit 57 --body-file `) + +既存 description の commit 表に新規 6 commit を追記。 +`## やらないこと` に `--ndf-upload-types` allowlist を v0.4.0 TODO として明記。 + +## 復帰情報 + +途中停止からの復帰手順: +1. `cd /work/ai-plugins && git status` で現在の差分を確認 +2. `git log --oneline origin/feature/scenario-test-v0.3.0-pytest-native..HEAD` で + 未 push のローカルコミット数を確認 +3. このファイル (`issues/i15.md`) の Commit 計画と照合し、未着手の commit から再開 +4. 各 commit 完了後に必ず pytest を回し、本ファイルの「進捗」を更新 + +## 注意事項 + +- main への直接 commit / push は厳禁 (現在 `feature/scenario-test-v0.3.0-pytest-native` 上) +- ユーザー確認なしで PR Approve / Merge は行わない +- 各 commit メッセージ末尾に `Co-Authored-By: Claude Opus 4.7 (1M context) ` +- Major 1 の HAR 設計選択 (function-scope) の根拠を commit メッセージに含める (上記テンプレ済) +- Major 5 の統合テストは Playwright 実機なしで動かせる範囲に限定 +- 既存 `_safe_slug(name, fallback)` 単体仕様は変えず、`_safe_case_slug(node)` を新設して + evidence fixture 内のみ切替える (既存テスト `test_safe_slug` を壊さない) diff --git a/issues/i16.md b/issues/i16.md new file mode 100644 index 00000000..2ebccca4 --- /dev/null +++ b/issues/i16.md @@ -0,0 +1,53 @@ +# 開発AIエージェント向け指示書 + +* このドキュメントはClaude Codeなどの開発AIエージェント向けの指示書です。 +* チャットで「docs/cmd01.mdのx番を実行してください」と言われたらこのファイルを読み、見出しに書いてある番号の内容を実行してください。 +* 頻繁に書き換わるので、指示があるたびに読み込みなおしてください。 +* 返答やドキュメントはすべて日本語で。 + +# 1. ndf plugin紹介プレゼンテーション資料作成 +* plugins/ndf を紹介するプレゼン資料を作成してください。 +* skill配布の方式 + * user / project / plugin + * pluginのメリット +* ndfで達成したいこと + * 開発体験の共有 + * 特に開発ワークフロー + * /pr, /review, /fix, /cross-review, /pr-tests , /mergedなど + * 自動読み込みではなくスラッシュコマンドを使うことで「固い」フローを実現 + * mcpよりcli + * なぜ今mcpよりcliと言われているのか(要外部記事調査) + * playwright + * codex / gemini + * google-auth, google-drive +* ndfのインストール方法 +* 自分pluginのススメ + * 自分なりの「開発しやすい環境づくり」重要 +* 全体で10枚程度 +* issues/ndf-presentation.md に出力 +* ここに書いてあることだけでなく、ndfの特徴は積極的に追加 +* ただし過剰な装飾後、煽りは不要。事実ベースで説明 +* 成果物はNoteboolLMで作成予定 + +# 2. issue-plan strategy skill作成 +* ある一つのissueに対して、plan(企画書・設計書に相当)作成の結果、推奨されるpull requestが複数に分かれることは日常的に発生します。 + * 例 + /work/carmo-system-console/issues/takemi.ohama/PLAN79.md + /work/carmo-system-console/issues/takemi.ohama/PLAN80.md +* こうした場合の一般的な開発フローをSkill化してndf pluginに入れてください +* スラッシュコマンドとしても、自動読み込みのSkillとしても利用可能とする + * あるissuesに対してのplanを作成するように依頼された場合に自動発動するskillとする + * スラッシュコマンドで引数にissuesのパス(ファイルパスまたはURL等)を渡された場合にそこから読み取った情報をissuesとしてplanを作る場合にも適用 +* issuesの内容をplan(企画・設 +計)に落とし込む + * 必要なら/plan モードに切り替えて実施 +* 複数PRにまたがる場合、mainなどのdefaultブランチに対してreleaseブランチを作成するものとする +* 個々のPRはreleaseブランチをbaseとして作成するものとする(個/別branch) +* plan実行の際、初めにrelease branchと個別branchを作成する + * branchに対するPRも先にDraftとして作成しておく +* git worktreeを使って可能な範囲で並行開発を行う +* レビュー(/reviewや/cross-review)は原則個別PR単位で行う +* release branchに対するレビューは原則結合テストに相当する箇所についてのみ行う + * release branchレビュー中に個別PRに対する指摘が出た場合は個別PR側に書き込む +* 検証環境(qa環境)向けのbranchでテストする場合は個別PR単位またはrelease branch単位のPRを作成しても構わない(/cherry-pick-pr skill参照) +* diff --git a/issues/memory_replaning.md b/issues/memory_replaning.md new file mode 100644 index 00000000..2c9231c7 --- /dev/null +++ b/issues/memory_replaning.md @@ -0,0 +1,558 @@ +以下は **AI Agent(Claude Code / Codex CLI / Gemini CLI / Kiro 等)に直接読ませることを前提にした方針ドキュメント**です。 +目的は **AGENTS.md を軽量化しつつ、Serena memory から移行した知識を整理するための標準ルール**を定義することです。 + +--- + +# AI Agent Knowledge Architecture Policy + +## Purpose + +This document defines how knowledge and operational instructions for AI agents are organized within this repository. + +The goal is to ensure: + +* predictable behavior across multiple AI agents +* minimal context usage +* compatibility with different agent environments +* maintainable long-term documentation + +This policy applies to all agents including: + +* Claude Code +* Codex CLI +* Gemini CLI +* Kiro +* other MCP-compatible agents + +--- + +# Core Principle + +Knowledge must be separated into the following layers: + +``` +AGENTS.md → navigation + policy +docs/ → repository knowledge +skills/ → executable workflows for agents +src/ → source code +``` + +Agents must **not treat AGENTS.md as a full knowledge base**. +AGENTS.md is only an **entry point**. + +--- + +# Knowledge Layer Responsibilities + +## AGENTS.md + +AGENTS.md is the **entry document for AI agents**. + +It must contain only: + +* repository overview +* navigation to documentation +* agent behavior rules +* references to skills + +AGENTS.md must remain **small and lightweight**. + +Recommended size: + +* under 300 lines +* ideally under 1500 tokens + +AGENTS.md must NOT contain: + +* detailed architecture +* database schemas +* long explanations +* operational procedures + +Those belong in the docs or skills directories. + +--- + +## docs/ + +The docs directory contains **repository knowledge**. + +This is the canonical location for information previously stored in: + +``` +.serena/memories/ +``` + +Examples: + +``` +docs/ + architecture/ + database/ + modules/ + repo-structure.md + dependencies.md +``` + +Content suitable for docs: + +* system architecture +* repository structure +* module explanations +* dependency relationships +* infrastructure overview +* design philosophy + +Docs are written for both humans and AI agents. + +Agents may read docs when they need deeper context. + +--- + +## skills/ + +Skills define **agent execution procedures**. + +A skill is used when the agent needs to perform a structured task. + +Examples: + +``` +.claude/skills/ + deploy-service.md + run-migrations.md + review-pull-request.md +``` + +Skill content typically includes: + +* step-by-step instructions +* commands to execute +* validation rules +* checklists + +Skills should not contain general architecture explanations. + +--- + +# Migration from Serena Memory + +Knowledge stored in: + +``` +.serena/memories/ +``` + +must be redistributed according to the following rules. + +| Memory Type | Destination | +| ------------------------ | ----------- | +| Repository structure | docs | +| Architecture explanation | docs | +| Module descriptions | docs | +| Dependencies | docs | +| Agent procedures | skills | +| Temporary investigations | remove | +| Task history | remove | + +Agents must not rely on `.serena/memories`. + +--- + +# Agent Reading Strategy + +Agents should follow this reading order when working in the repository. + +``` +1. AGENTS.md +2. relevant docs +3. skills (if a workflow is required) +4. source code +``` + +Agents should **not load the entire docs directory unnecessarily**. + +Agents should read only relevant documentation sections. + +--- + +# Multi-Agent Compatibility + +All documentation must remain compatible with multiple agents. + +Therefore: + +* avoid tool-specific formats +* avoid Serena-specific knowledge storage +* prefer plain Markdown +* prefer stable directory structures + +This ensures the repository works consistently with: + +* Claude Code +* Codex CLI +* Gemini CLI +* Kiro +* future agent systems + +--- + +# Serena MCP Usage + +Serena MCP is allowed only for: + +* LSP features +* symbol search +* semantic code navigation +* safe refactoring + +Serena memory features must not be used. + +Knowledge must remain in: + +``` +docs/ +skills/ +``` + +This prevents knowledge fragmentation across agent tools. + +--- + +# Documentation Index + +The following documents provide deeper repository knowledge. + +``` +docs/repo-structure.md +docs/architecture/ +docs/database/ +docs/modules/ +docs/infrastructure/ +``` + +Agents should consult these documents when architectural understanding is required. + +--- + +# Summary + +This repository follows a layered knowledge architecture: + +``` +AGENTS.md → navigation and policy +docs/ → repository knowledge +skills/ → executable workflows +src/ → implementation +``` + +This structure ensures: + +* stable agent behavior +* reduced context usage +* long-term maintainability +* compatibility with multiple AI development tools + +Agents must respect this structure when reading or writing repository knowledge. + + +--- + + +以下は **AI Agent(Claude Code / Codex CLI / Gemini CLI / Kiro 等)が最初に読むことを想定した軽量な `AGENTS.md` テンプレート**です。 +前のポリシー(AI Agent Knowledge Architecture Policy)に従い、**インデックス+行動ルールのみ**にしています。 + +--- + +# AGENTS.md + +## Purpose + +This file is the entry point for AI agents working in this repository. + +Agents must use this document to understand: + +* repository purpose +* where knowledge is located +* how to behave when modifying code +* which skills to use + +This file is intentionally lightweight. + +Detailed documentation exists in the `docs/` directory. + +--- + +# Project Overview + +This repository contains the implementation of the system. + +Primary responsibilities of the repository may include: + +* application logic +* infrastructure definitions +* database schema +* operational tooling + +Agents should read the relevant documentation before performing major modifications. + +See: + +```text +docs/architecture/ +docs/repo-structure.md +``` + +--- + +# Repository Structure + +High level structure: + +```text +src/ → source code +docs/ → repository knowledge +.claude/ → Claude Code skills +.codex/ → Codex CLI skills +infra/ → infrastructure definitions +scripts/ → operational scripts +``` + +Agents should explore directories before editing files. + +See detailed explanation: + +``` +docs/repo-structure.md +``` + +--- + +# Knowledge Architecture + +This repository separates knowledge into layers. + +```text +AGENTS.md → navigation + policy +docs/ → repository knowledge +skills/ → executable workflows +src/ → implementation +``` + +Agents must **not add detailed knowledge to AGENTS.md**. + +New documentation should be written in: + +``` +docs/ +``` + +Operational procedures should be written as: + +``` +.claude/skills/ +``` + +--- + +# Agent Behavior Rules + +Agents must follow these rules when modifying the repository. + +### Read before modifying + +Before implementing changes, agents should read: + +* relevant documentation in `docs/` +* related source code +* existing implementation patterns + +--- + +### Prefer existing patterns + +When implementing new code: + +* follow the architecture described in `docs/architecture` +* reuse existing modules +* avoid introducing new patterns without justification + +--- + +### Avoid large refactors unless required + +Agents should avoid: + +* large-scale refactors +* architectural rewrites +* dependency changes + +unless explicitly requested. + +--- + +### Keep changes minimal + +Agents should: + +* modify only the necessary files +* avoid unrelated changes +* preserve formatting and coding style + +--- + +# Documentation Index + +Agents may consult the following documents for deeper understanding. + +## Architecture + +``` +docs/architecture/ +``` + +Contains system design and component relationships. + +--- + +## Repository Structure + +``` +docs/repo-structure.md +``` + +Explains the purpose of each major directory. + +--- + +## Modules + +``` +docs/modules/ +``` + +Describes internal modules and responsibilities. + +--- + +## Database + +``` +docs/database/ +``` + +Contains schema explanations and data flow. + +--- + +# Skills + +Operational workflows are implemented as skills. + +Examples: + +``` +.claude/skills/deploy-service.md +.claude/skills/run-migrations.md +.claude/skills/review-pull-request.md +``` + +Agents should prefer using skills when performing structured tasks. + +--- + +# Serena MCP Usage + +Serena MCP may be used for: + +* symbol search +* reference search +* semantic navigation +* safe refactoring + +Serena memory must not be used. + +Repository knowledge must remain in: + +``` +docs/ +skills/ +``` + +--- + +# Reading Strategy for Agents + +Agents should read repository knowledge in the following order. + +```text +1. AGENTS.md +2. relevant docs +3. skills (if needed) +4. source code +``` + +Agents should avoid loading unnecessary documentation. + +--- + +# When Documentation is Missing + +If the required knowledge does not exist: + +Agents may create new documentation in: + +``` +docs/ +``` + +Do not add long explanations to AGENTS.md. + +--- + +# Summary + +AGENTS.md acts only as a navigation entry point. + +```text +AGENTS.md → navigation +docs/ → knowledge +skills/ → workflows +src/ → code +``` + +Agents must respect this structure to ensure compatibility with: + +* Claude Code +* Codex CLI +* Gemini CLI +* Kiro +* future AI development agents + +--- + +💡補足(重要) + +このテンプレートの狙いは次の3つです。 + +1️⃣ **AGENTS.mdを軽量に保つ(~500〜1200 tokens)** +2️⃣ **docsを知識ベースにする** +3️⃣ **skillsを実行手順にする** + +この構造にすると、あなたが使っている + +* Claude Code +* Codex CLI +* Gemini CLI +* Kiro +* MCP + +すべてで **最も安定して動くリポジトリ構造**になります。 + +--- + +もしよければ次に、 +**Serena memory → docs へ移すときの「最適ディレクトリ構造」**も作ります。 + +これは実はかなり重要で、 +**AIのコード理解速度が2〜5倍くらい変わることがあります。** diff --git a/issues/ndf-claude-ndf-md-abolition.md b/issues/ndf-claude-ndf-md-abolition.md new file mode 100644 index 00000000..c59bfe64 --- /dev/null +++ b/issues/ndf-claude-ndf-md-abolition.md @@ -0,0 +1,265 @@ +# CLAUDE.ndf.md廃止 - 3層構造移行計画 + +## ステータス +- 作成日: 2026-03-08 +- 最終更新: 2026-03-08 +- 現在のフェーズ: 実装完了(PR #39) + +## 概要 + +CLAUDE.ndf.mdを廃止し、3層構造(エントリポイント/docs/skills)に移行する。 +Serena memoryも廃止し、知識はdocs/、手順はskills/に配置する方針。 + +--- + +## 依存関係グラフ + +``` +タスク1 (mcp-serena新規作成) ← 独立 +タスク2 (ndf-policiesスキル追加) ← 独立 +タスク3 (/ndf:cleanupスキル追加) ← 独立 +タスク5 (MCPプラグインにdocs追加) ← 独立 +タスク4 (NDF既存資産の廃止・変更) ← タスク1完了後(Serena MCPがNDFから分離済みであること) +タスク6 (ルートファイル変更) ← タスク1完了後(marketplace.jsonにmcp-serena追加) + ← タスク4完了後(ルートCLAUDE.ndf.md削除はタスク4の方針確定後) +``` + +--- + +## 実装順序 + +### フェーズA: 独立タスク(並列実行可能) + +以下4タスクは相互依存なし。並列実行推奨。 + +#### タスク1: mcp-serenaプラグイン新規作成 + +**目的**: Serena MCPをNDFから分離し、独立プラグインとして作成 + +**ファイル操作**: +| 操作 | ファイル | +|------|---------| +| 作成 | `plugins/mcp-serena/.claude-plugin/plugin.json` | +| 作成 | `plugins/mcp-serena/.mcp.json` | +| 作成 | `plugins/mcp-serena/hooks/hooks.json` | +| 作成 | `plugins/mcp-serena/docs/serena-guide.md` | +| 作成 | `plugins/mcp-serena/README.md` | + +**詳細**: +- `plugin.json`: name=mcp-serena, version=1.0.0。既存MCPプラグイン(mcp-bigqueryなど)の構造に準拠 +- `.mcp.json`: NDF `.mcp.json`からserenaセクションのみ抽出。codexは含めない +- `hooks/hooks.json`: SessionStartフックで `activate_project` + `check_onboarding_performed` を実行 +- `docs/serena-guide.md`: シンボル検索、リファクタリングコマンドのリファレンス。memory機能は記載しない +- スキルなし。memory機能は使用禁止。コード操作専用 + +**参考**: 既存MCPプラグインの構造パターン +``` +plugins/mcp-bigquery/ +├── .claude-plugin/plugin.json # name, version, description, author, keywords のみ +├── .mcp.json # MCPサーバー定義 +└── README.md +``` + +#### タスク2: ndf-policiesスキル追加 + +**目的**: CLAUDE.ndf.mdのポリシー部分をmodel-invokedスキルに移行 + +**ファイル操作**: +| 操作 | ファイル | +|------|---------| +| 作成 | `plugins/ndf/skills/ndf-policies/SKILL.md` | +| 編集 | `plugins/ndf/.claude-plugin/plugin.json` (skills配列に追加) | + +**詳細**: +- `user-invocable: false`, `disable-model-invocation: false` で常時コンテキスト注入 +- descriptionにポリシーサマリーを記載(Claudeが自動で読み込むトリガー) +- SKILL.md本文に以下のポリシーを記載: + - 応答・ドキュメント・コミットメッセージは日本語 + - mainブランチへの直接push/merge禁止 + - commit/push/PR mergeはユーザー確認後 + - コンテキスト節約(ファイル全体を読む前にSerenaのシンボル概要確認) + - 複雑タスクはndf:directorに委譲 + - 専門タスクは対応エージェントに直接委譲 + - 知識はdocs/に、手順はskills/に配置(AGENTS.mdを肥大化させない) + +**注意**: plugin.jsonのバージョン更新はタスク4でまとめて行う + +#### タスク3: /ndf:cleanupスキル追加 + +**目的**: CLAUDE.ndf.mdの後始末用スラッシュコマンド + +**ファイル操作**: +| 操作 | ファイル | +|------|---------| +| 作成 | `plugins/ndf/skills/cleanup/SKILL.md` | +| 編集 | `plugins/ndf/.claude-plugin/plugin.json` (skills配列に追加) | + +**詳細**: +- `user-invocable: true`, `disable-model-invocation: true`(手動呼出のみ) +- 処理内容: + 1. プロジェクトルートの`CLAUDE.ndf.md`検出 → 削除 + 2. `CLAUDE.md`から`@CLAUDE.ndf.md`行を削除 + 3. `AGENTS.md`から`@CLAUDE.ndf.md`行を削除(存在する場合) + 4. `~/.claude/CLAUDE.ndf.md`検出 → 削除 + 5. `~/.claude/CLAUDE.md`から`@CLAUDE.ndf.md`行を削除 + 6. 結果報告 +- allowed-tools: Bash, Read, Edit, Write + +**注意**: plugin.jsonのバージョン更新はタスク4でまとめて行う + +#### タスク5: MCPプラグインにdocs/guide.md追加(5件) + +**目的**: 各MCPプラグインに使い方ガイドを追加 + +**ファイル操作**: +| 操作 | ファイル | +|------|---------| +| 作成 | `plugins/mcp-bigquery/docs/bigquery-guide.md` | +| 作成 | `plugins/mcp-dbhub/docs/dbhub-guide.md` | +| 作成 | `plugins/mcp-markitdown/docs/markitdown-guide.md` | +| 作成 | `plugins/mcp-chrome-devtools/docs/chrome-devtools-guide.md` | +| 作成 | `plugins/mcp-aws-docs/docs/aws-docs-guide.md` | + +**詳細**: +- スキルではなくドキュメントとして配置 +- 各プラグインの.mcp.jsonとREADME.mdを参考に、具体的なツール名・使い方を記載 +- 既存のdocs/ディレクトリは存在しないため新規作成が必要 + +--- + +### フェーズB: NDF既存資産の廃止・変更(フェーズA完了後) + +#### タスク4: NDF既存資産の廃止・変更 + +**前提条件**: タスク1完了(Serena MCPが独立プラグインとして存在すること) + +**目的**: memory系スキル削除、Serena MCP分離、inject仕組み廃止 + +**ファイル操作**: +| 操作 | ファイル | +|------|---------| +| 削除 | `plugins/ndf/skills/serena/SKILL.md` (ディレクトリごと) | +| 削除 | `plugins/ndf/skills/memory-handling/SKILL.md` (ディレクトリごと) | +| 削除 | `plugins/ndf/skills/serena-memory-strategy/SKILL.md` (ディレクトリごと) | +| 削除 | `plugins/ndf/skills/mem-capture/SKILL.md` (ディレクトリごと) | +| 削除 | `plugins/ndf/skills/mem-review/SKILL.md` (ディレクトリごと) | +| 削除 | `plugins/ndf/scripts/inject-plugin-guide.js` | +| 削除 | `plugins/ndf/CLAUDE.ndf.md` | +| 編集 | `plugins/ndf/.mcp.json` → serenaセクション削除(codexのみ残す) | +| 編集 | `plugins/ndf/hooks/hooks.json` → SessionStartのinjectフック削除、CLAUDE.ndf.md検出警告フックに変更 | +| 編集 | `plugins/ndf/.claude-plugin/plugin.json` → skills配列から5個削除、ndf-policies追加、cleanup追加、バージョン更新(3.0.0) | + +**plugin.json skills配列の変更(差分)**: +``` +削除: + - "./skills/serena" + - "./skills/mem-review" + - "./skills/mem-capture" + - "./skills/memory-handling" + - "./skills/serena-memory-strategy" + +追加(タスク2,3で作成済み): + + "./skills/ndf-policies" + + "./skills/cleanup" +``` + +**hooks/hooks.json変更内容**: +- SessionStartの`inject-plugin-guide.js`呼出を削除 +- 代わりにCLAUDE.ndf.md検出時の警告メッセージを出すフックに変更 + (例: 「CLAUDE.ndf.mdは廃止されました。/ndf:cleanupを実行してください」) +- Stopフック(slack-notify.js)はそのまま維持 + +**.mcp.json変更内容**: +```json +// 変更前: serena + codex の2サーバー +// 変更後: codex のみ +{ + "mcpServers": { + "codex": { ... } // 既存のまま + } +} +``` + +**バージョン**: 2.9.0 → 3.0.0(破壊的変更: memory系スキル削除、Serena MCP分離) + +--- + +### フェーズC: ルートファイル変更(フェーズA・B完了後) + +#### タスク6: ルートファイル変更 + +**前提条件**: タスク1完了(mcp-serena存在)、タスク4完了(NDF側のCLAUDE.ndf.md削除済み) + +**ファイル操作**: +| 操作 | ファイル | +|------|---------| +| 削除 | `CLAUDE.ndf.md` (ルート、544行) | +| 編集 | `.claude-plugin/marketplace.json` → pluginsにmcp-serenaエントリ追加 | +| 編集 | `CLAUDE.md` → `@CLAUDE.ndf.md`参照行を削除、Serena関連の記述をmcp-serenaプラグイン参照に変更 | + +**marketplace.json追加エントリ**: +```json +{ + "name": "mcp-serena", + "source": "./plugins/mcp-serena", + "description": "Serena MCP server for semantic code intelligence (symbol search, refactoring)" +} +``` + +**CLAUDE.md変更箇所**: +- 155行目の`@CLAUDE.ndf.md`行を削除 +- Serena MCPの使用説明をmcp-serenaプラグインへの参照に書き換え +- memory関連の記述を削除または更新 + +--- + +## 並列実行まとめ + +``` +時間軸 → + +フェーズA(並列): + [タスク1: mcp-serena作成] + [タスク2: ndf-policiesスキル] + [タスク3: /ndf:cleanupスキル] + [タスク5: MCPプラグインdocs追加] + +フェーズB(タスク1完了後): + [タスク4: NDF既存資産の廃止・変更] + +フェーズC(タスク4完了後): + [タスク6: ルートファイル変更] +``` + +**推奨エージェント**: すべて `ndf:corder`(ファイル作成・編集・削除のコーディングタスク) + +--- + +## 注意点・リスク + +### 高リスク + +1. **plugin.jsonの競合**: タスク2,3,4がすべてplugin.jsonを編集する。タスク2,3はskills配列への追加のみだが、タスク4でskills配列の大幅変更+バージョン更新を行う。**対策**: タスク2,3ではplugin.jsonのバージョンは変更せず、タスク4でまとめてplugin.json全体を更新する。 + +2. **CLAUDE.ndf.md二重削除**: タスク4で`plugins/ndf/CLAUDE.ndf.md`を、タスク6で`CLAUDE.ndf.md`(ルート)を削除する。別ファイルなので問題ないが、混同に注意。 + +### 中リスク + +3. **hooks.json変更の検証**: CLAUDE.ndf.md検出警告フックの実装方法は要検討。シェルコマンドで`[ -f CLAUDE.ndf.md ]`を検出し警告メッセージを出力する形が現実的。 + +4. **既存ユーザーへの影響**: mcp-serenaを別途インストールしないとSerena MCPが使えなくなる。/ndf:cleanupの実行案内が必要。 + +5. **CLAUDE.md(ルート)の編集範囲**: 155行目の`@CLAUDE.ndf.md`削除は単純だが、Serena関連の記述書き換え範囲はタスク実行時に確認が必要。 + +### 低リスク + +6. **docs/ディレクトリ作成**: 5つのMCPプラグインにdocs/を新規作成する。既存ファイルとの衝突なし。 + +7. **scripts/inject-plugin-guide.js削除**: slack-notify.jsは残すため、scripts/ディレクトリ自体は削除しない。 + +--- + +## 復帰情報 + +各タスクは独立性が高いため、途中停止しても個別タスク単位で再開可能。 +plugin.jsonの最終更新はタスク4に集約しているため、タスク4実行時にタスク2,3の完了状態を確認すること。 diff --git a/issues/ndf-presentation.md b/issues/ndf-presentation.md new file mode 100644 index 00000000..3d137e11 --- /dev/null +++ b/issues/ndf-presentation.md @@ -0,0 +1,247 @@ +# NDF Plugin 紹介プレゼンテーション + +> Claude Code 向け統合プラグイン `ndf` の紹介資料。NotebookLM での読み込みを想定し、1スライド=1セクションで構成する。 + +--- + +## スライド 1: タイトル / 何のプラグインか + +**NDF Plugin — Claude Code 開発環境を統合する Skill / Agent / Hook パッケージ** + +- リポジトリ: +- バージョン: v4.3.1 +- 提供物 + - **Skill 38個**(PR/レビューワークフロー、原則ガイドライン、外部AI連携、Playwright E2E など) + - **Sub Agent 8個**(director / corder / data-analyst / researcher / qa / debugger / devops-engineer / code-reviewer) + - **自動 Hook**(SessionStart で transcript 保持期間管理 / Stop で AI 要約 → Slack 通知) +- ライセンス: MIT +- 動作環境: Claude Code(CLI / IDE / Web 共通) + +--- + +## スライド 2: Claude Code における Skill 配布の3方式 + +Claude Code が Skill / Agent / Slash Command を取り込む経路は3つある。 + +| 方式 | 配置場所 | スコープ | 配布 | +|---|---|---|---| +| **user** | `~/.claude/skills/`, `~/.claude/agents/` | 自分の全プロジェクト共通 | 手動コピー | +| **project** | `/.claude/skills/` | 1リポジトリ内のみ | git で共有 | +| **plugin** | `~/.claude/plugins//` | インストールした全プロジェクト | marketplace 経由 | + +```mermaid +flowchart LR + A[Claude Code 起動] --> B{Skill 解決順} + B --> C[user: 自分専用] + B --> D[project: チーム共有] + B --> E[plugin: 公開配布] +``` + +--- + +## スライド 3: なぜ plugin 方式か + +事実ベースの利点を列挙する。 + +- **インストール / 更新が1コマンド** + - `/plugin marketplace add` → `/plugin install` でセットアップ完了 + - バージョン管理は `plugin.json` の semver で plugin 側に集約 +- **複数の構成要素を一括提供** + - 1 plugin に Skill / Sub Agent / Hook / MCP 定義を同梱できる(user / project 方式では個別管理) +- **チームを跨いだ再利用** + - リポジトリ単位(project)ではなく、Marketplace 単位で広く共有できる +- **副作用の局所化** + - `/plugin disable` で全機能をまとめて無効化可能。user / project は手動削除が必要 + +--- + +## スライド 4: ndf が目指すこと — 開発体験の共有 + +`ndf` の主目的は「個人の開発ワークフローを再現可能な形でチームに配布する」こと。 + +- 個人で蓄積したノウハウは通常、ローカル設定や暗黙知に閉じ込められる +- Claude Code は Skill / Agent でこれを **テキスト化** できる +- plugin として配布すれば、**同じ手順を別マシン・別人で実行可能** になる +- ndf はこの考えに基づき、PR 運用 / レビュー / デバッグ / Web テスト など普段使いの手順をひと通り収録している + +スコープ: + +- 「便利機能を全部入れる」ではなく「**一連のフローを完結させる**」ことを優先 +- 例: 「PR を出す」一連の流れは `/ndf:pr` → `/ndf:pr-tests` → `/ndf:review` → `/ndf:fix` → `/ndf:merged` で閉じる + +--- + +## スライド 5: 「固いフロー」を作る — スラッシュコマンド主義 + +ndf の Skill は、原則 `disable-model-invocation: true` を付け **モデル自動起動を禁止** している。 + +- 自動読み込み(model-invocation)は便利だが、**実行順序とタイミングがモデル任せ** になり再現性が低い +- ndf は**ユーザが明示的に `/ndf:xxx` を叩く** ことで、毎回同じ手順を踏むことを保証する +- これにより「PR 出し忘れ・テスト計画スキップ・Resolve 漏れ」といった揺らぎを排除 + +主要ワークフロー Skill: + +| Skill | 役割 | +|---|---| +| `/ndf:pr` | commit + push + PR 作成 / 既存 PR 説明更新 | +| `/ndf:pr-tests` | PR の Test Plan を自動実行 | +| `/ndf:review` | PR 単位レビュー(Approve / Request Changes 判定) | +| `/ndf:fix` | PR レビューコメントへの修正対応 | +| `/ndf:cross-review` | codex / gemini 両方が APPROVE するまで自動ループ | +| `/ndf:resolve-pr-comments` | 対応済みコメント返信 + Resolve | +| `/ndf:merged` | マージ後のローカルブランチクリーンアップ | +| `/ndf:cherry-pick-pr` | 環境ブランチへの cherry-pick PR 作成 | +| `/ndf:sync-main` | 最新 main を現在ブランチに取り込み | + +--- + +## スライド 6: 目玉機能 — クロスレビュー収束ループ + +`/ndf:cross-review ` は、**codex / gemini 両方** がレビューを返し、両者 APPROVE になるまで自動で `/ndf:review` と `/ndf:fix` を回す。 + +```mermaid +flowchart TD + A[Round N 開始] --> B[codex review 並列] + A --> C[gemini review 並列] + B --> D{両方 APPROVE?} + C --> D + D -- Yes --> Z[完了] + D -- No --> E[subagent で /ndf:fix] + E --> F{rotate_after 到達?} + F -- Yes --> G[PR ローテーション
squash + 新PR] + F -- No --> A + G --> A +``` + +設計上の特徴(事実): + +- **メイン context を太らせない**: レビュー本文は AI 自身が `gh api` で投稿、修正は `general-purpose` サブエージェント側で実行 +- **状態を `/tmp/cross-review-pr<番号>-state.json` に永続化** し、中断・再開可能 +- **振動検知**: 前ラウンドと同じ指摘が 50% 以上重複したら自動中断 +- **PR ローテーション**: 一定 round で squash + 新 PR を切り、巨大化を防ぐ + +--- + +## スライド 7: 「MCP より CLI」の流れ + +2026 年に入り、AI コーディングエージェント向けツール接続は **MCP よりも CLI 直叩きが推奨される** ケースが増えている。外部記事の論点は以下の通り。 + +- **トークン消費**: GitHub MCP は93ツールで起動時に約 55,000 token を context に積む。一方 `gh` CLI はモデル既知でスキーマ追加 0 token、実呼び出しも ~200 token 程度 +- **信頼性**: 比較記事の計測で MCP は 25 試行中 7 件 TCP timeout、CLI は 100% 成功 +- **構成性**: Unix の pipe / シェル合成は学習データに大量に存在し、モデルが扱い慣れている +- **学習量**: man page / Stack Overflow など、CLI の使用例の学習素材が圧倒的に多い + +ただし MCP は **認証・多人数運用・企業ガバナンス** で優位なため、現実は併用が基本。 + +### ndf の判断 + +- 旧 v3 系で同梱していた **Codex MCP サーバを v4.0.0 で廃止** +- 代わりに `/ndf:codex` Skill / `corder` Agent から `codex exec` を **CLI 直接実行** +- Gemini も同様に `/ndf:gemini` Skill から CLI を呼ぶ +- Web 自動化は Playwright(CLI / Python ライブラリ)、Google 連携も Google API CLI / Python で実装 + +→ 「動かないときに自分でデバッグできる」「context を食わない」CLI を優先する方針。 + +--- + +## スライド 8: ndf に同梱されている CLI / ツール群 + +```mermaid +flowchart LR + NDF[ndf plugin] --> WF[PR/Review
Workflow Skills] + NDF --> AI[外部AI委譲] + NDF --> WEB[Web自動化] + NDF --> G[Google連携] + NDF --> DEV[開発補助] + + AI --> AI1[/ndf:codex
codex exec/] + AI --> AI2[/ndf:gemini
gemini -p/] + WEB --> W1[playwright-scenario-test
pytest-playwright + HUD動画] + WEB --> W2[browser-test
Chrome DevTools] + G --> G1[google-auth
OAuth2 一元管理] + G --> G2[google-drive
export/upload] + DEV --> D1[git-gh-operations] + DEV --> D2[python-execution
uv 自動判定] + DEV --> D3[docker-container-access] + DEV --> D4[qa-security-scan
OWASP Top 10] +``` + +特徴的な Skill: + +- **`playwright-scenario-test`**: pytest-playwright + axe-core (a11y) + Core Web Vitals 計測 + body_check(fatal/warning パターン検出)+ Markdown レポート + Google Drive 共有を fixture として提供 +- **`google-auth`**: 単一トークンで Sheets / Drive / Calendar 等のスコープを一元管理。CLI / Python ライブラリ両方として使える +- **`skill-stats`**: transcript を集計して Skill 利用率を算出。description の網羅性チェックに使う + +--- + +## スライド 9: インストール手順 + +前提: + +- Claude Code 本体 +- Python 3.10+ と `uvx`(Serena MCP 用 / 別プラグイン `mcp-serena` 経由) +- Codex CLI(外部AIレビューを使う場合): `npm install -g @openai/codex` → `codex login` + +インストール: + +```bash +# 1. Marketplace を追加 +/plugin marketplace add https://github.com/takemi-ohama/ai-plugins + +# 2. NDF プラグイン本体をインストール +/plugin install ndf@ai-plugins + +# 3. 必要に応じて MCP プラグインを追加(任意) +/plugin install mcp-chrome-devtools@ai-plugins # Playwright相当 +/plugin install mcp-bigquery@ai-plugins +/plugin install mcp-dbhub@ai-plugins +/plugin install mcp-aws-docs@ai-plugins +/plugin install mcp-notion@ai-plugins +/plugin install mcp-serena@ai-plugins # コードインテリジェンス +``` + +環境変数(`.env`): + +```bash +SERENA_HOME=.serena +SLACK_BOT_TOKEN=xoxb-... # Slack 通知用(任意) +SLACK_CHANNEL_ID=C... +SLACK_USER_MENTION=<@U...> +``` + +設定後に Claude Code を再起動するとフック・MCP が読み込まれる。 + +--- + +## スライド 10: まず ndf を、その先に「自分 plugin」を + +ナイルのエンジニアにはまず `ndf` をそのまま使ってもらいたい。理由は単純で、PR / レビュー / マージ後クリーンアップといった**毎日繰り返す手順を最初から共有しておけば、レビュー体験と運用ノウハウがチーム内で揃う**ため。 + +- `/ndf:pr` → `/ndf:review` → `/ndf:fix` → `/ndf:cross-review` → `/ndf:merged` を全員が同じ手順で踏む +- 「私のところでは動く」状態を減らせる +- フィードバックを上げてもらえば本体側に反映できる(OSS / MIT) + +その上で、**慣れてきたら自分の plugin を作るのが次のステップ**。 + +- 開発フローはチーム・言語・リリースサイクルで違うため、自分の手に馴染んだコマンド群を別途持っておくと効率が上がる +- plugin 化しておけば、PC 移行や新規参加者のオンボーディングが `/plugin install` 一発で済む +- 追加コストは低い: `skills//SKILL.md` を書いて `plugin.json` の skills 配列に追加するだけ + +ndf の構成はそのまま雛形として流用可能: + +- `plugins//.claude-plugin/plugin.json` … メタ情報 +- `plugins//skills//SKILL.md` … YAML frontmatter + 手順 +- `plugins//agents/.md` … サブエージェント定義 +- `plugins//hooks/hooks.json` … SessionStart / Stop など +- `plugins//.mcp.json` … MCP サーバ定義(必要時のみ) + +ndf を共通土台に、各自の好みは個人 plugin に切り出す — この二段構えがチームと個人の両方にとって扱いやすい。 + +### 参考リンク + +- Claude Code plugin ドキュメント: +- Skill 仕様: +- 「CLI vs MCP」議論の例: + - + - + - diff --git a/issues/old/i001.md b/issues/old/i001.md new file mode 100644 index 00000000..4ff6a81b --- /dev/null +++ b/issues/old/i001.md @@ -0,0 +1,610 @@ +# NDFプラグインと公式プラグインの機能重複調査・削除計画 + +**作成日**: 2026-01-03 +**ステータス**: 調査完了・削除計画策定済み + +## 1. 調査概要 + +### 調査対象 +- **anthropics/claude-plugins-official**: https://github.com/anthropics/claude-plugins-official +- **NDFプラグイン**: `/work/ai-plugins/plugins/ndf/` + +### 調査結果サマリー + +anthropics/claude-plugins-officialには以下のプラグインが含まれています: + +**公式プラグイン (plugins/)**: 23個 +- LSP系プラグイン (10個): clangd, csharp, gopls, jdtls, lua, php, pyright, rust-analyzer, swift, typescript +- 開発ワークフロー系 (3個): code-review, commit-commands, pr-review-toolkit +- 開発支援系 (10個): agent-sdk-dev, example-plugin, explanatory-output-style, feature-dev, frontend-design, hookify, learning-output-style, plugin-dev, ralph-wiggum, security-guidance + +**外部プラグイン (external_plugins/)**: 13個 +- MCP統合系: github, context7, serena +- プロジェクト管理: asana, linear, gitlab, slack +- 開発ツール: firebase, greptile, laravel-boost, playwright, stripe, supabase + +## 2. 重複機能の詳細分析 + +### 2.1. MCPサーバーの重複 ⚠️ **重大な重複** + +NDFプラグインは`.mcp.json`で以下のMCPサーバーを統合しています: + +| MCPサーバー | NDFでの状態 | 公式での提供 | 重複度 | +|------------|-----------|------------|-------| +| **github** | 有効 (Docker) | external_plugins/github | **100%重複** | +| **context7** | 有効 (HTTP) | external_plugins/context7 | **100%重複** | +| **serena** | 有効 (uvx) | external_plugins/serena | **100%重複** | +| notion | 無効 | - | 重複なし | +| awslabs.aws-documentation-mcp-server | 無効 | - | 重複なし | +| mcp-server-bigquery | 無効 | - | 重複なし | +| dbhub | 無効 | - | 重複なし | +| chrome-devtools-mcp | 有効 | - | 重複なし | +| codex | 有効 | - | 重複なし | +| claude-code | 無効 | - | 重複なし | + +**結論**: **3つのMCPサーバー (github, context7, serena) が100%重複** + +### 2.2. スラッシュコマンドの重複 🔶 **部分的重複** + +#### NDFプラグインのコマンド (6個) +1. `/ndf:serena` - 開発記憶の記録 +2. `/ndf:pr` - PR作成 +3. `/ndf:fix` - PR修正対応 +4. `/ndf:review` - PRレビュー +5. `/ndf:merged` - マージ後クリーンアップ +6. `/ndf:clean` - ブランチクリーンアップ + +#### 公式プラグインのコマンド + +**commit-commands プラグイン**: +- Git commit/push/PR作成のワークフローコマンド +- NDFの`/ndf:pr`と**機能的に重複**の可能性あり + +**code-review プラグイン**: +- 自動コードレビュー機能 +- NDFの`/ndf:review`と**機能的に重複**の可能性あり + +**pr-review-toolkit プラグイン**: +- 包括的なPRレビューエージェント +- NDFの`/ndf:review`と**機能的に重複**の可能性あり + +**結論**: **コマンド名は異なるが、機能的には重複あり** + +### 2.3. サブエージェントの重複 ✅ **重複なし** + +#### NDFプラグインのエージェント (6個) +1. **director** - タスク統括と調整 +2. **data-analyst** - データ分析とSQL操作 +3. **corder** - 高品質コード生成 +4. **researcher** - 情報収集と分析 +5. **scanner** - ファイル読み取り +6. **qa** - 品質管理とテスト + +#### 公式プラグインのエージェント + +**pr-review-toolkit プラグイン**: +- comments, tests, error-handling, type-design, code-quality, code-simplification +- NDFの**qa**エージェントと**機能的に類似** + +**結論**: **名前は異なるが、qaエージェントとpr-review-toolkitは機能的に類似** + +### 2.4. Claude Code Skillsの重複 ✅ **重複なし** + +NDFプラグインは10個のClaude Code Skillsを独自に提供しており、公式プラグインには同等の機能はありません。 + +**結論**: **Skillsは完全にNDF独自機能** + +## 3. 削除対象の特定 + +### 3.1. 削除すべきもの(高優先度) + +#### A. MCPサーバー統合の削除 ⚠️ **最優先** + +**対象ファイル**: `/work/ai-plugins/plugins/ndf/.mcp.json` + +**削除対象セクション**: +```json +// 完全削除 +"github": { ... } +"context7": { ... } +"serena": { ... } +``` + +**理由**: +- これら3つのMCPサーバーは公式external_pluginsで提供されている +- ユーザーは公式プラグインを個別にインストール可能 +- NDFで統合する必要性なし +- メンテナンス負担の軽減 + +**影響範囲**: +- NDFプラグインのREADME.mdの「MCP統合」セクション更新必要 +- CLAUDE.ndf.mdの「Available MCP Tools」セクション更新必要 +- Serenaメモリー (`plugin-ndf.md`) の更新必要 +- plugin.jsonのバージョンインクリメント (1.2.1 → 1.3.0 or 2.0.0) + +### 3.2. 検討すべきもの(中優先度) + +#### B. スラッシュコマンドの整理 🔶 + +**現状**: +- NDFプラグインは6個のワークフローコマンドを提供 +- 公式には`commit-commands`, `code-review`, `pr-review-toolkit`が存在 +- 機能的に重複する可能性あり + +**推奨アクション**: +1. **詳細調査を実施** - 公式プラグインのコマンド内容を精査 +2. **差別化要素を明確化** - NDFコマンドの独自価値を確認 +3. **統合または削除を判断** - ユーザー体験を最優先 + +**注意**: +- ユーザーがすでにNDFコマンドに慣れている場合、削除は混乱を招く +- 公式コマンドとNDFコマンドの併用も可能 +- 段階的な移行計画が必要 + +#### C. qaエージェントとpr-review-toolkitの関係 🔶 + +**現状**: +- NDFの`qa`エージェントはコード品質・セキュリティ・パフォーマンステストを担当 +- 公式の`pr-review-toolkit`は包括的なPRレビュー機能を提供 +- 機能的に類似 + +**推奨アクション**: +1. **詳細調査を実施** - pr-review-toolkitの機能範囲を確認 +2. **補完関係の確認** - NDFのqaエージェントが提供する独自価値を評価 +3. **統合または共存を判断** - ユーザーニーズに基づく + +### 3.3. 保持すべきもの(削除不要) + +#### D. 独自のサブエージェント ✅ + +以下のエージェントは完全にNDF独自機能として保持: +- **director** - タスク統括(公式にない) +- **data-analyst** - BigQueryやDBHub連携(公式にない) +- **corder** - Codex統合のコーディング支援(公式にない) +- **researcher** - AWS Docs/Chrome DevTools連携(公式にない) +- **scanner** - PDF/Excel読み取り(公式にない) + +#### E. Claude Code Skills ✅ + +10個のSkillsはすべてNDF独自機能として保持: +- director-project-planning +- data-analyst-sql-optimization +- data-analyst-export +- corder-code-templates +- corder-test-generation +- researcher-report-templates +- scanner-pdf-analysis +- scanner-excel-extraction +- qa-code-review-checklist +- qa-security-scan + +#### F. 独自のMCPサーバー ✅ + +以下のMCPサーバーは公式に存在しないため保持: +- notion (現在無効) +- awslabs.aws-documentation-mcp-server (現在無効) +- mcp-server-bigquery (現在無効) +- dbhub (現在無効) +- chrome-devtools-mcp (有効) +- codex (有効) +- claude-code (現在無効) + +#### G. Slackフック・通知機能 ✅ + +以下は完全にNDF独自機能として保持: +- `hooks/hooks.json` +- `scripts/slack-notify.js` +- Stop hookによる自動Slack通知 + +## 4. 削除手順の詳細計画 + +### フェーズ1: MCPサーバー統合の削除(最優先) + +#### ステップ1: .mcp.jsonの編集 + +**対象ファイル**: `plugins/ndf/.mcp.json` + +**変更内容**: +```json +{ + "mcpServers": { + // ❌ 削除: "github": { ... } + // ❌ 削除: "context7": { ... } + // ❌ 削除: "serena": { ... } + + // ✅ 保持 + "notion": { ... }, + "awslabs.aws-documentation-mcp-server": { ... }, + "mcp-server-bigquery": { ... }, + "dbhub": { ... }, + "chrome-devtools-mcp": { ... }, + "codex": { ... }, + "claude-code": { ... } + } +} +``` + +#### ステップ2: README.mdの更新 + +**対象ファイル**: `plugins/ndf/README.md` + +**更新セクション**: +- 「MCP統合」セクション: 10サーバー → 7サーバーに変更 +- 削除したMCPサーバーの説明を削除 +- 公式external_pluginsへの参照を追加 + +**追加テキスト例**: +```markdown +## 公式プラグインとの連携 + +以下のMCPサーバーは公式external_pluginsで提供されています。 +必要に応じて個別にインストールしてください: + +- **GitHub MCP**: `/plugin install github@claude-plugin-directory` +- **Context7 MCP**: `/plugin install context7@claude-plugin-directory` +- **Serena MCP**: `/plugin install serena@claude-plugin-directory` +``` + +#### ステップ3: CLAUDE.ndf.mdの更新 + +**対象ファイル**: `plugins/ndf/CLAUDE.ndf.md` + +**更新セクション**: +- "Available MCP Tools (Reference)" セクション +- 削除したMCPサーバーの説明を削除 +- バージョンコメントを更新 + +```markdown + +``` + +#### ステップ4: Serenaメモリーの更新 + +**対象ファイル**: Serenaメモリー `plugin-ndf.md` + +**更新内容**: +- MCPサーバー数: 10 → 7に変更 +- 削除した3つのMCPサーバーの説明を削除 +- 公式プラグイン参照を追加 + +#### ステップ5: plugin.jsonのバージョン更新 + +**対象ファイル**: `plugins/ndf/.claude-plugin/plugin.json` + +**バージョン判断**: +- **MAJOR (2.0.0)**: MCP統合を削除は破壊的変更の可能性あり(推奨) +- **MINOR (1.3.0)**: 後方互換性があると判断した場合 + +**推奨**: **2.0.0** (破壊的変更として扱う) + +**理由**: +- ユーザーがgithub/context7/serena MCPに依存している場合、動作しなくなる +- 明示的なメジャーバージョンアップで変更を周知 + +```json +{ + "name": "ndf", + "version": "2.0.0", + "description": "Integrated plugin with specialized agents, commands, and skills. MCP servers moved to official plugins.", + ... +} +``` + +#### ステップ6: テスト + +1. **ローカルテスト**: + ```bash + /plugin reload ndf + /help mcp + ``` + - github, context7, serena MCPが表示されないことを確認 + +2. **機能テスト**: + - 残りの7つのMCPサーバーが正常に動作することを確認 + - サブエージェント(director, corder等)が正常に動作することを確認 + +3. **ドキュメント検証**: + - README.mdの記載が正確であることを確認 + - CLAUDE.ndf.mdの記載が正確であることを確認 + +#### ステップ7: コミット・PR作成 + +**ブランチ名**: `feature/remove-duplicate-mcp-servers` + +**コミットメッセージ例**: +``` +feat: GitHub/Context7/Serena MCPを公式プラグインに移譲 + +BREAKING CHANGE: 以下のMCPサーバーをNDFプラグインから削除 +- github MCP +- context7 MCP +- serena MCP + +これらのMCPサーバーは公式external_pluginsで提供されるため、 +必要に応じて個別にインストールしてください: + /plugin install github@claude-plugin-directory + /plugin install context7@claude-plugin-directory + /plugin install serena@claude-plugin-directory + +影響: +- .mcp.jsonから3つのMCPサーバー定義を削除 +- README.md、CLAUDE.ndf.md、Serenaメモリーを更新 +- バージョンを2.0.0にアップグレード + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +Co-Authored-By: Claude Opus 4.5 +``` + +**PR説明**: +```markdown +## Summary +- NDFプラグインからgithub/context7/serena MCPサーバー統合を削除 +- これらのMCPサーバーはanthropics/claude-plugins-officialのexternal_pluginsで提供されているため重複を解消 + +## Breaking Changes +⚠️ **破壊的変更**: 以下のMCPサーバーがNDFプラグインから削除されます +- GitHub MCP +- Context7 MCP +- Serena MCP + +## Migration Guide +ユーザーは以下のコマンドで公式プラグインをインストールできます: +```bash +/plugin install github@claude-plugin-directory +/plugin install context7@claude-plugin-directory +/plugin install serena@claude-plugin-directory +``` + +## Changes +- 削除: .mcp.json内のgithub/context7/serena MCP定義 +- 更新: README.md(MCP統合セクション) +- 更新: CLAUDE.ndf.md(Available MCP Toolsセクション) +- 更新: Serenaメモリー(plugin-ndf.md) +- 更新: plugin.json(バージョン2.0.0) + +## Test Plan +- [x] ローカルでNDFプラグインを再読み込み +- [x] 残りの7つのMCPサーバーが正常動作 +- [x] サブエージェントが正常動作 +- [x] ドキュメントが正確 + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +``` + +### フェーズ2: スラッシュコマンドの整理(中優先度) + +#### 前提条件 +フェーズ1(MCPサーバー削除)完了後に実施 + +#### ステップ1: 公式プラグインのコマンド詳細調査 + +**調査対象**: +1. `commit-commands` プラグインのコマンド一覧と機能 +2. `code-review` プラグインのコマンド一覧と機能 +3. `pr-review-toolkit` プラグインのコマンド一覧と機能 + +**調査方法**: +- GitHubリポジトリから各プラグインのREADME.mdとcommands/を確認 +- 実際にプラグインをインストールして動作確認 + +#### ステップ2: 重複・差別化要素の評価 + +**評価基準**: +| 項目 | 評価内容 | +|-----|---------| +| 機能の重複度 | 公式コマンドと完全に重複しているか | +| 独自価値 | NDFコマンドにしかない機能があるか | +| ユーザー体験 | コマンド名・使用方法が直感的か | +| メンテナンス性 | NDFで保持する価値があるか | + +#### ステップ3: 削除判断と実施 + +**判断フロー**: +1. **完全重複かつ独自価値なし** → 削除 +2. **部分重複だが独自価値あり** → 保持(ドキュメント強化) +3. **重複なし** → 保持 + +**削除手順**(削除対象がある場合): +1. `plugin.json`のcommandsフィールドから削除 +2. `commands/`ディレクトリから該当ファイルを削除 +3. README.md、CLAUDE.ndf.mdを更新 +4. Serenaメモリーを更新 +5. plugin.jsonのバージョンをインクリメント (2.0.0 → 2.1.0 or 3.0.0) +6. テスト・コミット・PR作成 + +### フェーズ3: qaエージェントの評価(低優先度) + +#### ステップ1: pr-review-toolkitの機能確認 + +**調査内容**: +- pr-review-toolkitのエージェント一覧と機能 +- NDFのqaエージェントとの機能比較 +- 補完関係の評価 + +#### ステップ2: 統合・共存判断 + +**判断基準**: +| シナリオ | アクション | +|---------|----------| +| 完全重複 | qaエージェントを削除または統合 | +| 補完関係 | 両方保持、ドキュメントで使い分けを明記 | +| 独自機能多数 | qaエージェント保持 | + +## 5. 削除による影響評価 + +### 5.1. ユーザーへの影響 + +#### A. MCPサーバー削除 (フェーズ1) + +**影響度**: 🔴 **高** + +**影響を受けるユーザー**: +- github/context7/serena MCPを使用している全ユーザー + +**移行手順**: +1. NDFプラグインを2.0.0にアップグレード +2. 必要なMCPサーバーを個別にインストール + ```bash + /plugin install github@claude-plugin-directory + /plugin install context7@claude-plugin-directory + /plugin install serena@claude-plugin-directory + ``` + +**メリット**: +- 公式プラグインの最新機能を利用可能 +- NDFプラグインのメンテナンス負担軽減 +- 明確な責任分界点 + +**デメリット**: +- 移行作業が必要 +- インストールコマンドが増える + +#### B. スラッシュコマンド削除 (フェーズ2) + +**影響度**: 🟡 **中** (削除対象による) + +**影響を受けるユーザー**: +- 削除対象コマンドを使用しているユーザー + +**移行手順**: +- 公式プラグインの対応コマンドに移行 + +#### C. qaエージェント削除 (フェーズ3) + +**影響度**: 🟡 **中** (削除する場合) + +**影響を受けるユーザー**: +- qaエージェントを使用している全ユーザー + +**移行手順**: +- pr-review-toolkitプラグインに移行 + +### 5.2. 技術的影響 + +**プラグイン構成**: +- ファイル数: 若干減少 +- 依存関係: シンプル化 +- メンテナンス性: 向上 + +**互換性**: +- バージョン1.x → 2.x: 破壊的変更あり +- 既存ユーザーへの周知必須 + +## 6. リスクと対策 + +### リスク1: ユーザーの混乱 + +**リスク**: MCPサーバーが突然使えなくなる + +**対策**: +- 明確なマイグレーションガイド提供 +- README.mdに大きく記載 +- BREAKING CHANGEとしてコミットメッセージに明記 + +### リスク2: 機能劣化の懸念 + +**リスク**: 公式プラグインがNDFの統合版より使いづらい + +**対策**: +- 事前に公式プラグインの動作確認 +- 必要に応じてフィードバックをAnthropicに提供 + +### リスク3: ユーザー離反 + +**リスク**: 変更が大きすぎてNDFプラグインから離れる + +**対策**: +- 段階的な変更(フェーズ1→2→3) +- 各フェーズで十分な周知期間を設ける +- ユーザーフィードバックを収集 + +## 7. 推奨スケジュール + +### 即座に実施(Week 1-2) + +✅ **フェーズ1: MCPサーバー削除** +- 最も重複度が高く、明確な削除対象 +- 公式プラグインで代替可能 +- 即座に実施推奨 + +### 調査後に判断(Week 3-4) + +🔶 **フェーズ2: スラッシュコマンド整理** +- 詳細調査が必要 +- ユーザー影響を慎重に評価 +- 調査結果に基づいて判断 + +### 長期的に検討(Month 2-3) + +🔶 **フェーズ3: qaエージェント評価** +- 急ぎではない +- pr-review-toolkitの成熟度を見極める +- ユーザーフィードバックを収集してから判断 + +## 8. 成功基準 + +### フェーズ1成功基準 + +- [ ] .mcp.jsonから3つのMCPサーバーが削除されている +- [ ] README.md、CLAUDE.ndf.md、Serenaメモリーが更新されている +- [ ] plugin.jsonが2.0.0にアップグレードされている +- [ ] ローカルテストがすべてパスしている +- [ ] PRがマージされている +- [ ] ユーザーへの周知が完了している + +### フェーズ2成功基準 + +- [ ] 公式プラグインのコマンド詳細調査が完了している +- [ ] 削除判断が明確になっている +- [ ] 削除対象がある場合、実施完了している +- [ ] ユーザーへの移行ガイドが提供されている + +### フェーズ3成功基準 + +- [ ] pr-review-toolkitの機能確認が完了している +- [ ] qaエージェントの方向性が決定している +- [ ] 必要に応じて実施完了している + +## 9. 次のアクション + +### 即座に実施すべきこと + +1. **ユーザーに確認** + - この削除計画に同意するか + - 特に懸念事項はないか + +2. **フェーズ1実施開始** + - featureブランチ作成 + - .mcp.json編集 + - ドキュメント更新 + - テスト・コミット・PR作成 + +### 後続タスク + +3. **フェーズ2調査開始** + - 公式プラグインのコマンド詳細調査 + - 削除判断の材料収集 + +4. **フェーズ3調査開始** + - pr-review-toolkitの機能確認 + - 長期的な方向性検討 + +## 10. 参考情報 + +### 関連リンク + +- **anthropics/claude-plugins-official**: https://github.com/anthropics/claude-plugins-official +- **NDFプラグイン**: `/work/ai-plugins/plugins/ndf/` +- **Claude Code公式ドキュメント**: https://docs.claude.com/en/docs/claude-code + +### 調査で使用したツール + +- Serena MCP: プロジェクト構造理解 +- GitHub MCP: 公式リポジトリ調査 +- Read/Glob/Grep: ファイル内容確認 + +--- + +**最終更新**: 2026-01-03 +**作成者**: Claude Code (Director Agent) diff --git a/issues/old/i01.md b/issues/old/i01.md new file mode 100644 index 00000000..02bce554 --- /dev/null +++ b/issues/old/i01.md @@ -0,0 +1,102 @@ +# 開発AIエージェント向け指示書 + +* このドキュメントはClaude Codeなどの開発AIエージェント向けの指示書です。 +* チャットで「docs/cmd01.mdのx番を実行してください」と言われたらこのファイルを読み、見出しに書いてある番号の内容を実行してください。 +* 頻繁に書き換わるので、指示があるたびに読み込みなおしてください。 +* 返答やドキュメントはすべて日本語で。 + + +# 1. plugins/mcp-integration にcodex mcpを追加 +https://zenn.dev/tmasuyama1114/articles/cdfd4562bdce78 +* この記事を参考にplugins/mcp-integrationでinstallするMCPにcodex cli mcpを追加してください +* README.mdなども修正してください + + +# 2. context7追加 +plugins/ndf にcontext7 mcpを追加します +https://github.com/upstash/context7 + +# 3. サブエージェント追加 +plugins/ndf にサブエージェントも追加します。 + * data analyst ... bigquery, dbhubの操作を担当。SQL生成と結果の解釈、結果データのファイル出力を担当する。 + * corder ... コーディングを担当。codex mcp, serena mcp, context7 mcpを活用し品質の高いコードを生成する。 + * researcher ... 調査担当。codex mcpやaws documentation mcp, Chrome DevToolsを活用し、外部サイトから情報を収集。分析して結果を返す + * scanner ... 画像、PDF、Officeファイル担当。pdf、画像、ppt、xlsといったClaude Codeが直接読めないファイルをcodex mcpに任せて読み取ってもらう。 + +# 4. README.md拡充 +* plugins/ndf/README.md に必要事項を追加します。 + * plugins/mcp-integration/README.mdを参考に、DATABASE_DSNなどの設定方法を追加 + * plugins/install-slack-hook/README.md を参考にSLACK BOTの設定方法 + +# 5. mainエージェントへの指示 +plugins/ndf/ +* mainエージェント(親エージェント)にsub agentの役割と積極活用を促す指示を追加してください +# 6. slack通知に作業要約追加 +plugins/ndf/hooks/hooks.json +* slack-notify.shをstop hookで呼び出しています。 +* この処理を以下のように変更してください。 +* promptで40文字以内の日本語要約を作成 +* agent toolで要約を引数につけてslack-notify.shを呼び出す + +# 7. クリーニング +* plugins/ndf/agents/slack-notifier.md 不要になったので削除してください +* ndfのバージョンを1.0.2としてください +* plugins/ndf/scripts/slack-notify.sh こちらもおそらく不要です。念のため確認してから削除 +* ndfのREADME.md, CLAUDE.mdを修正 +* プロジェクトルートのREADME.md, CLAUDE.mdも修正 + +# 8. リファクタリング +* plugins/ndf/scripts/slack-notify.js をリファクタリングしてください + * 目的は可読性の向上と冗長なコードの簡潔化です。 + +# 9. クリーニング2 +* plugins/ndf/agents/memory-recorder.md こちらも利用しないことになったので削除してください +* ndfのREADME.md, CLAUDE.mdを修正 +* プロジェクトルートのREADME.md, CLAUDE.mdも修正 + +# 10. 品質管理サブエージェントの追加 +* plugins/ndf/agents 品質管理(qa)を担当するサブエージェントを追加してください。 + +# 11. CLAUDE.md方針 +* plugins/ndf/CLAUDE.md を以下の方針としてください +* 大方針 + * 日本語で応答 + * 勝手なgit pushは禁止。特にデフォルトブランチにはpushしない。 +* 行動指針 + * コンテキストの管理、段階的開示を意識する + * sub agent, mcpの積極活用 + * mainエージェントはtodolistの管理と結果の統合に徹し、それ以外はできるだけサブエージェントに任せる + * serena mcpはmain, subいずれのエージェントでも活用する + * serena mcpの使い方をCLAUDE.mdに記載しておく + * 特にテキストファイルの読み書きは原則serena mcpを利用して行う + * PDF、画像、officeツール(xls, pptなど)などのバイナリファイルの読み取りはscannerエージェント経由でcodex mcpに依頼 + * 事実を調査する + * 技術的に難易度が高い課題はresearcherによって外部リソースを調査してから解決策を検討する + * 現在記載されているStop Hookに関する事項は解消されたので不要になりました。 + +# 12. README.md改善 +* plugins/ndf/README.md を改修します +* README.mdはユーザ向け、CLAUDE.mdはエージェント向けです。 + * 設定では、先に設定ステップ全体の流れを説明してから、設定方法の詳細に入ってください + * 開発ワークフローの説明をmcpよりも先にしてください。 + * mcpの詳細説明はCLAUDE.mdを参照するように記載し、ここでの説明は最小限にしてください +* DBHubについて + * DBHubの設定の説明が冗長です。例はなくて良いでしょう。 + * DBHubの設定の説明で、mysqlとmariadbは設定方法が同じなのでまとめてください + * SSLの説明はPostgresのセクションに記述してください + * SSHによる踏み台経由の接続方法についても記載して下さい(mysqlのみでも可) +* 開発ワークフローコマンドの使用例は不要です(大体コマンド名が記載されているだけなので)。 + * 何をするコマンドなのか、いつ使うのかを2-3行程度で説明してください +* mcpの説明で、使わないmcpはdisableしておくことを推奨し、diableの手順を記載してください +* 自動フックの説明は古いです。現状に合わせてください +* トラブルシューティングはこの内容なら不要です。 + +* ついでにplugins/ndf/CLAUDE.md も修正 + * 勝手にpushしない、をpush/mergeしないに修正 + * PRのmergeはユーザが行う前提です。 + * タスク分類のフローチャート はmermaid記法で記載してください + +# 13. お行儀の悪いmcpのデフォルトdisable +* aws-docmentation, claude-code, dbhub, bigquery, notion mcpはデフォルトでdisable=trueとしてください。 +* aws-docはエラーが出る、bigqueryとdbhubは利用するプロジェクトが限られる +* claude-code, notionはcontextが大きすぎるためです。 diff --git a/issues/old/i02.md b/issues/old/i02.md new file mode 100644 index 00000000..212e30a6 --- /dev/null +++ b/issues/old/i02.md @@ -0,0 +1,48 @@ +# Issue #02: @playwright/mcpパッケージのChromium認識問題 + +**報告日**: 2025-11-16 +**ステータス**: 調査完了 +**優先度**: 高 + +## 問題の概要 + +`npx -y @playwright/mcp@latest --browser chromium`で起動すると、以下のエラーが発生: + +``` +Browser specified in your config is not installed. Either install it (likely) or change the config. +``` + +`npx playwright install chromium`でChromiumをインストール済み(`~/.cache/ms-playwright/chromium-1194/`)にもかかわらず、ブラウザが認識されない。 + +## 根本原因 + +### 1. npx実行時の分離環境 + +`npx`経由で`@playwright/mcp`を実行すると、以下のような問題が発生: + +- **一時的なnode_modules**: npxは一時的な場所に`@playwright/mcp`とその依存関係(`playwright@1.57.0-alpha-2025-11-14`、`playwright-core@1.57.0-alpha-2025-11-14`)をダウンロード +- **バージョン不一致**: 事前に`npx playwright install chromium`でインストールしたChromiumは、別バージョンのPlaywrightでインストールされた可能性がある +- **ブラウザパス認識の問題**: 各Playwrightインストールは、自身がインストールしたブラウザのみを認識する + +### 2. Playwrightのブラウザ認識メカニズム + +Playwrightは以下の順序でブラウザを検索: + +1. **PLAYWRIGHT_BROWSERS_PATH環境変数**(最優先) +2. **デフォルトキャッシュディレクトリ**: + - Linux: `~/.cache/ms-playwright` + - macOS: `~/Library/Caches/ms-playwright` + - Windows: `%USERPROFILE%\AppData\Local\ms-playwright` + +しかし、**各Playwrightパッケージのバージョンは、それぞれ独自のブラウザバージョンを期待**する。 + +### 3. バージョン管理の問題 + +```bash +# 例: 以前インストールしたChromium +~/.cache/ms-playwright/chromium-1194/ + +# @playwright/mcpが期待するChromiumバージョン +# playwright@1.57.0-alpha-2025-11-14 が期待するバージョン +# → 異なる可能性がある +``` diff --git a/issues/old/i03.md b/issues/old/i03.md new file mode 100644 index 00000000..e18b42e7 --- /dev/null +++ b/issues/old/i03.md @@ -0,0 +1,20 @@ +# 開発AIエージェント向け指示書 + +* このドキュメントはClaude Codeなどの開発AIエージェント向けの指示書です。 +* チャットで「docs/cmd01.mdのx番を実行してください」と言われたらこのファイルを読み、見出しに書いてある番号の内容を実行してください。 +* 頻繁に書き換わるので、指示があるたびに読み込みなおしてください。 +* 返答やドキュメントはすべて日本語で。 + + +# 1. 来年度開発指針作成 +これまでとは全く異なるミッションです。 + +issues/開発指針.md + +にあるように、2026年度(来年)の開発指針を策定しようとしています。 +issues/開発指針.mdはそのアイデアノートです。 + +このノートから、10p程度のプレゼンテーション資料を作成してください。 +実際の作成はcodex mcpに依頼し、まずはmarkdown形式のページごとの内容を作成したあと、 +pptxファイルを作成してください。pptxで10ページ-15ページ程度になることが望ましいです。 + diff --git a/issues/old/i04.md b/issues/old/i04.md new file mode 100644 index 00000000..6a66aa29 --- /dev/null +++ b/issues/old/i04.md @@ -0,0 +1,24 @@ +# 開発AIエージェント向け指示書 + +* このドキュメントはClaude Codeなどの開発AIエージェント向けの指示書です。 +* チャットで「docs/cmd01.mdのx番を実行してください」と言われたらこのファイルを読み、見出しに書いてある番号の内容を実行してください。 +* 頻繁に書き換わるので、指示があるたびに読み込みなおしてください。 +* 返答やドキュメントはすべて日本語で。 + + +# 1. plugins/ndf/CLAUDE.md をルートに転記するSessionStart hook +* plugins/ndf/CLAUDE.md にこのpluginを活用するための指針を記載していましたが、pluginをインストールしただけでは読み込まれないようです。 +* このため、plugins/ndf/hooks/hooks.json のSessionStart hookに以下のようなスクリプトを追加してください + * プロジェクトのCLAUDE.mdまたはAGENT.mdを探す(できるだけ上位のディレクトリにあるもの、ついで.claude/にあるものを優先) + * 既に最新バージョンのCLAUDE_plugin.md の内容が転記済みであれば修了(*後述) + * 転記されていなければ + * 最新バージョン前の記述があればそれを削除 + * 最新バージョンの記述をmdファイルの一番最後に追記 + +* plugins/ndf/CLAUDE.mdの転記済み判断とバージョン管理は以下の通りです。 + * これはhookではなくai-pluginsプロジェクトが行います。 + * このプロジェクトルートのCLAUDE.mdにも以下を書いておいてください + * plugins/ndf/CLAUDE_plugin.mdの前後を固定の文字列+バージョン番号で囲む + * 「固定の文字列は無意味」な英数字の羅列30文字とする + * plugins/ndf/CLAUDE_plugin.mdの内容を変更した場合はバージョン番号をインクリメントする + diff --git a/issues/old/i05.md b/issues/old/i05.md new file mode 100644 index 00000000..6135a941 --- /dev/null +++ b/issues/old/i05.md @@ -0,0 +1,32 @@ +# 開発AIエージェント向け指示書 + +* このドキュメントはClaude Codeなどの開発AIエージェント向けの指示書です。 +* チャットで「docs/cmd01.mdのx番を実行してください」と言われたらこのファイルを読み、見出しに書いてある番号の内容を実行してください。 +* 頻繁に書き換わるので、指示があるたびに読み込みなおしてください。 +* 返答やドキュメントはすべて日本語で。 + + +# 1. サブエージェント「director」追加 +* ndf pluginに「director」サブエージェントを追加してください。 +* directorは、main agentが担っていた責務すべてが担当です。 +* main agentはできるだけ何もせず、サブエージェントへの指示のみに徹 するようにしてください。 +* ファイル調査、プラン作成、結果の取りまとめなど。指示出し以外のすべての業務を可能な限りsub agentに任せるようにしてください。 + +# 2. 適切な状況報告体制の構築 +* ndf:director sub agentはバックグラウンドで動作することを前提に、適切なタイミングごとに、mainエージェントに作業内容を報告するようにしてください。 + * また、main agentはsub agentの報告を受信してユーザに報告するようにしてください。 + * 報告は最初は1分毎、とし、タスクが長くなるようならそれに応じて報告間隔も広くしてください + * plugins/ndf/CLAUDE.ndf.mdにもこの仕様を書いておいてください + + +# 3. slack-notify.js 処理順変更 +plugins/ndf/scripts/slack-notify.js の処理を変更します。 +* これまで、メンション付き通知→要約作衛→メンション削除→要約付きメッセージ通知、だったはずです。 +* これを、要約作成→「メンション付きかつ要約付きメッセージ」送信→「メンションなし&要約付きメッセージ」送信→「メンション付きかつ要約付きメッセージ」削除、という順番としてください + +# 4. サブエージェント、MCPのネスト禁止ルール追加 +* サブエージェントを利用する際、サブエージェントがサブエージェントを呼ぶことを繰り返してしまう、coreダンプする現象が発生しているようです。 + * directorサブエージェントはたのサブエージェントやMCPを呼ぶことができる。ただしdirectors subagentやclaude code mcpを呼んではいけない(無限呼び出し回避) + * director以外のサブエージェントは他のサブエージェントを呼んではいけない。 + * MCPは利用してかまわない。ただし無限呼び出しは防ぐこと +* 以上のルールに従って、plugins/ndf/agents/内のファイルやplugins/ndf/CLAUDE.ndf.mdを修正してください diff --git a/issues/old/i06.md b/issues/old/i06.md new file mode 100644 index 00000000..f83c09d2 --- /dev/null +++ b/issues/old/i06.md @@ -0,0 +1,26 @@ +# 開発AIエージェント向け指示書 + +* このドキュメントはClaude Codeなどの開発AIエージェント向けの指示書です。 +* チャットで「docs/cmd01.mdのx番を実行してください」と言われたらこのファイルを読み、見出しに書いてある番号の内容を実行してください。 +* 頻繁に書き換わるので、指示があるたびに読み込みなおしてください。 +* 返答やドキュメントはすべて日本語で。 + +# 1. チューニング +* plugins/ndf/agents/director.md 策定された計画は、適切な場所にファイルとして保存するように定義してください + * プロジェクトのissues/ やgithub issue、notion等が適切な場所となります。 + * それらにこのプロジェクトに対する既存のissue、ticket等が存在していればそこが適切な場所と判断可能です。 + * プロジェクト内 > github issues > notionが優先順位となります + * 判断が困難な場合はユーザーに選択肢で問いかけてください。 +* plugins/ndf/agents/scanner.md codexの利用にこだわらず、claude codそのものや、mcp、LLMのAPIなど可能な手段を使ってバイナリファイルを読むように変更してください。 + +# 2. 外部サイト調査 +* 外部サイト調査にChrome Dev toolsやplaywright mcpを使う様に支持しているagentがありますが、これらはどうも遅いので、普通にWeb FetchやWeb Searchを利用するように変更してください。 +* また、外部サイトの調査に適した無料のAI向けツールがあれば、それらを利用しても構いません。 + + +# 3. sub agentに対応したskillの利用 +* plugins/ndf/agents で定義されているsub agentsにAgent Skillsを導入してください。 + * https://code.claude.com/docs/ja/skills +* 各agentはそれぞれ必要なskillを積極的に呼び出して利用します。 +* 外部のpublicなskillsを踏査して、適したskillが公開されていれば、そちらをこのpluginに取り込んでください +* sub agentの目的に照らしてSkillの利点を生かした適切なスクリプトやテンプレート、toolを生成・定義してください diff --git a/issues/old/i07-skills-design.md b/issues/old/i07-skills-design.md new file mode 100644 index 00000000..9727e7c3 --- /dev/null +++ b/issues/old/i07-skills-design.md @@ -0,0 +1,943 @@ +# NDFプラグイン - Sub-Agent Skills 詳細設計書 + +**作成日**: 2025-12-15 +**担当**: director agent +**関連Issue**: i07.md + +--- + +## 設計方針 + +### 基本原則 +1. **焦点を絞る**: 1 Skill = 1機能 +2. **明確な説明**: トリガー用語を含む具体的なdescription +3. **既存MCPとの重複回避**: MCPで実現できることはSkillsにしない +4. **作業効率最大化**: 繰り返しタスクの自動化、テンプレート化 + +### 優先度基準 +- **高**: 頻繁に実行する定型作業、テンプレート化で大幅な時短 +- **中**: 有用だが頻度は中程度、または実装コストが高い +- **低**: Nice-to-have、または既存ツールで十分対応可能 + +--- + +## 1. director agent 用 Skills + +### Skill 1.1: Project Planning Templates +**name**: `director-project-planning` +**description**: Create structured project plans with task breakdown, timeline, resource allocation, and risk assessment. Use when starting new features, refactoring, or complex implementations. Triggers: "plan", "roadmap", "task breakdown", "project structure". + +**提供機能**: +- プロジェクト計画書テンプレート生成 +- タスク分解とマイルストーン設定 +- リスク評価とリソース配分 +- 並列実行可能性の自動判断 + +**ディレクトリ構造**: +``` +skills/director-project-planning/ +├── SKILL.md +├── templates/ +│ ├── project-plan-template.md +│ ├── task-breakdown-template.md +│ └── risk-assessment-template.md +└── scripts/ + └── generate-plan.js +``` + +**allowed-tools**: Read, Write, Glob, Grep + +**スクリプト概要** (`generate-plan.js`): +- ユーザー入力(プロジェクト概要、目標)を受け取る +- テンプレートを読み込み、動的に項目を埋める +- タスク分解を提案(実装→テスト→ドキュメント) +- issues/ディレクトリに自動保存 + +**テンプレート概要** (`project-plan-template.md`): +```markdown +# [プロジェクト名] 実装計画 + +## 概要 +- 目的: +- スコープ: +- 期限: + +## タスク分解 +### フェーズ1: [名前] +- [ ] タスク1 +- [ ] タスク2 + +## リソース配分 +- 必要なサブエージェント: +- 並列実行可能タスク: + +## リスク評価 +- リスク1: [説明] - 対策: +``` + +**優先度**: 🔴 **高** - Directorの最も重要な機能 + +--- + +### Skill 1.2: GitHub Integration +**name**: `director-github-integration` +**description**: Create and manage GitHub issues, pull requests, and milestones from project plans. Use when converting plans to actionable GitHub items. Triggers: "create issue", "open PR", "github milestone", "track progress". + +**提供機能**: +- 計画書からGitHub Issue自動生成 +- Pull Request作成支援 +- マイルストーン管理 +- 進捗トラッキング + +**ディレクトリ構造**: +``` +skills/director-github-integration/ +├── SKILL.md +├── templates/ +│ ├── issue-template.md +│ └── pr-template.md +└── scripts/ + └── create-github-items.js +``` + +**allowed-tools**: Bash(git/gh コマンド), Read, Write + +**スクリプト概要** (`create-github-items.js`): +- 計画書を解析し、タスクごとにIssueを作成 +- `gh issue create`コマンドを実行 +- Issue番号を計画書に逆参照として追加 +- ラベル、担当者、マイルストーンを自動設定 + +**テンプレート概要** (`issue-template.md`): +```markdown +## 概要 +[タスクの説明] + +## 受入基準 +- [ ] 基準1 +- [ ] 基準2 + +## 関連 +- 計画書: [リンク] +- 親Issue: #XXX +``` + +**優先度**: 🟡 **中** - GitHub統合は便利だが、手動でも可能 + +--- + +### Skill 1.3: Progress Reporting +**name**: `director-progress-report` +**description**: Generate progress reports summarizing completed tasks, ongoing work, blockers, and next steps. Use when updating stakeholders or reviewing project status. Triggers: "progress report", "status update", "weekly report". + +**提供機能**: +- 進捗レポート自動生成 +- 完了タスク、進行中タスク、ブロッカーの整理 +- 次のアクション提案 +- グラフ・統計データ生成(オプション) + +**ディレクトリ構造**: +``` +skills/director-progress-report/ +├── SKILL.md +├── templates/ +│ └── progress-report-template.md +└── scripts/ + └── generate-report.js +``` + +**allowed-tools**: Read, Write, Bash(git log等) + +**スクリプト概要** (`generate-report.js`): +- Git historyから最近のコミットを取得 +- issues/ディレクトリの計画書を読み、進捗を抽出 +- 完了率を計算 +- レポートを生成してissues/配下に保存 + +**テンプレート概要** (`progress-report-template.md`): +```markdown +# 進捗レポート - [日付] + +## サマリー +- 完了タスク: X個 +- 進行中タスク: Y個 +- ブロッカー: Z個 + +## 詳細 +### 完了 +- [タスク名] - [完了日] + +### 進行中 +- [タスク名] - [進捗率] + +### ブロッカー +- [問題] - [対策] + +## 次のステップ +1. +``` + +**優先度**: 🟢 **低** - Nice-to-have、手動でも容易 + +--- + +## 2. data-analyst agent 用 Skills + +### Skill 2.1: SQL Optimization Patterns +**name**: `data-analyst-sql-optimization` +**description**: Apply SQL optimization patterns including index usage, query rewriting, JOIN optimization, and window functions. Use when improving query performance. Triggers: "optimize SQL", "slow query", "improve performance". + +**提供機能**: +- SQLクエリ最適化パターンライブラリ +- パフォーマンス改善提案 +- インデックス推奨 +- クエリ実行計画の解析 + +**ディレクトリ構造**: +``` +skills/data-analyst-sql-optimization/ +├── SKILL.md +├── reference.md # 最適化パターン詳細 +└── examples.md # Before/Afterサンプル +``` + +**allowed-tools**: なし(参照のみ) + +**reference.md 概要**: +```markdown +## パターン1: N+1クエリ削減 +**Before**: 複数回のSELECT +**After**: JOINまたはサブクエリ + +## パターン2: WHERE句最適化 +**Before**: 関数適用後のフィルタ +**After**: インデックス活用可能な形式 + +## パターン3: ウィンドウ関数活用 +**Before**: サブクエリの入れ子 +**After**: ROW_NUMBER(), RANK() +``` + +**優先度**: 🔴 **高** - データアナリストの頻繁なニーズ + +--- + +### Skill 2.2: Data Visualization Scripts +**name**: `data-analyst-visualization` +**description**: Generate data visualizations (charts, graphs, tables) from query results using Python/matplotlib or JavaScript. Use when creating reports or dashboards. Triggers: "visualize data", "create chart", "plot graph". + +**提供機能**: +- クエリ結果の可視化 +- チャート生成(棒グラフ、折れ線グラフ、円グラフ) +- HTMLレポート生成 +- 画像ファイル出力 + +**ディレクトリ構造**: +``` +skills/data-analyst-visualization/ +├── SKILL.md +├── scripts/ +│ ├── visualize.py +│ └── generate-html-report.js +└── templates/ + └── report-template.html +``` + +**allowed-tools**: Bash(Pythonスクリプト実行), Write + +**スクリプト概要** (`visualize.py`): +```python +import pandas as pd +import matplotlib.pyplot as plt +import sys +import json + +# JSON形式のクエリ結果を読み込み +data = json.loads(sys.stdin.read()) +df = pd.DataFrame(data) + +# チャート生成 +df.plot(kind='bar', x='category', y='value') +plt.savefig('output.png') +``` + +**テンプレート概要** (`report-template.html`): +```html + + +Data Analysis Report + +

{{title}}

+ + {{data_table}}
+ + +``` + +**優先度**: 🟡 **中** - 有用だがPython環境依存 + +--- + +### Skill 2.3: Data Export Templates +**name**: `data-analyst-export` +**description**: Export query results to various formats (CSV, JSON, Excel, Markdown tables) with proper formatting and headers. Use when saving analysis results. Triggers: "export data", "save results", "output CSV/JSON/Excel". + +**提供機能**: +- CSV出力(カンマ区切り、ヘッダー付き) +- JSON出力(構造化、pretty-print) +- Excel出力(複数シート、書式設定) +- Markdownテーブル出力 + +**ディレクトリ構造**: +``` +skills/data-analyst-export/ +├── SKILL.md +└── scripts/ + ├── export-csv.js + ├── export-json.js + ├── export-excel.js + └── export-markdown.js +``` + +**allowed-tools**: Write, Bash + +**スクリプト概要** (`export-csv.js`): +```javascript +const fs = require('fs'); + +function exportToCSV(data, filename) { + const headers = Object.keys(data[0]).join(','); + const rows = data.map(row => Object.values(row).join(',')); + const csv = [headers, ...rows].join('\n'); + fs.writeFileSync(filename, csv); +} +``` + +**優先度**: 🔴 **高** - データアナリストの必須機能 + +--- + +## 3. corder agent 用 Skills + +### Skill 3.1: Code Generation Templates +**name**: `corder-code-templates` +**description**: Generate code templates for common patterns: REST API endpoints, React components, database models, authentication, error handling. Use when implementing new features. Triggers: "create API", "new component", "implement auth", "add model". + +**提供機能**: +- REST APIエンドポイントテンプレート +- Reactコンポーネントテンプレート +- データベースモデルテンプレート +- 認証ロジックテンプレート +- エラーハンドリングパターン + +**ディレクトリ構造**: +``` +skills/corder-code-templates/ +├── SKILL.md +├── templates/ +│ ├── rest-api-endpoint.js +│ ├── react-component.jsx +│ ├── database-model.js +│ ├── auth-middleware.js +│ └── error-handler.js +└── reference.md +``` + +**allowed-tools**: Read, Write, Bash + +**テンプレート概要** (`rest-api-endpoint.js`): +```javascript +// [ROUTE_NAME] API Endpoint +const express = require('express'); +const router = express.Router(); + +/** + * @route GET /api/[resource] + * @desc [Description] + * @access [Public/Private] + */ +router.get('/', async (req, res) => { + try { + // TODO: Implement logic + res.json({ success: true, data: [] }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; +``` + +**優先度**: 🔴 **高** - コーディング効率大幅向上 + +--- + +### Skill 3.2: Test Generation +**name**: `corder-test-generation` +**description**: Generate unit tests, integration tests, and test fixtures for code. Supports Jest, Mocha, pytest. Use when writing tests. Triggers: "generate tests", "create unit test", "add test coverage". + +**提供機能**: +- ユニットテスト生成(Jest、Mocha、pytest) +- 統合テスト生成 +- テストフィクスチャ生成 +- モック/スパイ設定 + +**ディレクトリ構造**: +``` +skills/corder-test-generation/ +├── SKILL.md +├── templates/ +│ ├── jest-unit-test.test.js +│ ├── mocha-test.test.js +│ ├── pytest-test.py +│ └── test-fixtures.json +└── scripts/ + └── generate-tests.js +``` + +**allowed-tools**: Read, Write, Bash + +**テンプレート概要** (`jest-unit-test.test.js`): +```javascript +const { [functionName] } = require('../[modulePath]'); + +describe('[functionName]', () => { + test('should [expected behavior]', () => { + // Arrange + const input = [testInput]; + const expected = [expectedOutput]; + + // Act + const result = [functionName](input); + + // Assert + expect(result).toEqual(expected); + }); + + test('should handle edge cases', () => { + // TODO: Add edge case tests + }); +}); +``` + +**スクリプト概要** (`generate-tests.js`): +- ソースコードを解析(Serena MCP使用) +- 関数シグネチャを抽出 +- テストケースのスケルトンを生成 +- エッジケースの提案 + +**優先度**: 🔴 **高** - テスト作成は頻繁で時間がかかる + +--- + +### Skill 3.3: Documentation Generator +**name**: `corder-doc-generation` +**description**: Generate API documentation, JSDoc comments, README sections, and inline code comments. Use when documenting code. Triggers: "generate docs", "add comments", "create API docs", "update README". + +**提供機能**: +- JSDoc/PyDocコメント生成 +- API仕様書生成 +- README.mdテンプレート +- インラインコメント提案 + +**ディレクトリ構造**: +``` +skills/corder-doc-generation/ +├── SKILL.md +├── templates/ +│ ├── jsdoc-template.js +│ ├── pydoc-template.py +│ ├── api-docs-template.md +│ └── readme-template.md +└── scripts/ + └── generate-docs.js +``` + +**allowed-tools**: Read, Write, Bash + +**テンプレート概要** (`jsdoc-template.js`): +```javascript +/** + * [Function description] + * + * @param {[type]} [paramName] - [parameter description] + * @returns {[returnType]} [return value description] + * @throws {[ErrorType]} [error condition] + * + * @example + * const result = functionName(param); + * // result: [expected output] + */ +function functionName(paramName) { + // Implementation +} +``` + +**優先度**: 🟡 **中** - 便利だが頻度は中程度 + +--- + +## 4. researcher agent 用 Skills + +### Skill 4.1: Research Report Templates +**name**: `researcher-report-templates` +**description**: Generate structured research reports with findings, comparisons, recommendations, and citations. Use when documenting investigation results. Triggers: "create report", "summarize findings", "compare technologies". + +**提供機能**: +- 調査レポートテンプレート +- 技術比較テーブル生成 +- ベストプラクティスまとめ +- 引用・参照リンク管理 + +**ディレクトリ構造**: +``` +skills/researcher-report-templates/ +├── SKILL.md +├── templates/ +│ ├── research-report-template.md +│ ├── tech-comparison-template.md +│ └── best-practices-template.md +└── scripts/ + └── generate-report.js +``` + +**allowed-tools**: Read, Write + +**テンプレート概要** (`research-report-template.md`): +```markdown +# [調査テーマ] 調査レポート + +## 概要 +- 調査目的: +- 調査期間: +- 情報源: + +## 調査結果 +### ポイント1 +- 説明 +- 詳細 +- 参照: [リンク] + +### ポイント2 +... + +## 技術比較 +| 項目 | 技術A | 技術B | 技術C | +|------|------|------|------| +| 特徴 | | | | +| 長所 | | | | +| 短所 | | | | + +## 推奨事項 +1. +2. + +## 参考リンク +- [タイトル](URL) +``` + +**優先度**: 🔴 **高** - Researcherの主要な成果物 + +--- + +### Skill 4.2: API Specification Extractor +**name**: `researcher-api-extractor` +**description**: Extract and document API specifications from documentation sites including endpoints, parameters, responses, authentication. Use when integrating external APIs. Triggers: "extract API spec", "document API", "analyze endpoints". + +**提供機能**: +- APIエンドポイント一覧抽出 +- パラメータ仕様抽出 +- レスポンス構造抽出 +- 認証方式ドキュメント + +**ディレクトリ構造**: +``` +skills/researcher-api-extractor/ +├── SKILL.md +├── templates/ +│ └── api-spec-template.md +└── scripts/ + └── extract-api-spec.js +``` + +**allowed-tools**: Read, Bash(WebFetch間接利用) + +**テンプレート概要** (`api-spec-template.md`): +```markdown +# [API Name] 仕様書 + +## ベースURL +`https://api.example.com/v1` + +## 認証 +- 方式: Bearer Token +- ヘッダー: `Authorization: Bearer {token}` + +## エンドポイント + +### GET /resource +**説明**: [説明] +**パラメータ**: +- `param1` (string, required): [説明] +- `param2` (number, optional): [説明] + +**レスポンス**: +```json +{ + "success": true, + "data": [] +} +``` + +**スクリプト概要** (`extract-api-spec.js`): +- WebFetchでAPIドキュメントを取得 +- Markdown/HTMLから構造化情報を抽出 +- テンプレートに整形して出力 + +**優先度**: 🟡 **中** - API統合時に便利 + +--- + +## 5. scanner agent 用 Skills + +### Skill 5.1: PDF Analysis +**name**: `scanner-pdf-analysis` +**description**: Analyze PDF documents with table extraction, section identification, and content summarization. Use when reading technical documents, reports, or papers. Triggers: "analyze PDF", "extract tables", "summarize document". + +**提供機能**: +- PDF構造解析 +- テーブル抽出とCSV変換 +- セクション識別 +- 重要ポイント要約 + +**ディレクトリ構造**: +``` +skills/scanner-pdf-analysis/ +├── SKILL.md +├── templates/ +│ └── pdf-summary-template.md +└── scripts/ + └── analyze-pdf.py +``` + +**allowed-tools**: Bash(Pythonスクリプト実行), Write + +**スクリプト概要** (`analyze-pdf.py`): +```python +import PyPDF2 +import tabula +import sys + +def analyze_pdf(pdf_path): + # PDFテキスト抽出 + with open(pdf_path, 'rb') as f: + reader = PyPDF2.PdfReader(f) + text = ''.join([page.extract_text() for page in reader.pages]) + + # テーブル抽出 + tables = tabula.read_pdf(pdf_path, pages='all') + + # 構造化出力 + return { + 'text': text, + 'tables': tables, + 'page_count': len(reader.pages) + } +``` + +**テンプレート概要** (`pdf-summary-template.md`): +```markdown +# [ファイル名] 分析結果 + +## 概要 +- ページ数: X +- テーブル数: Y + +## 重要ポイント +1. +2. + +## 抽出テーブル +### テーブル1 +| 列1 | 列2 | +|-----|-----| +| ... | ... | +``` + +**優先度**: 🔴 **高** - PDFは頻繁に扱う + +--- + +### Skill 5.2: Excel Data Extraction +**name**: `scanner-excel-extraction` +**description**: Extract, transform, and structure data from Excel files including multiple sheets, formulas, and formatting. Use when processing Excel data. Triggers: "extract Excel data", "read spreadsheet", "convert Excel to JSON/CSV". + +**提供機能**: +- 複数シート読み込み +- データ構造化(JSON変換) +- CSV出力 +- 数式評価 + +**ディレクトリ構造**: +``` +skills/scanner-excel-extraction/ +├── SKILL.md +└── scripts/ + ├── extract-excel.py + └── convert-to-json.js +``` + +**allowed-tools**: Bash, Write + +**スクリプト概要** (`extract-excel.py`): +```python +import pandas as pd +import sys +import json + +def extract_excel(file_path): + # 全シート読み込み + excel_file = pd.ExcelFile(file_path) + data = {} + + for sheet_name in excel_file.sheet_names: + df = pd.read_excel(file_path, sheet_name=sheet_name) + data[sheet_name] = df.to_dict(orient='records') + + # JSON出力 + print(json.dumps(data, ensure_ascii=False, indent=2)) + +if __name__ == '__main__': + extract_excel(sys.argv[1]) +``` + +**優先度**: 🔴 **高** - Excelは頻繁に扱う + +--- + +## 6. qa agent 用 Skills + +### Skill 6.1: Code Review Checklist +**name**: `qa-code-review-checklist` +**description**: Comprehensive code review checklist covering readability, maintainability, performance, security, and best practices. Use when reviewing code. Triggers: "code review", "review checklist", "quality check". + +**提供機能**: +- コードレビューチェックリスト +- 言語別ベストプラクティス +- セキュリティチェック項目 +- パフォーマンスチェック項目 + +**ディレクトリ構造**: +``` +skills/qa-code-review-checklist/ +├── SKILL.md +├── checklists/ +│ ├── general-checklist.md +│ ├── javascript-checklist.md +│ ├── python-checklist.md +│ └── security-checklist.md +└── templates/ + └── review-report-template.md +``` + +**allowed-tools**: なし(参照のみ) + +**チェックリスト概要** (`general-checklist.md`): +```markdown +# コードレビューチェックリスト + +## 可読性 +- [ ] 変数名・関数名は明確か +- [ ] コメントは適切か +- [ ] ネストは深すぎないか + +## 保守性 +- [ ] DRY原則に従っているか +- [ ] 関数は単一責任か +- [ ] モジュール分割は適切か + +## パフォーマンス +- [ ] 不要なループはないか +- [ ] データ構造は適切か +- [ ] キャッシュを活用しているか + +## セキュリティ +- [ ] 入力値検証があるか +- [ ] SQLインジェクション対策があるか +- [ ] XSS対策があるか +``` + +**テンプレート概要** (`review-report-template.md`): +```markdown +# コードレビューレポート - [ファイル名] + +## サマリー +- レビュー日: +- レビュアー: +- 評価: ⭐⭐⭐⭐☆ + +## 問題点 +### 重大 🔴 +- [問題] - [行番号] - [修正案] + +### 警告 🟡 +- [問題] - [行番号] - [修正案] + +### 提案 🟢 +- [改善案] + +## 良い点 +- + +## 総評 + +``` + +**優先度**: 🔴 **高** - QAの主要機能 + +--- + +### Skill 6.2: Security Scan Templates +**name**: `qa-security-scan` +**description**: Security scanning templates and checklists for OWASP Top 10, authentication, authorization, data protection. Use when security testing. Triggers: "security scan", "vulnerability check", "OWASP". + +**提供機能**: +- OWASP Top 10チェックリスト +- 認証・認可検証 +- データ保護確認 +- セキュリティレポート生成 + +**ディレクトリ構造**: +``` +skills/qa-security-scan/ +├── SKILL.md +├── checklists/ +│ ├── owasp-top10-checklist.md +│ ├── auth-checklist.md +│ └── data-protection-checklist.md +└── templates/ + └── security-report-template.md +``` + +**allowed-tools**: なし(参照のみ) + +**チェックリスト概要** (`owasp-top10-checklist.md`): +```markdown +# OWASP Top 10 チェックリスト + +## 1. インジェクション +- [ ] SQLクエリはパラメータ化されているか +- [ ] コマンドインジェクション対策があるか +- [ ] LDAPインジェクション対策があるか + +## 2. 認証の不備 +- [ ] パスワードは安全にハッシュ化されているか +- [ ] セッション管理は適切か +- [ ] 多要素認証を実装しているか + +## 3. 機密データの露出 +- [ ] 通信は暗号化されているか(HTTPS) +- [ ] 機密データはログに出力されていないか +- [ ] APIキーは環境変数管理か +``` + +**優先度**: 🔴 **高** - セキュリティは最重要 + +--- + +### Skill 6.3: Performance Test Report +**name**: `qa-performance-test` +**description**: Generate performance test reports with Core Web Vitals, load times, bottleneck analysis, and optimization recommendations. Use when testing web applications. Triggers: "performance test", "load time", "Core Web Vitals". + +**提供機能**: +- Core Web Vitals測定レポート +- ページロード時間分析 +- ボトルネック特定 +- 最適化提案 + +**ディレクトリ構造**: +``` +skills/qa-performance-test/ +├── SKILL.md +├── templates/ +│ └── performance-report-template.md +└── scripts/ + └── analyze-performance.js +``` + +**allowed-tools**: Bash(Chrome DevTools MCP間接利用), Write + +**テンプレート概要** (`performance-report-template.md`): +```markdown +# パフォーマンステストレポート - [URL] + +## Core Web Vitals +- **LCP** (Largest Contentful Paint): X.Xs + - 評価: [Good/Needs Improvement/Poor] +- **FID** (First Input Delay): Xms + - 評価: [Good/Needs Improvement/Poor] +- **CLS** (Cumulative Layout Shift): X.XX + - 評価: [Good/Needs Improvement/Poor] + +## ページロード時間 +- First Contentful Paint: X.Xs +- Time to Interactive: X.Xs +- Total Blocking Time: Xms + +## ボトルネック +1. [問題] - [影響度] - [改善案] +2. + +## 推奨改善策 +1. +2. +``` + +**スクリプト概要** (`analyze-performance.js`): +- Chrome DevTools MCPでパフォーマンス測定 +- Core Web Vitalsを抽出 +- ボトルネックを特定(Networkタイムライン解析) +- レポート生成 + +**優先度**: 🟡 **中** - Web開発時に有用 + +--- + +## 実装優先順位まとめ + +### 🔴 優先度高(即座に実装) +1. **director-project-planning** - プロジェクト計画書生成 +2. **data-analyst-sql-optimization** - SQL最適化パターン +3. **data-analyst-export** - データ出力テンプレート +4. **corder-code-templates** - コード生成テンプレート +5. **corder-test-generation** - テスト生成 +6. **researcher-report-templates** - 調査レポートテンプレート +7. **scanner-pdf-analysis** - PDF分析 +8. **scanner-excel-extraction** - Excel抽出 +9. **qa-code-review-checklist** - コードレビューチェックリスト +10. **qa-security-scan** - セキュリティスキャン + +### 🟡 優先度中(余裕があれば実装) +11. **director-github-integration** - GitHub統合 +12. **data-analyst-visualization** - データ可視化 +13. **corder-doc-generation** - ドキュメント生成 +14. **researcher-api-extractor** - API仕様抽出 +15. **qa-performance-test** - パフォーマンステスト + +### 🟢 優先度低(将来的に検討) +16. **director-progress-report** - 進捗レポート + +--- + +## 次のステップ + +1. **外部公開Skills調査結果の統合** - researcherからの結果を待つ +2. **スクリプト・テンプレート詳細設計** - 優先度高のSkillsから着手 +3. **実装** - corderエージェントに依頼 +4. **テスト** - 各Skillsの動作確認 +5. **ドキュメント更新** - plugin.json、README.md、CLAUDE.ndf.md + +--- + +**作成者**: director agent +**次回更新**: 外部Skills調査完了後、またはスクリプト詳細設計完了後 diff --git a/issues/old/i07.md b/issues/old/i07.md new file mode 100644 index 00000000..d7fe00ba --- /dev/null +++ b/issues/old/i07.md @@ -0,0 +1,286 @@ +# Task #3: Sub-AgentへのClaude Code Skills導入 - 設計計画書 + +**作成日**: 2025-12-15 +**担当**: director agent +**ステータス**: 🔄 分析・設計中 + +--- + +## プロジェクト概要 + +NDFプラグインの6つのsub-agent(director、data-analyst、corder、researcher、scanner、qa)にClaude Code Skillsを導入し、各エージェントの作業効率を最大化する。 + +**目標バージョン**: 1.1.1 → 1.2.0(MINOR: 新機能追加) + +--- + +## 調査完了: Claude Code Skills仕様 + +### Skills基本仕様 +- **Model-invoked**: Claudeが自律的に判断して呼び出す +- **必須ファイル**: `SKILL.md`(YAMLフロントマター + Markdown) +- **YAMLフロントマター**: + - `name`: スキル名(必須、小文字・数字・ハイフン、最大64文字) + - `description`: 説明(必須、最大1024文字、トリガー用語含む) + - `allowed-tools`: アクセス可能ツール制限(オプション) + +### ディレクトリ構造(推奨) +``` +skill-name/ +├── SKILL.md # 必須 +├── reference.md # オプション: 詳細ドキュメント +├── examples.md # オプション: 使用例 +├── scripts/ # オプション: スクリプト +│ └── helper.py +└── templates/ # オプション: テンプレート + └── template.txt +``` + +### plugin.jsonへの統合(推定) +```json +{ + "name": "ndf", + "version": "1.2.0", + "skills": [ + "./skills/skill-name-1", + "./skills/skill-name-2" + ] +} +``` + +### ベストプラクティス +- **焦点を絞る**: 1 Skill = 1機能 +- **明確な説明**: トリガー用語を含む具体的なdescription +- **Progressive Disclosure**: 必要な場合のみ支援ファイルを読み込む + +--- + +## 既存Sub-Agent分析 + +### 1. director - タスク統括・計画立案エージェント + +**専門領域**: +- タスク全体の統括、進捗管理 +- 情報収集と調査(Serena MCP活用) +- 計画立案と並列実行判断 +- 結果の統合と報告 + +**使用ツール**: +- Serena MCP(コード探索、メモリー管理) +- GitHub MCP(Issue/PR管理) +- 基本ツール(Read, Glob, Grep, Bash) + +**作業プロセス**: +1. 要求理解 → TodoList作成 +2. 情報収集(Serenaメモリー、コードベース構造) +3. 計画立案と並列実行判断 +4. サブエージェント特定とMain Agentへ報告 +5. 結果統合と報告 + +**Skillsで改善できる領域**: +- ✅ **計画書テンプレート自動生成** +- ✅ **GitHub Issue/PR作成の定型化** +- ✅ **並列実行判断チェックリスト** +- ✅ **進捗レポート生成** +- ✅ **サブエージェント連携パターン** + +--- + +### 2. data-analyst - データ分析・SQL専門エージェント + +**専門領域**: +- SQL生成と実行(BigQuery、DBHub) +- データ解釈と分析 +- データ出力(CSV、JSON、Excel) + +**使用ツール**: +- BigQuery MCP +- DBHub MCP + +**作業プロセス**: +1. 要件理解 +2. データ探索(スキーマ確認) +3. SQL設計 +4. 実行と検証 +5. 解釈と報告 +6. ファイル出力 + +**Skillsで改善できる領域**: +- ✅ **SQL最適化パターン** +- ✅ **データ可視化スクリプト** +- ✅ **CSV/JSON/Excel出力テンプレート** +- ✅ **データ品質チェック** +- ✅ **レポート生成テンプレート** + +--- + +### 3. corder - コーディング専門エージェント + +**専門領域**: +- コード設計と実装 +- コード品質保証(Codex MCP) +- コードベース理解(Serena MCP) +- 最新情報の活用(Context7 MCP) + +**使用ツール**: +- Codex CLI MCP +- Serena MCP +- Context7 MCP + +**作業プロセス**: +1. 要件理解 +2. コードベース調査(Serena) +3. 最新情報収集(Context7) +4. 設計 +5. 実装 +6. レビュー(Codex) +7. 改善 +8. テスト + +**Skillsで改善できる領域**: +- ✅ **コード生成テンプレート**(設計パターン別) +- ✅ **リファクタリングパターン** +- ✅ **テストコード生成** +- ✅ **ドキュメント自動生成** +- ✅ **セキュリティチェックリスト** + +--- + +### 4. researcher - 情報収集・調査専門エージェント + +**専門領域**: +- 技術ドキュメント調査(AWS Docs MCP) +- Webスクレイピング(WebFetch、Chrome DevTools MCP) +- コードベース調査(Codex MCP) +- 情報の統合と分析 + +**使用ツール**: +- WebFetch(優先、静的ページ) +- AWS Documentation MCP +- Chrome DevTools MCP(動的ページ) +- Codex CLI MCP + +**作業プロセス**: +1. 調査計画 +2. ツール選択 +3. 情報収集 +4. 情報整理 +5. 分析 +6. 報告 + +**Skillsで改善できる領域**: +- ✅ **調査レポートテンプレート** +- ✅ **ベストプラクティス収集パターン** +- ✅ **API仕様抽出スクリプト** +- ✅ **技術比較テーブル生成** +- ✅ **WebスクレイピングパターンLibrary** + +--- + +### 5. scanner - ファイル読み取り専門エージェント + +**専門領域**: +- PDF読み取り +- 画像読み取り(OCR) +- Officeファイル読み取り(PowerPoint、Excel、Word) +- データ変換と整理 + +**使用ツール**: +- Read tool(画像優先) +- Codex CLI MCP(PDF、Office) + +**作業プロセス**: +1. ファイル確認 +2. ツール選択 +3. 読み取り実行 +4. 内容抽出 +5. 構造化 +6. 報告 + +**Skillsで改善できる領域**: +- ✅ **PDF要約テンプレート** +- ✅ **OCR後処理スクリプト** +- ✅ **Excelデータ構造化** +- ✅ **PowerPointスライド要約** +- ✅ **ファイル形式変換ツール** + +--- + +### 6. qa - 品質保証・テスト専門エージェント + +**専門領域**: +- コード品質レビュー(Codex MCP) +- セキュリティ検証(OWASP Top 10) +- パフォーマンステスト(Chrome DevTools MCP) +- テストカバレッジ +- ドキュメント品質 +- Claude Codeプラグイン品質 + +**使用ツール**: +- WebFetch(静的ページ確認) +- Codex CLI MCP +- Serena MCP +- Chrome DevTools MCP +- Claude Code MCP + +**作業プロセス**: +1. スコープ確認 +2. ツール選択 +3. 静的分析(Codex) +4. 動的テスト(Chrome DevTools) +5. 構造分析(Serena) +6. ドキュメント検証(WebFetch) +7. レポート作成 +8. 修正支援 + +**Skillsで改善できる領域**: +- ✅ **コードレビューチェックリスト** +- ✅ **セキュリティスキャンテンプレート** +- ✅ **パフォーマンステストレポート** +- ✅ **テストカバレッジ分析** +- ✅ **品質レポート生成** + +--- + +## 次のステップ + +### タスク完了待ち +- ✅ Claude Code Skills公式仕様調査(researcher) +- 🔄 外部公開Skills調査(researcher)- 進行中 + +### 実施予定 +1. **Skills詳細設計** - director(このドキュメント作成後) +2. **スクリプト・テンプレート設計** - director +3. **Skills実装** - corder +4. **バージョン管理とドキュメント更新** - director + +--- + +## 成果物予定 + +### 新規ディレクトリ +``` +plugins/ndf/skills/ +├── director-planning/ +├── director-github-integration/ +├── data-analyst-sql-optimization/ +├── data-analyst-report-generation/ +├── corder-code-templates/ +├── corder-test-generation/ +├── researcher-report-templates/ +├── scanner-pdf-analysis/ +├── scanner-excel-extraction/ +├── qa-code-review-checklist/ +├── qa-security-scan/ +└── qa-performance-test/ +``` + +### 更新ファイル +- `plugins/ndf/.claude-plugin/plugin.json` - skills配列追加、バージョン1.2.0 +- `plugins/ndf/CLAUDE.ndf.md` - Skills使用方法追加 +- `plugins/ndf/README.md` - Skills説明追加 +- Serenaメモリー(plugin-ndf.md)更新 + +--- + +**次回更新**: Skills詳細設計完了後 diff --git a/issues/old/i08.md b/issues/old/i08.md new file mode 100644 index 00000000..8782f870 --- /dev/null +++ b/issues/old/i08.md @@ -0,0 +1,64 @@ +# 開発AIエージェント向け指示書 + +* このドキュメントはClaude Codeなどの開発AIエージェント向けの指示書です。 +* チャットで「docs/cmd01.mdのx番を実行してください」と言われたらこのファイルを読み、見出しに書いてある番号の内容を実行してください。 +* 頻繁に書き換わるので、指示があるたびに読み込みなおしてください。 +* 返答やドキュメントはすべて日本語で。 + +# 1. Markdown作成についてのSkill作成 +* Markdown文書作成時に読み込むSkillを作成して下さい +* 図を描くときはASCII ARTではなく、mermaidまたはplantUMLで記述すること + * mermaidまたはplantUMLは書きたい図の内容によってどちらにするか判断 + * ツリー構造の表現のみはasciiのままでよい +* Markdown文書は概ね300行以内とすること + * それ以上になる場合はディレクトリを作成して順序を示すprefixをつけてファイルを分割すること + * 順序prefixはパディング付き2桁(01-, 02-, ...) + +# 2. Affaan Mustafa Method導入 +* 以下の記事を読み、レポートをまとめてください + * https://github.com/affaan-m/everything-claude-code + * https://qiita.com/dai_chi/items/c19be47044d062d59ee8 + * https://zenn.dev/ttks/articles/a54c7520f827be +* affaan-mプラグインを作成し、導入すべき知見を導入してください + * ndfと併存する前提で構成してください + * これまでのskillやagentと重複する場合はaffaan-mを優先 + * ndfのskill、agentを整理・削除 + * カスタムコマンドとMCPが重複した場合はndf優先 +* 導入した知見をreport08.mdにまとめてください + +# 3. カスタムコマンド/pr-tests導入 ✅ 完了 + +## 実装内容 + +### 新規ファイル +- `plugins/ndf/commands/pr-tests.md`: Test Plan自動実行コマンド + +### 更新ファイル +- `plugins/ndf/.claude-plugin/plugin.json`: + - commands配列に`./commands/pr-tests.md`を追加 + - version: `2.2.2` → `2.3.0`(MINOR版アップ) +- `plugins/ndf/README.md`: + - 概要セクションでコマンド数を更新(6→7) + - `/pr-tests`コマンドの説明を追加 + +## 機能仕様 + +* `/ndf:pr-tests`コマンドの実装完了 +* PRを読み取ってTest Planを自動実行 + * ✅ 対象のPRを読み込む(引数またはブランチから自動検出) + * ✅ Test Planを解釈し、テスト実行計画を建てて実行する + * ✅ 結果をもとにPRコメントを編集する + * ✅ 成功したテストはチェックマークを付ける + * ✅ 失敗した場合は原因がコードであればコードを修正し、修正commitをpushし、コメントを追加する + * ✅ 失敗原因がコードで修正できない場合はPRにその旨のコメントを追加する + * ✅ 全てのテストが終ったらPRにテスト結果をまとめたコメントを追加する + +## バージョン更新 + +- **v2.2.2 → v2.3.0** (MINOR版アップ) +- 理由: 新機能追加(後方互換性あり) + +## 次のステップ + +- [ ] ローカルテストでコマンドが正常に動作することを確認 +- [ ] コミット・PR作成 diff --git a/issues/old/report01.md b/issues/old/report01.md new file mode 100644 index 00000000..dd81c562 --- /dev/null +++ b/issues/old/report01.md @@ -0,0 +1,210 @@ +plugin 経由で MCP サーバを定義する場合は、**「プラグインの中に `.mcp.json` か `mcpServers` 設定を書く」**というのが公式のやり方です。 +公式 Plugins Reference をそのままかみ砕いて説明します。([Claude Code][1]) + +--- + +## 1. 全体像:プラグインと MCP サーバの関係 + +Claude Code のプラグインは + +* commands(スラッシュコマンド) +* agents(サブエージェント) +* skills +* hooks +* **MCP servers** + +を「1パッケージ」にまとめたものです。([Claude Code][1]) + +**MCP サーバをプラグインに束ねると**: + +* プラグインを有効にした時点で MCP サーバが自動起動される +* その MCP が提供するツールが Claude のツール一覧に出てくる +* 個々のユーザーが `.mcp.json` を手で配布しなくて済む(チーム配布が楽) + +というメリットがあります。([Claude Code][1]) + +--- + +## 2. プラグイン内での MCP サーバ定義の場所 + +公式リファレンスでは、MCP サーバ定義の場所は 2 パターンあります:([Claude Code][1]) + +1. **プラグイン直下の `.mcp.json` に書く** +2. `.claude-plugin/plugin.json` の `mcpServers` フィールドで + + * 直接オブジェクトとして書く + * もしくは `./mcp-config.json` など外部 JSON を参照させる + +標準構成はこんな感じです:([Claude Code][1]) + +```text +my-plugin/ +├── .claude-plugin/ +│ └── plugin.json # プラグインマニフェスト +├── commands/ +│ └── ... +├── agents/ +│ └── ... +├── hooks/ +│ └── hooks.json +├── skills/ +│ └── ... +├── .mcp.json # ← MCPサーバ定義(パターン1) +└── servers/ # MCP サーバ本体(バイナリ/スクリプトなど) + └── ... +``` + +--- + +## 3. `.mcp.json` で MCP サーバを定義する(基本パターン) + +`.mcp.json` のフォーマットは **「標準 MCP サーバ設定」**で、トップレベルに `mcpServers` オブジェクトを置きます。([Claude Code][1]) + +公式のサンプル(ほぼそのまま): + +```jsonc +{ + "mcpServers": { + "plugin-database": { + "command": "${CLAUDE_PLUGIN_ROOT}/servers/db-server", + "args": ["--config", "${CLAUDE_PLUGIN_ROOT}/config.json"], + "env": { + "DB_PATH": "${CLAUDE_PLUGIN_ROOT}/data" + } + }, + "plugin-api-client": { + "command": "npx", + "args": ["@company/mcp-server", "--plugin-mode"], + "cwd": "${CLAUDE_PLUGIN_ROOT}" + } + } +} +``` + +各フィールドの意味は: + +* `mcpServers` + + * キー:Claude から見えるサーバ名(例: `"plugin-database"`) + * 値:その MCP サーバの起動方法 + +* `command` + + * 実行するコマンド。ローカルバイナリ・Node スクリプト・Python など何でも OK + * 例: `./servers/db-server`, `node`, `python`, `npx`など + +* `args`(任意) + + * コマンドライン引数。設定ファイルパスやモード指定など + +* `env`(任意) + + * 環境変数を指定 + * `${CLAUDE_PLUGIN_ROOT}` が使える(プラグインの実インストールディレクトリに展開される)([Claude Code][1]) + +* `cwd`(任意) + + * プロセスのカレントディレクトリ + +この `.mcp.json` を plugin ルートに置いておけば、プラグインが有効化されたときに MCP サーバが自動で起動し、Claude の「ツール」として認識されます。([Claude Code][1]) + +--- + +## 4. `plugin.json` に紐づける(パターン2) + +MCP 設定を **別ファイルに分けたい/インラインで書きたい**場合は、`.claude-plugin/plugin.json` の `mcpServers` フィールドを使います。([Claude Code][1]) + +### 4-1. 外部ファイル参照 + +`plugin.json`: + +```jsonc +{ + "name": "my-mcp-plugin", + "version": "1.0.0", + "description": "Plugin bundling custom MCP servers", + "mcpServers": "./mcp.json" // ここで外部JSONを参照 +} +``` + +`./mcp.json`(内容はさっきの `.mcp.json` と同じ形式): + +```jsonc +{ + "mcpServers": { + "plugin-database": { + "command": "${CLAUDE_PLUGIN_ROOT}/servers/db-server" + } + } +} +``` + +### 4-2. 直接インラインで書く + +`plugin.json` にそのままオブジェクトを書くこともできます:([Claude Code][1]) + +```jsonc +{ + "name": "my-inline-mcp-plugin", + "version": "1.0.0", + "description": "Inline MCP config example", + "mcpServers": { + "plugin-database": { + "command": "${CLAUDE_PLUGIN_ROOT}/servers/db-server", + "args": ["--config", "${CLAUDE_PLUGIN_ROOT}/config.json"] + } + } +} +``` + +この場合、**別途 `.mcp.json` を置く必要はありません。** + +--- + +## 5. 実際に MCP サーバを動かすときの注意点 + +公式リファレンスに書いてあるポイントを、実務寄りにまとめると:([Claude Code][1]) + +1. **すべてのパスは plugin ルートからの相対パス or `${CLAUDE_PLUGIN_ROOT}` を使う** + + * 絶対パスは環境ごとに変わるので NG +2. **サーバ実行ファイルに実行権限を付ける** + + * Unixなら `chmod +x servers/db-server` +3. **MCP サーバは MCP プロトコル(JSON-RPC over stdio)で応答する必要がある** + + * ここは従来通りの MCP 実装ルール +4. **デバッグには `claude --debug` や `--mcp-debug` を使う** + + * プラグイン読み込みや MCP 起動エラーがログに出る +5. **プラグイン単位でユーザー・チームに配布できる** + + * `.mcp.json` や設定を手で配るより遥かに楽 + +--- + +## 6. ざっくり手順まとめ + +1. **プラグインの骨組みを作る** + + * `my-plugin/.claude-plugin/plugin.json` を作成 +2. **MCP サーバ本体を用意する** + + * `servers/` 以下に Node/Python/Go などで MCP サーバ実装 +3. **MCP 定義を書く** + + * `.mcp.json` か `plugin.json` の `mcpServers` にサーバ起動方法を記述 +4. **ローカルマーケットプレイス経由でインストール** + + * `marketplace.json` を用意して `/plugin marketplace add ./my-marketplace` + * `/plugin install my-plugin@my-marketplace` +5. Claude Code を再起動して、MCP サーバがツールとして見えているか確認 + +--- + +もしよければ次のステップとして: + +* BigQuery / Slack / GitHub など、**実際の社内ツール向け MCP サーバを束ねた plugin の「雛形リポジトリ」**を一緒に設計することもできます。 +* 例えば「kk-generation 用 /takeoff plugin」や「ナイル社内向け plugin」みたいな形で、marketplace + plugin 構成を具体的に書き起こすことも可能です。 + +[1]: https://code.claude.com/docs/en/plugins-reference "Plugins reference - Claude Code Docs" diff --git a/issues/old/report02.md b/issues/old/report02.md new file mode 100644 index 00000000..a3ed0eec --- /dev/null +++ b/issues/old/report02.md @@ -0,0 +1,151 @@ +結論: +**はい、あります。プラグイン側で「親エージェント向けのシステム指示書」を定義できます。 +ただし名前は *CLAUDE.md* ではなく、`agents/` ディレクトリに置く「エージェント定義ファイル」として提供します。** + +つまり、**プラグイン経由で “親エージェントの振る舞い” を決めることが可能**です。 + +以下、公式仕様に基づいて整理します。 + +--- + +# ✅ 結論:plugin で「親エージェントへの指示書」を定義できる + +Claude Code の plugin では、次のものをバンドルできます: + +* **サブエージェント(agents/)** +* **hooks** +* tools / MCP / commands +* skills +* etc. + +このうち **エージェント定義ファイル(= エージェントのシステムプロンプト)を plugin 側で持たせられる**ので、 +事実上 **「plugin 内に CLAUDE.md 相当の指示書」を同梱できます。** + +--- + +# 📌 仕組み:plugin の `agents/` ディレクトリに “親エージェント” を定義する + +プラグイン側では、次のような構造を取れます: + +``` +my-plugin/ + .claude-plugin/ + plugin.json + agents/ + main.md ← 親エージェント (CLAUDE.md 相当) + data-analyst.md ← サブエージェント + mcp.json + commands/ + hooks/ +``` + +`agents/main.md` の内容に、**親エージェントのシステムプロンプトを直接書けます**。 + +例: + +```markdown +--- +name: main +description: > + 親エージェント。BigQuery/dbhub のようなデータ取得タスクは + 必ず data-analyst サブエージェントに依頼する。 + 自分で MCP ツールは直接使用しないこと。 +--- + +あなたはプロジェクト全体の調整役として行動します。 + +# 禁止事項 +- BigQuery MCP を直接呼び出してはならない +- dbhub MCP を直接呼び出してはならない +- データ取得や SQL 実行はすべて data-analyst へ委譲すること + +# 指示 +- データ取得が必要な場合、「data-analyst を呼び出して ○○ を集計して」と明示する。 +- data-analyst から返ってきた要約のみを扱い、自分のコンテキストに生データを保持しない。 +``` + +--- + +# 📌 重要:plugin で定義したエージェントは「プロジェクトに自動追加」される + +公式ドキュメントの該当部分(Plugins → agents)では: + +* **plugin の agents/ 内に配置したエージェントは「そのプロジェクトで利用可能なエージェント」として追加される** +* name, description, tools, model が指定できる +* tools 範囲を制限できる(→ data-analyst だけ BigQuery MCP を持たせるのはこの仕組み) + +と説明されています。 + +つまり **plugin が “エージェント定義 + ルール” をプロジェクトに差し込む仕組み**があるわけです。 + +--- + +# 💡 実際にどうなるか? + +### 👇 あなたの目的は: + +> 親エージェントから BigQuery/dbhub MCP を見えなくしたい +> (少なくとも、触らせないようにしたい) + +### Plugin 構成で実現できること: + +* data-analyst サブエージェント → tools に BigQuery/dbhub MCP を列挙 +* 親エージェント → tools に BigQuery/dbhub MCP を含めない +* 親エージェントの agent 定義(main.md)に + **「データ操作はすべて data-analyst に委譲する」**と明記 +* plugin インストール時にこの main.md を自動適用 + +→ **実質的に CLAUDE.md と同じことが plugin 内に含められる**。 + +これは Claude Code plugin の設計上、正式にサポートされている構成です。 + +--- + +# 📝 plugin.json の例 + +```jsonc +{ + "name": "team-data-workflow", + "version": "1.0.0", + "description": "データ分析専用サブエージェント付きワークフロープラグイン", + "agents": "./agents", + "mcpServers": "./mcp.json" +} +``` + +この構成なら、plugin を入れたプロジェクトでは: + +* 親エージェント(main) +* data-analyst + +が自動で追加され、 +「親には BigQuery MCP を触らせない」 +という規律が **plugin で一元管理**できます。 + +--- + +# 🎯 まとめ + +### ✔ plugin で CLAUDE.md と同等の「指示書」を定義できます + +→ それは `agents/main.md` という形で表現される + +### ✔ 親エージェント専用のプロンプトを書ける + +→ BigQuery/dbhub MCP に触らない規律を plugin が強制可能 + +### ✔ data-analyst には BigQuery/dbhub MCP ツールのみ付与 + +→ 親とサブで明確に役割分離できる + +--- + +# 📘 必要があれば… + +* あなたの plugin 構成(ファイル名や MCP の数)に合わせて +  **完全な plugin テンプレ構成** +  **main.md / data-analyst.md の最適化** +  **tools のリストの正しい記載方法** + を一式生成できます。 + +ファイル構成を少し教えていただければ、すぐに最適解を組み上げます。 diff --git a/issues/old/report03.md b/issues/old/report03.md new file mode 100644 index 00000000..208e1bed --- /dev/null +++ b/issues/old/report03.md @@ -0,0 +1,249 @@ +了解です。 +以下に **そのまま使える形**で、 + +1. **記憶戦略を運用するための Agent Skill(薄い・判断しない)** +2. **Serena MCP に置く「記憶戦略そのもの」の Memory(中期/長期)** + +を **別々の Markdown** として出力します。 + +> 設計意図 +> +> * **Skill**:記憶をどう扱うかの「行動ルール」だけ +> * **Serena Memory**:記憶戦略という「前提・思想・判断基準」そのもの + +--- + +# ① 記憶戦略用 Agent Skill(薄い skill) + +**用途** + +* Claude Code / Agent に常時持たせる +* 「記憶をどう使うか」を機械的に守らせる +* 判断はしない(判断は memory 側) + +**推奨配置** + +* `skills/memory_handling.md` +* または `CLAUDE.md` から参照される skill + +--- + +```md +# Skill: Memory Handling Strategy + +## Purpose +This skill defines **how the agent should handle memory**, +not **what should be remembered**. + +All decisions, principles, and project-specific rules must be retrieved +from Serena MCP memory. + +--- + +## Core Rules + +### 1. Skill must remain thin +- Do not store project-specific rules here +- Do not store decisions, reasons, or constraints +- This skill defines behavior only + +--- + +### 2. When to read memory +Before starting a task, check whether: +- the task depends on project-specific assumptions +- the task may conflict with past decisions +- the task requires architectural, legal, or organizational constraints + +If yes: +- list available Serena memories +- read only the relevant ones + +--- + +### 3. When to write memory +After completing a task, write to Serena memory if: +- a new decision was made +- an assumption was confirmed or invalidated +- a reusable insight emerged +- repeating this explanation later would be costly + +--- + +### 4. What NOT to write as memory +Do NOT store: +- step-by-step procedures +- implementation details +- temporary working notes +- raw logs or dumps + +--- + +### 5. Memory hygiene behavior +- Prefer updating existing memory over creating duplicates +- If a memory seems outdated, mark it for review +- Do not assume old memories are always valid + +--- + +## Interaction Pattern (Summary) + +1. Identify whether memory is needed +2. Read only relevant memories +3. Execute the task +4. Persist only durable knowledge back to memory + +--- + +## Guiding Principle + +> The agent acts. +> The memory decides. +``` + +--- + +# ② 記憶戦略そのもの(Serena MCP Memory) + +これは **Serena MCP に保存される「長期 memory」** です。 +Agent Skill から参照される前提知識になります。 + +**推奨配置** + +* `.serena/memories/memory-strategy.md` + +--- + +```md +# Memory Strategy for AI Agent +type: policy +confidence: high +project: global +expires: none + +--- + +## Purpose + +This memory defines **how mid-term and long-term memories are structured, +classified, and maintained** across projects. + +It is the single source of truth for deciding: +- what belongs in memory +- what belongs in skills +- how memories evolve over time + +--- + +## Memory Layer Definitions + +### Short-term Memory +- Exists only within a session +- Not persisted +- Used for immediate task context + +--- + +### Mid-term Memory +- Valid for weeks to months +- Represents temporary or revisitable decisions +- Must include a review date + +Typical contents: +- phase-specific decisions +- PoC constraints +- experimental results +- temporary architectural choices + +Required metadata: +- type: decision | assumption | experiment +- confidence: low | medium | high +- review_at: YYYY-MM-DD +- project: + +--- + +### Long-term Memory +- Stable across the project lifecycle +- Represents principles and non-negotiable constraints + +Typical contents: +- architectural principles +- legal / IP constraints +- organizational policies +- technology philosophy + +Required metadata: +- type: principle | constraint | policy +- confidence: high +- expires: none +- project: + +--- + +## Skill vs Memory Decision Checklist + +If any of the following are true, the information must be stored as memory: + +- it will be reused in future sessions +- it is project-specific +- it explains *why*, not *how* +- it restricts future choices +- it may need revision or expiration +- it would bloat an agent skill + +Otherwise, it belongs in a skill or remains transient. + +--- + +## Memory Granularity Rules + +- One memory entry = one decision or principle +- Do not mix facts, assumptions, and conclusions +- Prefer clarity over completeness +- Avoid embedding procedures + +--- + +## Memory Maintenance Policy + +- Mid-term memories must be reviewed at `review_at` +- Obsolete memories should be archived or deleted +- Long-term memories should rarely change and must be updated carefully + +--- + +## Anti-Patterns + +- Storing procedures in memory +- Encoding decisions inside skills +- Creating duplicate memories for similar topics +- Leaving experimental assumptions unreviewed + +--- + +## One-line Rule + +> Skills describe behavior. +> Memory defines reality. +``` + +--- + +## 最終整理(使い分け) + +| 項目 | Skill | Serena Memory | +| ----- | ----- | ------------- | +| 役割 | 行動ルール | 判断・前提 | +| 肥大化耐性 | 低 | 高 | +| 修正コスト | 高 | 低 | +| 時間軸 | 短期 | 中期・長期 | + +--- + +次のステップとしておすすめなのは: + +* 🔄 **中期 memory の自動レビュー運用** +* 🤖 **task 終了時に memory write を促すプロンプト設計** + +どこまで仕上げますか? diff --git a/issues/old/report04.md b/issues/old/report04.md new file mode 100644 index 00000000..9e421025 --- /dev/null +++ b/issues/old/report04.md @@ -0,0 +1,249 @@ +了解です。Claude Code の **カスタムスラッシュコマンド**として、 + +1. **中期 memory の自動レビュー運用**(/mem-review) +2. **task 終了時に memory write を促す**(/mem-capture) + +を **そのまま置ける .md ファイル**で作ります。 + +> 仕様根拠:Claude Code は `.claude/commands/*.md` に Markdown を置くとスラッシュコマンド化でき、frontmatter で `allowed-tools` などを指定できます。([Claude Code][1]) +> また `disable-model-invocation: true` を使うと自動発火を抑制できます。([クラスメソッド発「やってみた」系技術メディア | DevelopersIO][2]) + +--- + +## 1) /mem-review(中期 memory の自動レビュー運用) + +**狙い** + +* `.serena/memories/` を走査して `review_at` 期限の来た中期記憶を検出 +* 期限超過/期限間近をまとめて提示 +* 1件ずつ「延長/長期化/アーカイブ/削除/更新」を提案し、必要ならファイルを編集 + +> Serena の memory が `.serena/memories/` に置かれる運用は一般に定着しています(Serena workflow/usage系の情報)。([クラスメソッド発「やってみた」系技術メディア | DevelopersIO][3]) +> ※あなたの環境でパスが違う場合は、コマンド内の `MEM_DIR` を変更してください。 + +**保存先**:`.claude/commands/mem-review.md` + +```md +--- +description: "中期Serena memory(review_at付き)を自動検出してレビューする" +argument-hint: "[--days N] [--dir PATH] 例: /mem-review --days 14" +allowed-tools: Bash(date:*), Bash(find:*), Bash(rg:*), Bash(ls:*), Bash(pwd:*), Read, Write +disable-model-invocation: true +--- + +あなたは「中期/長期の記憶戦略」を運用するエージェントです。 +このコマンドは `.serena/memories/` の **中期 memory**(`review_at` を持つもの)をレビューします。 + +# 入力 +$ARGUMENTS + +# 期待するmemory形式(例) +先頭付近に以下のようなメタ情報がある(YAMLでなくても "key: value" を本文に含めば可): +- type: decision | assumption | experiment +- review_at: YYYY-MM-DD +- confidence: low | medium | high +- project: + +# 実行手順 + +## 0) パラメータ解釈 +- --days N : 「期限間近」とみなす日数(デフォルト14日) +- --dir PATH : memoryディレクトリ(デフォルト `.serena/memories`) + +まず Bash で今日の日付(YYYY-MM-DD)を取得する: +- `date +%F` + +次に、対象ディレクトリを決定する(存在確認も行う)。 + +## 1) 期限超過・期限間近の抽出 +Bash で以下を行う: +1. memoryファイル一覧を取得(*.md想定) +2. 各ファイルから `review_at: YYYY-MM-DD` を抽出 +3. 今日の日付と比較して: + - overdue: review_at < today + - due_soon: today <= review_at <= today + N日 +4. 結果を「overdue」「due_soon」に分けてリストアップ + +比較は Bash で `date -d` が使えない環境があるので、**ISO日付の文字列比較**を基本とする。 +(YYYY-MM-DDなら辞書順で比較可能) + +## 2) レポート表示(必須) +以下を必ず出力: +- 今日の日付 +- 設定(dir, days) +- overdue一覧(ファイル名・review_at・type・project・冒頭1〜2行の要約) +- due_soon一覧(同上) + +要約のために必要なファイルだけ Read する。 + +## 3) 1件ずつレビュー処理 +各対象memoryについて、次の選択肢を提示し、ユーザーが指示しなくても「推奨」を1つ示す: +A. 延長(review_at を未来に更新) +B. 長期化(type=principle/constraint/policyへ移行、expires: none 付与、review_at削除) +C. 更新(内容修正 + review_at更新) +D. アーカイブ(ファイル末尾に `status: archived` を追記、または `ARCHIVE/` に移動) +E. 削除 + +ユーザーが指示しない場合は、以下で自動推奨: +- 実験結果が確定して「原則」になっている → B +- まだ暫定だが有効 → A +- 内容が古い/前提が変わった → C or D +- 明らかに不要 → E + +編集が必要なら Write でファイルを更新する。 + +## 4) 変更サマリ +最後に、実行した変更を一覧で出力: +- 更新したファイル +- 変更内容(review_at変更/長期化/アーカイブ/削除) +- 次回のレビュー推奨日 + +# 注意 +- このコマンド自体は「記憶戦略の運用」だけを行う。 +- 新しい意思決定を作るのは /mem-capture に誘導する。 +``` + +--- + +## 2) /mem-capture(task終了時の memory write を促す) + +**狙い** + +* タスク完了後に、**記憶化すべきものだけ**を短いフォームで回収 +* 既存memoryに追記 or 新規作成 +* 中期なら `review_at` をデフォルトで **60日後**(変更可) +* 長期化フラグ(`--long`)で principle/constraint/policy として保存 + +**保存先**:`.claude/commands/mem-capture.md` + +```md +--- +description: "タスク終了時に、再利用価値のある知見をSerena memoryとして保存する" +argument-hint: "[--project NAME] [--type decision|assumption|experiment|principle|constraint|policy] [--review-at YYYY-MM-DD] [--long] [--append FILE]" +allowed-tools: Bash(date:*), Bash(pwd:*), Bash(ls:*), Bash(mkdir:*), Bash(test:*), Read, Write +disable-model-invocation: true +--- + +あなたは「中期/長期の記憶戦略」を運用するエージェントです。 +このコマンドは **タスク終了時**に、記憶化すべき内容をフォームで回収し、 +`.serena/memories/` に保存します。 + +# 入力 +$ARGUMENTS + +# 目標 +- Skillを肥大化させないため、判断・前提・制約は memory に保存する +- ただし「手順」「実装詳細」は保存しない + +# 手順 + +## 0) デフォルト設定 +- memory dir: `.serena/memories` +- 今日: `date +%F` +- デフォルト review_at: 今日 + 60日(Bashで日付計算できない環境があるので、ユーザーに日付入力を促しても良い) + +## 1) まずユーザーにフォームで回答してもらう(必須) +以下をそのまま提示し、ユーザーの入力を待たずに「推奨の埋め方」も例示してよいが、最終的にはユーザー入力を使う。 + +### Memory Capture Form +1. project: (例 carloc / mdx / global) +2. type: (decision / assumption / experiment / principle / constraint / policy) +3. confidence: (low / medium / high) +4. title: (短いタイトル) +5. context: (何が起きた? 1-2文) +6. decision_or_fact: (確定事項を1-3点) +7. why: (理由があれば1-2文) +8. next_action: (必要なら) +9. review_at: (中期のみ YYYY-MM-DD, 長期は none) +10. related_files: (任意。パスやPR番号など) + +## 2) 保存先の決定 +- `--append FILE` があれば、そのファイルを Read して追記する +- それ以外は新規作成: + - ファイル名: `{today}-{project}-{slug(title)}.md` + - 保存先: `.serena/memories/` + +必要なら mkdir を Bash で実行。 + +## 3) 出力フォーマット +新規作成の場合、以下のテンプレを使う(内容はフォームから埋める): + +--- +# +type: <type> +confidence: <confidence> +project: <project> +review_at: <YYYY-MM-DD or none> +created_at: <today> +status: active +--- + +## Context +<context> + +## Decision / Facts +- <decision_or_fact 1> +- <decision_or_fact 2> + +## Why +<why> + +## Next action +<next_action> + +## Related +- <related_files> + +## Notes +- This memory intentionally excludes procedures and implementation details. + +## 4) 中期→長期化の自動提案 +- type が decision/assumption/experiment でも、内容が「不変の原則」なら + 長期化(principle/constraint/policy)を提案する +- `--long` 指定があれば review_at は none にする + +## 5) 完了サマリ +保存/追記したファイルパスを必ず表示し、次の推奨コマンドを提示: +- 次回レビュー: `/mem-review` +``` + +--- + +## 置き方(最小) + +```bash +mkdir -p .claude/commands +# 上の2ファイルをそれぞれ .claude/commands/mem-review.md と mem-capture.md に保存 +``` + +--- + +## 使い方例 + +* 期限チェック(14日以内を「間近」扱い) + + * `/mem-review --days 14` + +* タスク完了の記憶化(中期) + + * `/mem-capture --project carloc --type decision --review-at 2026-04-01` + +* 長期化(原則として保存) + + * `/mem-capture --project global --type principle --long` + +--- + +## 追加で「より自動」にしたい場合(選択肢) + +Claude Code 側だけで“完全自動(フック)”は難しいことが多いので、実務的には + +* PR作成前に `/mem-capture` +* 週1で `/mem-review` + が堅いです。 + +もし「Git hook / CI で週次レビューを半自動化」までやりたいなら、あなたの運用(GitHub Actions有無、CI権限)に合わせて *hook/ジョブ* 版も作れます。 + +[1]: https://code.claude.com/docs/ja/slash-commands?utm_source=chatgpt.com "スラッシュコマンド - Claude Code Docs" +[2]: https://dev.classmethod.jp/en/articles/disable-model-invocation-claude-code/?utm_source=chatgpt.com "For skills that are only executed manually (slash commands), I ..." +[3]: https://dev.classmethod.jp/articles/20250806-tuning-project-configuration-for-serena-efficiency/?utm_source=chatgpt.com "プロジェクト構成をSerena効率化に向けたチューニング" diff --git a/issues/old/report05.md b/issues/old/report05.md new file mode 100644 index 00000000..3bbf19e3 --- /dev/null +++ b/issues/old/report05.md @@ -0,0 +1,184 @@ +いいテーマですね。 +結論から言うと **「Claude Code の agent skills」と「Serena MCP」は役割がかなり違う**ので、**競合ではなくレイヤー分離**で考えるのが一番スッキリします。 + +--- + +## 全体像(まずここを押さえる) + +ざっくり役割を一言で言うと👇 + +| 要素 | 役割 | +| ---------------------------- | --------------------------- | +| **Claude Code agent skills** | **その場の作業をどう実行するか(振る舞い・手順)** | +| **Serena MCP** | **外部知能(記憶・検索・構造化知識)へのI/O** | +| **Serena memory** | **人間やプロジェクトの「長期的な文脈」を保持** | + +つまり +👉 *skill = 行動の型* +👉 *memory = 記憶の層* +👉 *MCP = それをLLMに接続する配線* + +--- + +## Claude Code の agent skills とは何か + +Claude Code の skill は基本的に: + +* **タスク実行向け** +* **短期コンテキスト前提** +* **「今このリポジトリで何をするか」寄り** + +### 向いていること + +* リファクタリング手順 +* テスト生成フロー +* PR作成ルール +* エラーログ解析 → 修正 +* 「このコードベースではこう直す」という即時判断 + +### 向いていないこと + +* プロジェクト横断の知識蓄積 +* 半年前の設計思想を覚えておく +* 人・組織・方針レベルの記憶 + +👉 **stateless(毎回忘れてOK)な作業ロジック**が得意。 + +--- + +## Serena MCP の位置づけ(ここが重要) + +Serena MCP は **「Claudeに外付けの脳を与える」仕組み**です。 + +* MCP = Model Context Protocol +* LLMの外にある: + + * memory + * knowledge + * vector store + * structured notes + を **安定して参照できる** + +つまり **Claude Code が毎回ゼロから考えなくて済む**。 + +--- + +## Serena memory と skill の決定的な違い + +### ① 時間軸が違う + +| 観点 | Skill | Serena memory | +| ---- | ------- | ------------- | +| 有効期間 | そのセッション | 週〜年 | +| 再利用 | 限定的 | 非常に高い | +| 前提 | 今回のタスク | 人・プロジェクトの履歴 | + +### ② 記憶の粒度が違う + +**skill** + +```text +「TypeScriptの型は厳密に」 +「diffは最小化」 +``` + +**serena memory** + +```text +このプロジェクトでは +- BigQueryに日本語カラム名を使う +- ETLは冪等性必須 +- 推論モデルはLightGBM優先 +``` + +👉 skillは「やり方」 +👉 memoryは「前提条件」 + +--- + +## 「Serena memory は不要?」への正直な答え + +### ❌ 不要なケース + +以下に全部当てはまるなら **不要**です: + +* 単発のコード生成しかしない +* 毎回別プロジェクト +* 設計思想をAIに覚えさせる気がない +* 「その場で動けばOK」 + +→ Claude Code + skills だけで十分 + +--- + +### ✅ 圧倒的に効くケース(あなたはここ) + +あなたの過去の使い方を見る限り👇 + +* 複数プロダクト横断(カルモ、カーロケ、査定AI…) +* 技術選定の思想が一貫している +* 法務・IP・業務制約が多い +* 「また同じ説明をAIにさせたくない」 + +**これは Serena memory が一番効くタイプです。** + +--- + +## おすすめの使い分けパターン(実践編) + +### レイヤー構成(鉄板) + +``` +[ Serena memory ] + - 技術思想 + - 事業背景 + - 過去の意思決定 + - NG集 + + ↓ 参照 + +[ Claude Code agent skills ] + - 実装 + - 修正 + - 解析 + - 出力整形 +``` + +### 具体例 + +#### Serena memory に入れるべきもの + +* 「中古車価格推定は説明可能性必須」 +* 「BigQueryはCDC前提」 +* 「PoCではGPU使わない」 +* 「IP的に○○方式は避ける」 + +#### skill に書くべきもの + +* 「LightGBMの学習コードを生成」 +* 「Dockerfileを最適化」 +* 「PRテンプレに沿ってdiff作成」 + +--- + +## 「Serena memory は skill で代替できる?」問題 + +技術的には **一部は可能**ですが: + +* skillは肥大化する +* 毎回ロードされてコスト増 +* 文脈の優先順位制御が難しい + +👉 **長期記憶を skill に押し込むのはアンチパターン**です。 + +--- + +## 最終まとめ(判断基準) + +* **短期・作業手順** → Claude Code skills +* **長期・前提・思想** → Serena memory +* **不要か?** → + + * 趣味開発:不要 + * 事業開発・組織開発:**必須級** + diff --git a/issues/old/report08.md b/issues/old/report08.md new file mode 100644 index 00000000..2ce70ebd --- /dev/null +++ b/issues/old/report08.md @@ -0,0 +1,788 @@ +# Claude Code ベストプラクティス調査レポート + +## 調査日時 +2026-01-30 + +## 調査対象記事 +1. [everything-claude-code](https://github.com/affaan-m/everything-claude-code) - Anthropicハッカソン優勝者による本番環境対応設定集 +2. [Claude Code 実践ガイド (Qiita)](https://qiita.com/dai_chi/items/c19be47044d062d59ee8) +3. [Claude Code 実装パターン (Zenn)](https://zenn.dev/ttks/articles/a54c7520f827be) + +--- + +## 📊 各記事の要約 + +### 1. everything-claude-code (GitHub) + +**概要**: 10ヶ月以上の実戦使用から生まれた本番環境対応の設定集 + +**主要コンポーネント**: +- **エージェント**: 専門化された部分タスク実行(例: code-reviewer, tdd-guide) +- **スキル**: 再利用可能なワークフロー定義(TDD実装、バックエンド設計パターン等) +- **ルール**: 常時適用される指針(セキュリティチェック、コーディング規約、テスト要件) +- **ホック**: ツール実行時に自動発火する処理(console.log検出警告等) + +**重要な発見**: +- ⚠️ **コンテキスト窓の縮小問題**: MCPツール有効化により200k→70kへ縮小する可能性 +- 推奨設定: MCP設定時20~30個、プロジェクト単位で10個以下、アクティブツール80未満 +- パッケージマネージャー自動検出機構(npm/pnpm/yarn/bun) +- 継続学習システム(instinct-based learning with confidence scoring) + +**実装パターン**: +- TDDワークフロー: インターフェース定義→RED→GREEN→リファクタリング→カバレッジ検証 +- 検証ループ: チェックポイント vs 継続評価の使い分け +- クロスプラットフォーム対応(Windows/macOS/Linux) +- GitHub App ベースのスキル自動生成機能 + +### 2. Claude Code 実践ガイド (Qiita) + +**概要**: 5つの中核原則による効率的な設定管理 + +**5つの中核原則**: +1. **段階的改善** - 完璧性より実用性を優先 +2. **コンテキスト効率化** - ツール過剰問題を回避、未使用MCPを無効化 +3. **並列実行活用** - `/fork`コマンドとgit worktreesの使い分け +4. **定型作業自動化** - Hooksで繰り返し作業を排除 +5. **エージェントスコープ制限** - Subagentに限定ツールのみ許可 + +**実装レベルのテクニック**: + +| Hooks設定例 | 用途 | +|------------|------| +| Prettier自動フォーマット | PostToolUse時の整形 | +| console.log検出警告 | 2段階チェック | +| TypeScript型チェック | 自動実行 | +| tmux使用促進 | PreToolUse時 | + +**並列ワークフロー判断基準**: +- 異なるファイル群 → `/fork`(軽量) +- 同一ファイル編集可能性 → git worktrees(競合防止) + +**コンテキスト管理ルール**: +- MCP設定数: 20-30個以内 +- プロジェクト有効化: 10個以下 +- 総ツール数: 80個以下維持 +- `/status`で監視、50-60%で`/compact`実行 + +### 3. Claude Code 実装パターン (Zenn) + +**概要**: 4つの基本原則に基づく実証済みアプローチ + +**4つの基本原則**: +1. **エージェント分業制**: 専門エージェントに必要最小限のツール(5個程度)を配置 +2. **TDD中心ワークフロー**: RED→GREEN→REFACTORサイクル、80%以上カバレッジ必須 +3. **セキュリティファースト**: コミット前の脆弱性、入力検証、シークレット混入チェック +4. **コンテキスト管理**: MCPは20~30個設定、プロジェクト単位で10個以下を有効化 + +**実装パターン**: +- **agents/**: planner.md、architect.md、tdd-guide.md、code-reviewer.md、security-reviewer.md等 +- **commands/**: `/tdd`、`/plan`、`/code-review`、`/build-fix`等のスラッシュコマンド +- **rules/**: モジュラーに分割されたルールファイル(セキュリティ、コーディング規格、テスト要件) +- **hooks/**: PostToolUse時の自動フォーマット、console.log検出等 + +**重要な実績データ**: +- MCPサーバー有効化で200k→70kへコンテキスト減少 +- ツール数80個以下制限と選別が重要 + +--- + +## 🎯 affaan-mプラグインとして実装すべき機能 + +### 前提: NDFプラグインとの併用 + +**NDFプラグインの役割(既存)**: +- MCP統合(6個のMCPサーバー) +- ワークフローコマンド(6個のスラッシュコマンド) +- 専門エージェント(6個のサブエージェント) +- スキル(8個のClaude Code Skills) + +**affaan-mプラグインの役割(新規)**: +- コンテキスト管理 +- 品質保証機能 +- TDDワークフロー +- セキュリティチェック +- 開発効率化機能 + +### Phase 1: 基盤整備(v1.0.0) + +#### 1. コンテキスト管理機能 + +**問題**: MCPツール有効化により200k→70kへコンテキストが大幅縮小 + +**実装機能**: +- コンテキスト監視コマンド: `/context-status` +- 自動コンパクト化: 60%閾値で`/compact`実行 +- MCP数警告: 10個超過時に警告表示 +- ツール数監視: 80個以下を推奨 + +**実装場所**: +``` +plugins/affaan-m/ +├── commands/ +│ └── context-status.md # コンテキスト監視コマンド +├── hooks/ +│ └── context-monitor.js # 自動監視フック +└── docs/ + └── context-management.md # コンテキスト管理ガイド +``` + +#### 2. Hooksシステム + +**実装するHooks**: + +| Hook タイプ | 機能 | 優先度 | +|-----------|------|--------| +| PostToolUse | 自動フォーマット(Prettier/ESLint) | 高 | +| PostToolUse | console.log/debugger検出警告 | 高 | +| PostToolUse | TypeScript型チェック自動実行 | 中 | +| PreCommit | シークレット混入チェック | 高 | +| PreCommit | テストカバレッジ検証(80%以上) | 中 | +| PreToolUse | tmux使用促進、環境チェック | 低 | + +**実装場所**: +``` +plugins/affaan-m/ +├── hooks/ +│ ├── hooks.json # Hooks定義 +│ ├── auto-format.js # 自動フォーマット +│ ├── detect-console-log.js # console.log検出 +│ ├── typescript-check.js # TypeScript型チェック +│ ├── secret-scan.js # シークレット混入チェック +│ └── coverage-check.js # カバレッジ検証 +└── docs/ + └── hooks-guide.md # Hooks設定ガイド +``` + +#### 3. TDDワークフローコマンド + +**5段階TDDプロセス**: +1. インターフェース定義 +2. RED(失敗テスト作成) +3. GREEN(最小実装) +4. リファクタリング +5. カバレッジ検証(80%以上) + +**実装コマンド**: +- `/tdd` - TDDワークフロー開始 +- `/tdd-red` - 失敗テスト作成 +- `/tdd-green` - 最小実装 +- `/tdd-refactor` - リファクタリング +- `/tdd-coverage` - カバレッジ検証 + +**実装場所**: +``` +plugins/affaan-m/ +├── commands/ +│ ├── tdd.md # TDDワークフローコマンド +│ ├── tdd-red.md # REDフェーズ +│ ├── tdd-green.md # GREENフェーズ +│ ├── tdd-refactor.md # リファクタリング +│ └── tdd-coverage.md # カバレッジ検証 +├── skills/ +│ └── tdd-workflow/ +│ └── SKILL.md # TDDワークフロースキル +└── docs/ + └── tdd-guide.md # TDDガイド +``` + +#### 4. セキュリティチェック機能 + +**実装機能**: +- OWASP Top 10チェックリスト +- シークレット混入検出 +- 入力検証パターン +- セキュリティレビューコマンド + +**実装コマンド**: +- `/security-scan` - セキュリティスキャン +- `/owasp-check` - OWASP Top 10チェック + +**実装場所**: +``` +plugins/affaan-m/ +├── commands/ +│ ├── security-scan.md # セキュリティスキャン +│ └── owasp-check.md # OWASP Top 10チェック +├── skills/ +│ └── security-review/ +│ └── SKILL.md # セキュリティレビュースキル +└── docs/ + └── security-guide.md # セキュリティガイド +``` + +#### 5. パッケージマネージャー自動検出 + +**検出順序**: +1. 環境変数チェック +2. プロジェクト設定ファイル +3. lockファイル検出(package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lockb) + +**実装場所**: +``` +plugins/affaan-m/ +├── hooks/ +│ └── detect-package-manager.js # パッケージマネージャー検出 +└── docs/ + └── package-manager-guide.md # パッケージマネージャーガイド +``` + +--- + +## 🔄 NDFプラグインとの役割分担 + +### NDFプラグイン(既存) + +**役割**: MCP統合、ワークフロー、専門エージェント + +| カテゴリ | 機能 | +|---------|------| +| **MCP統合** | Codex CLI, BigQuery, AWS Docs, Chrome DevTools等(6個) | +| **ワークフローコマンド** | `/commit`, `/review-pr`, `/slack-notify`等(6個) | +| **専門エージェント** | director, corder, data-analyst, researcher, scanner, qa(6個) | +| **スキル** | データ分析、コード生成、リサーチ等(8個) | + +### affaan-mプラグイン(新規) + +**役割**: コンテキスト管理、品質保証、TDDワークフロー + +| カテゴリ | 機能 | +|---------|------| +| **コンテキスト管理** | `/context-status`、自動コンパクト化、MCP数監視 | +| **品質保証** | Hooks(自動フォーマット、console.log検出、型チェック) | +| **TDDワークフロー** | `/tdd`関連コマンド、TDDスキル | +| **セキュリティ** | `/security-scan`、`/owasp-check`、シークレット検出 | +| **開発効率化** | パッケージマネージャー自動検出 | + +### 併用シナリオ + +**シナリオ1: コーディング作業** +1. NDFプラグイン: `ndf:corder`エージェントでコード実装 +2. affaan-mプラグイン: PostToolUse Hooksで自動フォーマット、型チェック +3. affaan-mプラグイン: `/tdd`コマンドでテスト駆動開発 + +**シナリオ2: セキュリティレビュー** +1. NDFプラグイン: `ndf:qa`エージェントでコード品質レビュー +2. affaan-mプラグイン: `/security-scan`で脆弱性チェック +3. affaan-mプラグイン: PreCommit Hooksでシークレット混入検出 + +**シナリオ3: コンテキスト管理** +1. affaan-mプラグイン: `/context-status`でコンテキスト使用率確認 +2. affaan-mプラグイン: 60%超過時に自動コンパクト化 +3. NDFプラグイン: MCP統合を継続(affaan-mが監視) + +--- + +## 📦 affaan-mプラグインの初期構成案 + +### ディレクトリ構造 + +``` +plugins/affaan-m/ +├── .claude-plugin/ +│ └── plugin.json # プラグインメタデータ +├── commands/ +│ ├── context-status.md # コンテキスト監視 +│ ├── tdd.md # TDDワークフロー +│ ├── tdd-red.md # REDフェーズ +│ ├── tdd-green.md # GREENフェーズ +│ ├── tdd-refactor.md # リファクタリング +│ ├── tdd-coverage.md # カバレッジ検証 +│ ├── security-scan.md # セキュリティスキャン +│ └── owasp-check.md # OWASP Top 10チェック +├── hooks/ +│ ├── hooks.json # Hooks定義 +│ ├── context-monitor.js # コンテキスト監視 +│ ├── auto-format.js # 自動フォーマット +│ ├── detect-console-log.js # console.log検出 +│ ├── typescript-check.js # TypeScript型チェック +│ ├── secret-scan.js # シークレット混入チェック +│ ├── coverage-check.js # カバレッジ検証 +│ └── detect-package-manager.js # パッケージマネージャー検出 +├── skills/ +│ ├── tdd-workflow/ +│ │ └── SKILL.md # TDDワークフロースキル +│ └── security-review/ +│ └── SKILL.md # セキュリティレビュースキル +├── docs/ +│ ├── context-management.md # コンテキスト管理ガイド +│ ├── hooks-guide.md # Hooks設定ガイド +│ ├── tdd-guide.md # TDDガイド +│ ├── security-guide.md # セキュリティガイド +│ └── package-manager-guide.md # パッケージマネージャーガイド +├── README.md # プラグイン説明 +└── CHANGELOG.md # 変更履歴 +``` + +### plugin.json + +```json +{ + "name": "affaan-m", + "version": "1.0.0", + "description": "コンテキスト管理、品質保証、TDDワークフローを提供するClaude Codeプラグイン(NDFプラグイン併用前提)", + "author": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "keywords": [ + "context-management", + "quality-assurance", + "tdd", + "security", + "hooks", + "productivity" + ], + "commands": [ + "./commands/context-status.md", + "./commands/tdd.md", + "./commands/tdd-red.md", + "./commands/tdd-green.md", + "./commands/tdd-refactor.md", + "./commands/tdd-coverage.md", + "./commands/security-scan.md", + "./commands/owasp-check.md" + ], + "skills": [ + "./skills/tdd-workflow", + "./skills/security-review" + ], + "hooks": "./hooks/hooks.json", + "dependencies": { + "ndf": "^2.1.0" + }, + "config": { + "contextMonitoring": { + "enabled": true, + "threshold": 60, + "autoCompact": true + }, + "hooks": { + "autoFormat": true, + "consoleLogDetection": true, + "typescriptCheck": true, + "secretScan": true, + "coverageCheck": true + }, + "tdd": { + "coverageThreshold": 80, + "enforceRedGreenRefactor": true + }, + "security": { + "owaspCheck": true, + "secretPatterns": [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "GITHUB_TOKEN", + "SLACK_TOKEN" + ] + } + } +} +``` + +--- + +## 📋 導入ロードマップ + +### Phase 1: 基盤整備(v1.0.0)【優先度: 高】 + +**目標**: コンテキスト管理、基本Hooks、TDDワークフロー + +**実装内容**: +- [ ] プラグイン基盤構築(plugin.json、ディレクトリ構造) +- [ ] コンテキスト管理機能(`/context-status`、自動コンパクト化) +- [ ] 基本Hooks(auto-format, secret-scan, console.log検出) +- [ ] TDDワークフローコマンド(`/tdd`関連) +- [ ] TDDワークフロースキル +- [ ] パッケージマネージャー自動検出 +- [ ] ドキュメント作成(README、各種ガイド) + +**成功基準**: +- プラグインが正常にインストールできる +- `/context-status`でコンテキスト使用率を確認できる +- `/tdd`コマンドでTDDワークフローを実行できる +- Hooksが正常に発火する +- NDFプラグインと併用できる + +**期間**: 2週間 + +### Phase 2: 品質向上(v1.1.0)【優先度: 中】 + +**目標**: セキュリティ機能、追加Hooks + +**実装内容**: +- [ ] セキュリティスキャン機能(`/security-scan`、`/owasp-check`) +- [ ] セキュリティレビュースキル +- [ ] TypeScript型チェックHook +- [ ] カバレッジ検証Hook +- [ ] tmux使用促進Hook +- [ ] セキュリティガイド充実 + +**成功基準**: +- `/security-scan`でOWASP Top 10チェックができる +- PreCommit Hooksでシークレット混入を検出できる +- カバレッジ80%未満時に警告が表示される + +**期間**: 1週間 + +### Phase 3: 効率化(v1.2.0)【優先度: 低】 + +**目標**: 並列ワークフロー、高度なコンテキスト管理 + +**実装内容**: +- [ ] `/fork`コマンドのサポート(git worktrees) +- [ ] 並列実行判断ロジック +- [ ] コンテキスト最適化アドバイザー +- [ ] MCP推奨設定ガイド + +**成功基準**: +- 並列実行可能なタスクを自動判断できる +- `/fork`コマンドでgit worktreesを活用できる +- MCP数超過時に最適化アドバイスが表示される + +**期間**: 1週間 + +--- + +## 🔍 実装時の注意事項 + +### 1. NDFプラグインとの互換性 + +**必須事項**: +- NDFプラグインのMCP統合に干渉しない +- NDFプラグインのエージェントと重複しない +- NDFプラグインのコマンドと命名衝突しない +- `dependencies`に`ndf: ^2.1.0`を明記 + +**推奨事項**: +- NDFプラグインの`ndf:corder`、`ndf:qa`と連携するHooks設計 +- NDFプラグインの`/commit`、`/review-pr`と補完的な機能提供 + +### 2. Hooksシステム + +**必須事項**: +- Node.js統一で記述(OS依存性排除) +- Windows/macOS/Linux対応 +- エラーハンドリング徹底(Hook失敗でもメイン処理は継続) + +**推奨事項**: +- Hooksは設定ファイルでON/OFF可能にする +- Hook実行時間を監視(遅延防止) +- Hook失敗時のフォールバック処理 + +### 3. TDDワークフロー + +**必須事項**: +- 80%カバレッジは推奨値(強制ではない) +- プロジェクトの性質に応じてカスタマイズ可能 +- カバレッジ未達時は警告のみ(ブロックしない) + +**推奨事項**: +- RED→GREEN→REFACTORの順序を厳守 +- カバレッジ閾値を設定ファイルで調整可能にする + +### 4. セキュリティチェック + +**必須事項**: +- OWASP Top 10に準拠 +- シークレット検出パターンは設定ファイルで管理 +- 誤検知を減らす正規表現パターン + +**推奨事項**: +- セキュリティスキャン結果をレポート化 +- 検出した脆弱性の修正ガイド提供 + +### 5. コンテキスト管理 + +**必須事項**: +- MCP数の上限警告(10個超過) +- ツール数の上限警告(80個超過) +- コンテキスト使用率の監視(60%閾値) + +**推奨事項**: +- `/compact`の自動実行(ユーザー確認あり) +- MCP最適化のアドバイス表示 +- コンテキスト使用率の履歴記録 + +--- + +## 🔧 NDFプラグインの修正提案 + +affaan-mプラグインとの円滑な連携のため、NDFプラグイン側にも以下の修正を推奨します。 + +### 【優先度: 高】ドキュメントの更新 + +#### 1. README.md の更新 + +**追加セクション**: +```markdown +## 推奨プラグイン併用 + +### affaan-m プラグイン + +NDFプラグインと併用することで、以下の機能が追加されます: + +- **コンテキスト管理**: `/context-status`でコンテキスト使用率を監視 +- **品質保証**: 自動フォーマット、console.log検出、シークレットスキャン +- **TDDワークフロー**: `/tdd`コマンドで5段階TDDプロセスをガイド +- **セキュリティチェック**: OWASP Top 10準拠の脆弱性検出 + +インストール方法: +\```bash +/plugin install affaan-m@ai-plugins +\``` + +詳細は[affaan-mプラグインREADME](../affaan-m/README.md)を参照してください。 +``` + +#### 2. CLAUDE.ndf.md の更新 + +**追加セクション**: +```markdown +### 8. 推奨プラグイン併用 + +**affaan-m プラグイン(推奨)**: + +NDFプラグインと併用することで、以下の機能が追加されます: + +- **コンテキスト管理**: コンテキスト使用率の監視と自動最適化 +- **品質保証Hooks**: 自動フォーマット、セキュリティスキャン +- **TDDワークフロー**: テストファーストな開発サイクルのガイド + +**使用例**: +\``` +# コンテキスト使用率を確認 +/context-status + +# TDDワークフローを開始 +/tdd "ユーザー認証機能" + +# セキュリティスキャンを実行 +/security-scan +\``` + +**注意事項**: +- affaan-mプラグインのHooksは自動的に発火します +- コンテキスト管理機能は常時監視モードで動作します +- TDDワークフローはNDFの`corder`エージェントと連携します +``` + +### 【優先度: 中】directorエージェントの更新 + +**agents/director.md に追加**: + +```markdown +### affaan-mプラグインとの連携 + +**コンテキスト管理**: +- タスク開始前に`/context-status`でコンテキスト使用率を確認 +- 60%を超える場合は警告し、`/compact`の実行を推奨 + +**TDDワークフローの推奨**: +- コーディングタスクでは`/tdd`コマンドの使用を提案 +- テスト未実装の場合はTDDワークフローを推奨 + +**品質保証**: +- コード生成後、affaan-mプラグインのHooksが自動的にチェックを実行 +- 警告が出た場合は修正を指示 + +**使用例**: +\``` +# タスク開始前 +1. コンテキスト確認: `/context-status` +2. TDDワークフロー開始: `/tdd "機能名"` +3. サブエージェント起動(ndf:corder等) +4. Hooksによる自動チェック(affaan-mが自動実行) +5. 完了確認 +\``` +``` + +### 【優先度: 中】corderエージェントの更新 + +**agents/corder.md に追加**: + +```markdown +### TDDワークフローとの連携 + +**affaan-mプラグインのTDDワークフローと連携する場合**: + +1. **インターフェース定義**(TDD Step 1) + - 関数シグネチャ、型定義を先に決定 + +2. **RED(失敗テスト)**(TDD Step 2) + - `/tdd-red`コマンドで失敗テストを作成 + - テストが失敗することを確認 + +3. **GREEN(最小実装)**(TDD Step 3) + - `/tdd-green`コマンドで最小実装 + - テストをパスする最小限のコード + +4. **リファクタリング**(TDD Step 4) + - `/tdd-refactor`コマンドでコード品質向上 + - テストは常にパスする状態を維持 + +5. **カバレッジ検証**(TDD Step 5) + - `/tdd-coverage`コマンドでカバレッジ確認 + - 80%以上を目標(affaan-mプラグインが自動チェック) + +**注意事項**: +- affaan-mプラグインのHooksが自動的にコード品質をチェックします +- console.log、シークレット、型エラーは自動検出されます +``` + +### 【優先度: 低】コンテキスト管理のベストプラクティス追加 + +**CLAUDE.md に追加**: + +```markdown +## コンテキスト管理のベストプラクティス + +### MCP設定の推奨事項 + +**推奨上限**: +- グローバル設定: 20-30個以内 +- プロジェクト設定: 10個以下 +- 総ツール数: 80個以下 + +**監視方法**: +- affaan-mプラグインの`/context-status`コマンドで監視 +- コンテキスト使用率が60%を超えたら`/compact`を実行 + +**最適化手順**: +1. 使用頻度の低いMCPサーバーを無効化 +2. プロジェクト固有のMCPのみを有効化 +3. 定期的に`/context-status`でチェック + +**例**: +\```bash +# コンテキスト使用率を確認 +/context-status + +# 60%を超えている場合 +/compact + +# MCP設定を最適化 +# 不要なMCPサーバーをdisableにする +\``` +``` + +### 【優先度: 低】marketplace.jsonの更新 + +**推奨プラグイン情報の追加**: + +```json +{ + "name": "ai-plugins", + "owner": { + "name": "takemi-ohama", + "url": "https://github.com/takemi-ohama" + }, + "plugins": [ + { + "name": "ndf", + "source": "./plugins/ndf" + }, + { + "name": "affaan-m", + "source": "./plugins/affaan-m", + "recommended": true, + "complementary": ["ndf"] + } + ], + "recommendations": [ + { + "plugins": ["ndf", "affaan-m"], + "description": "NDFプラグインとaffaan-mプラグインの併用で、MCP統合、ワークフロー、コンテキスト管理、品質保証の完全な開発環境を構築できます。" + } + ] +} +``` + +### 修正の優先順位 + +| 優先度 | 修正内容 | 影響範囲 | 工数 | +|-------|---------|---------|------| +| **高** | README.md更新 | ユーザー向けドキュメント | 小 | +| **高** | CLAUDE.ndf.md更新 | AI向けガイドライン | 中 | +| **中** | directorエージェント更新 | タスク実行フロー | 中 | +| **中** | corderエージェント更新 | コーディングワークフロー | 小 | +| **低** | CLAUDE.md更新 | 開発者向けガイド | 小 | +| **低** | marketplace.json更新 | マーケットプレイス連携 | 小 | + +### 実装タイミング + +- **v2.2.1(パッチ版)**: ドキュメント更新のみ + - README.md + - CLAUDE.ndf.md + - CLAUDE.md + +- **v2.3.0(マイナー版)**: エージェント更新 + - directorエージェント + - corderエージェント + +- **v2.3.1(パッチ版)**: マーケットプレイス連携 + - marketplace.json + +--- + +## 📚 参考リンク + +- [everything-claude-code](https://github.com/affaan-m/everything-claude-code) +- [Claude Code 実践ガイド (Qiita)](https://qiita.com/dai_chi/items/c19be47044d062d59ee8) +- [Claude Code 実装パターン (Zenn)](https://zenn.dev/ttks/articles/a54c7520f827be) +- [Claude Code 公式ドキュメント](https://docs.claude.com/en/docs/claude-code) + +--- + +## 📝 まとめ + +### affaan-mプラグインの目的 + +**NDFプラグインとの併用を前提**として、以下の補完的な機能を提供: + +1. **コンテキスト管理** - MCPツール過剰によるコンテキスト枯渇を防止 +2. **品質保証機能** - Hooksによる自動チェックと人的ミス防止 +3. **TDDワークフロー** - テストファーストな開発文化の確立 +4. **セキュリティチェック** - OWASP Top 10に準拠した脆弱性検出 +5. **開発効率化** - パッケージマネージャー自動検出等 + +### NDFプラグインとの役割分担 + +| プラグイン | 役割 | +|-----------|------| +| **NDFプラグイン** | MCP統合、ワークフロー、専門エージェント | +| **affaan-mプラグイン** | コンテキスト管理、品質保証、TDDワークフロー | + +### 実装ロードマップ + +#### Phase 1: affaan-mプラグイン作成(v1.0.0) + +**実装内容**: +- コンテキスト管理(`/context-status`、自動コンパクト化) +- 基本Hooks(auto-format, secret-scan, console.log検出) +- TDDワークフロー(`/tdd`関連コマンド、TDDスキル) +- パッケージマネージャー自動検出 +- ドキュメント整備 + +#### Phase 2: NDFプラグイン更新(v2.2.1 〜 v2.3.0) + +**v2.2.1(パッチ版)- ドキュメント更新**: +- README.md: affaan-m併用の推奨 +- CLAUDE.ndf.md: 併用時のガイドライン +- CLAUDE.md: コンテキスト管理ベストプラクティス + +**v2.3.0(マイナー版)- エージェント更新**: +- directorエージェント: affaan-m連携ロジック追加 +- corderエージェント: TDDワークフロー連携 + +**v2.3.1(パッチ版)- マーケットプレイス連携**: +- marketplace.json: 推奨プラグイン情報追加 + +### 期待される効果 + +**併用による相乗効果**: +- NDFプラグイン(MCP統合 + 専門エージェント) +- affaan-mプラグイン(コンテキスト管理 + 品質保証) +- = **本番環境対応の開発支援環境** + +これらを段階的に導入することで、Anthropicハッカソン優勝者(Affaan Mustafa氏)が実証した本番環境レベルの開発支援環境を構築できます。 diff --git "a/issues/old/\343\203\227\343\203\254\343\202\274\343\203\263\346\247\213\346\210\220\346\241\210.md" "b/issues/old/\343\203\227\343\203\254\343\202\274\343\203\263\346\247\213\346\210\220\346\241\210.md" new file mode 100644 index 00000000..bc4257bf --- /dev/null +++ "b/issues/old/\343\203\227\343\203\254\343\202\274\343\203\263\346\247\213\346\210\220\346\241\210.md" @@ -0,0 +1,307 @@ +# 2026年度 開発指針プレゼンテーション構成案 + +## スライド1: タイトルページ +**2026年度 開発指針** +~コストセンターからベネフィットセンターへ、そしてエンジニア駆動の未来へ~ + +--- + +## スライド2: 目次 +1. 現状認識と課題 +2. 2026年度の3つの重点項目 +3. ナイル全社のAI効率化 +4. BPO(ビジネスプロセスアウトソーシング)への挑戦 +5. エンジニア駆動開発への転換 +6. 組織体制の改革:SQローテーションの試み +7. 開発環境の整備 +8. 全社協力体制の構築 +9. ロードマップとKPI +10. まとめ + +--- + +## スライド3: 現状認識と課題 +### これまでの3年間 +- **かるもーん開発への集中** + - 単一プロダクトへのリソース投下 + - 他システム・部署の置き去り + +### 現在の課題 +- コストセンターとしての位置づけ +- 属人化・知識の偏在 +- 運用負荷の特定メンバーへの集中 +- ドキュメント不足 +- 現場ニーズの吸い上げ不足 + +--- + +## スライド4: 2026年度の3つの重点項目 +### 1. ナイル全社のAI効率化 +開発・運用の両面でAIエージェントを全面導入 + +### 2. BPOへの挑戦 +コストセンターからベネフィットセンターへの転換 + +### 3. エンジニア駆動開発 +ヒアリングから自己洞察へ、現場実践を通じた価値創造 + +--- + +## スライド5: ナイル全社のAI効率化 +### 開発におけるAI活用 +- **AIエージェントの全員導入** + - Claude Code、GitHub Copilotなどの積極活用 + - Vibe Coding実践による生産性向上 + +### 運用におけるAI活用 +- **輪番制と組み合わせた運用改善** + - AI支援による属人化解消 + - ドキュメント自動生成・更新 + +- **現場実践を通した業務効率化** + - AIを活用した隙間時間でのドキュメント整備 + - 自律的な課題発見と起票 + +--- + +## スライド6: BPOへの挑戦(1/2) +### コストセンターからベネフィットセンターへ + +**ビジョン** +内部の効率化を外部サービスとして提供し、収益源へ転換 + +### 3つのBPO事業領域 +1. **自動車産業DXサービスBPO** + - 業界知見を活かしたDX支援 + +2. **AI開発支援サービスBPO** + - AI導入・活用のコンサルティング + +3. **Nyle X Partners(ナイル イクスパートナーズ)支援** + - エンジニアリング支援・コーチング + - 人材調達支援 + +--- + +## スライド7: BPOへの挑戦(2/2) +### 成功のための前提条件 +- 社内での実績・ノウハウ蓄積 +- AIエージェント活用のベストプラクティス確立 +- 標準化されたプロセスと運用体制 + +### 期待される効果 +- 新たな収益源の創出 +- エンジニアのキャリアパス拡大 +- 社外への技術プレゼンス向上 +- 採用ブランディング強化 + +--- + +## スライド8: エンジニア駆動開発への転換 +### 従来の開発スタイルからの脱却 + +**Before(ヒアリング型)** +- 「どうすれば良い?」「どれが良い?」 +- 要件を聞いて実装する受動的な姿勢 + +**After(提案型・実践型)** +- 「これが良い!」 +- エンジニアが現場を理解し、能動的に提案 + +### 現場実践制度の構築 +- **エンジニアが現場業務を実践** + - 実際の業務フローを体験 + - ペインポイントの直接把握 + +- **置き土産の重視** + - 「エンジニアが来てくれてよかった」 + - 現場に負担をかけず、価値を残す + +### 目指す姿 +- かゆいところに手が届くサービス +- 手を抜かない実装 +- ユーザーに本当に使われるプロダクト + +--- + +## スライド9: 組織体制の改革:SQローテーションの試み(1/2) +### スクワッド制の継続・強化 + +**基本方針** +- 少人数チーム編成(理想は3人、最大5人) +- スクワッド単位でのミッション明示 +- 特定事業領域のエキスパート化 + +**属人化の排除** +- スクワッド内で「知らない」をなくす +- 個人への負担集中を防ぐ仕組み + +--- + +## スライド10: 組織体制の改革:SQローテーションの試み(2/2) +### ミッション外運用の輪番制導入 + +**狙い** +- どのスクワッドでも運用対応できる体制 +- 知識の水平展開 +- 特定メンバーへの負荷集中回避 + +**対象業務** +- 作業依頼対応 +- 不具合一次対応 +- 緊急対応 +- 自律的な課題発見と起票 + +### 段階的なローテーション展開 +1. **基盤構築フェーズ**:輪番制で安定化 +2. **拡大フェーズ**:ミッション交換・ローテーション +3. **協力フェーズ**:事業部外協力の企画 + +--- + +## スライド11: 単一プロダクトから全体最適へ +### これまでの3年間 +- かるもーん開発への集中投資 +- 他システム・部署のメンテナンス後回し + +### 2026年度の方針 +**「置き去りにされた」部署への集中対応** +- 優先順位の再評価 +- リソース配分の最適化 +- 技術的負債の計画的解消 + +### 期待される効果 +- 全社的な開発効率向上 +- 部署間の不公平感解消 +- システム全体の品質底上げ + +--- + +## スライド12: 開発環境の整備 +### ドキュメント整備 +- **AIを活用した執筆** + - 隙間時間での更新 + - 継続的なメンテナンス + - 全システムで80%以上のカバレッジ目標 + +### 技術的負債への取り組み +- 計画的なリファクタリング +- テストカバレッジの向上 +- CI/CDパイプラインの最適化 + +--- + +## スライド13: laravel-adminとの向き合い方(1/2) +### 現状認識 +**laravel-adminは逃げられない現実** +- 多くの管理画面で採用 +- 置き換えには膨大なコストと時間 +- メンテナンスが停滞気味 + +### 発想の転換 +**「逃げる」から「向き合う」へ** +- いっそメンテナーを目指す +- コミュニティへの貢献を通じた技術力向上 +- 社内ノウハウの蓄積と展開 + +--- + +## スライド14: laravel-adminとの向き合い方(2/2) +### 段階的なロードマップ + +**短期(~1年目:2026年度)** +- **volareinc/laravel-adminとしてfork** + - 社内カスタマイズの集約 + - バグフィックス・機能改善 + - ドキュメント整備 + +**中期(1~2年目:2027-2028年度)** +- **公式メンテナーを目指す** + - プルリクエストの積極的投稿 + - イシュー対応への参加 + - コミュニティでの存在感確立 + +**長期(2年後~:2029年度~)** +- **React SPAへの旅立ち** + - 段階的な移行計画 + - モダンな技術スタックへの刷新 + - laravel-adminで培った知見の活用 + +### 期待される効果 +- 技術的負債の計画的解消 +- エンジニアのOSS貢献実績 +- 採用ブランディング向上 +- コミュニティからの評価獲得 + +--- + +## スライド15: ナイルエンジニア全社協力体制 +### 横断PJの結成 +**対象組織** +- DXM(デジタルトランスフォーメーション) +- MS(マーケティングソリューション) +- MDXマーケティング +- パティオ + +### 活動内容 +- **AIエージェントを活用したVibe Coding実践** +- **工数配分**:SQ業務 80% + 横断PJ 20% + +### 知識共有の拡大 +**勉強会・技術ブログ** +- これまで:MDXエンジニア主体 +- これから:事業部・職種を問わず全社開放 +- 誰でも参加・発信できる環境 + +--- + +## スライド16: ロードマップとKPI +### 2026年度ロードマップ + +**Q1(4-6月)** +- AI効率化:全エンジニアへのAIツール導入完了 +- 組織改革:輪番制スタート、スクワッド再編 +- 現場実践:パイロットプログラム開始 + +**Q2(7-9月)** +- BPO準備:社内実績の蓄積、サービス設計 +- 全社協力:横断PJ本格始動 +- 開発環境:laravel-admin fork・メンテナンス開始 + +**Q3(10-12月)** +- BPO試験運用:Nyle X Partners支援開始 +- 全体最適:置き去り部署への集中対応開始 + +**Q4(1-3月)** +- BPO本格展開:外部サービス提供開始検討 +- 振り返りと2027年度計画 + +### 主要KPI +- AI活用率:100%(全エンジニア) +- 運用対応の平準化:特定メンバー負荷50%削減 +- BPO案件:3件以上の実績創出 +- 現場実践:全スクワッドで1回以上実施 +- 横断PJ工数:20%達成 +- ドキュメントカバレッジ:主要システム80%以上 + +--- + +## スライド17: まとめ +### 2026年度開発指針の核心 + +**変革の3本柱** +1. **AI効率化** - 全員がAIを使いこなす組織へ +2. **BPO挑戦** - 収益を生み出すエンジニアリング組織へ +3. **エンジニア駆動** - 現場を知り、価値を創るエンジニアへ + +### 実現のために +- スクワッド制の強化と輪番制の導入 +- 全社協力体制の構築 +- 継続的な学習と知識共有 + +### ゴール +**「技術で価値を創造し、収益を生み出す、エンジニアが主役の組織」** + +--- + +*以上、17ページの構成案(laravel-admin関連を2ページに拡充)* diff --git "a/issues/old/\351\226\213\347\231\272\346\214\207\351\207\235.md" "b/issues/old/\351\226\213\347\231\272\346\214\207\351\207\235.md" new file mode 100644 index 00000000..765c5b87 --- /dev/null +++ "b/issues/old/\351\226\213\347\231\272\346\214\207\351\207\235.md" @@ -0,0 +1,69 @@ +# 開発指針策定PJ + +## 開発指針の想定読者 + +- 経営層 +- エンジニア +- MDXの他ユニットのマネージャークラス +- 他事業部のマネージャークラス + +## 開発指針に書くべきこと + +- ナイル全社のAI効率化 + - 開発におけるAIエージェントの全員導入 + - 運用におけるAIを活用した効率化 + - 輪番制(後述)に組み込んだ運用改善 + - 現場実践(後述)を通した業務効率化 +- BPOビジネスプロセスアウトソーシング)への挑戦 + - コストセンターからベネフィットセンターへ + - 自動車産業DXサービスBPO + - AI開発支援サービスBPO + - **Nyle X Partners(ナイル イクスパートナーズ)**に対するエンジニアリング支援・コーチング、人材調達支援 +- SQローテーションの試み + - スクワッド制は継続・強化 + - スクワッド単位でのミッションの明示 + - 原則あるスクワッドはあるミッションに専念する + - スクワッド単位で特定事業領域に対するエキスパートとなる + - 少人数チーム編成(理想は3人。多くても5人まで) + - 属人化の排除 + - スクワッド内での「このタスクは知らない」をなくす + - 個人に負担が集中しない仕組みづくり + - ミッション外運用の輪番制導入 + - 運用(作業依頼対応、不具合一次対応、緊急対応、自律的な課題発見と起票)はどのスクワッドでもできるように + - これらの安定基盤を築いたうえで、知識範囲を拡大し幅広い視点を養うためのミッション交換、ローテーション、事業部外協力などを企画する +- 単一プロダクト開発(かるもーん)から全体最適へ + - 3年間集中していた「かるもーん」 + - 「置き去りにされた」部署への集中対応 +- 開発環境の整備 + - AIによるドキュメント整備 + - 隙間時間を活用したドキュメントの更新(AIを利用した執筆) + - laravel-adminからは逃げられない + - いっそメンテナーを目指そう + - まずはforkしてvolareinc/laravel-adminのメンテナンスへ + - 中期的には公式メンテナーへ + - 長期的にはReact SPAへの旅立ち(2年後をめどに) +- エンジニア駆動開発 + - ヒアリングから自己洞察へ + - エンジニアが現場に入り込んで実践する制度の構築(現場実践) + - もちろん現場に過度な負担をかけないように + - 「エンジニアが現場に入ってくれてよかった」的な置き土産付き + - 「どうすれば良い?」「どれが良い?」から「これが良い!」へ + - エンジニアが提案するサービスこそが一番使いやすい。はず。 + - かゆいところに手が届く。手を抜かないサービスの実装 + - れいやーえっくすにつづけみたいななにか + - 提案できる知識と経験の習得を前提とする +- ナイルエンジニア全社協力体制 + - SQとは別の横断PJ + - 特にDXM、MSへの技術協力のためのPJ結成 + - MDXマーケ、パティオなども対象とする + - AIエージェントを全面活用したVibe Codingの実践 + - SQとPJの工数配分(20%前後) + - 勉強会、技術ブログの拡充 + - MDXエンジニア主体で運営していた勉強会・ブログ + - 事業部・職種にこだわらず解放。誰でも参加へ + +## このうち、重点項目はどれ? + +- ナイル全社のAI効率化 +- BPO挑戦 +- エンジニア駆動開発 diff --git a/issues/template09.md b/issues/template09.md new file mode 100644 index 00000000..73067ffa --- /dev/null +++ b/issues/template09.md @@ -0,0 +1,77 @@ +## 🔧 PRレビュー指摘対応 (dd08b91) + +### 対応した指摘 + +| # | 優先度 | ファイル | 指摘内容 | 対応 | +|---|--------|---------|----------|------| +| 1 | **P1** | `lease_screening_service.py` | CICスコア取得が日本語キーに対応していない | ✅ 修正済み | +| 2 | **P1** | `inquiry.py` | 同様にCICスコア取得が日本語キーに対応していない | ✅ 修正済み | +| 3 | **P1** | `docs/lease-screening-*.md` | final_categoryラベルがコードと不一致 | ✅ 修正済み | +| 4 | **P2** | `docs/lease-screening-*.md` | TIER_VERSIONがコードと不一致 | ✅ 修正済み | + +--- + +### 修正1・2: CICスコア取得キー修正 + +**問題**: `translated_data`(日本語キー変換後)から`creditGuidance`を参照していたため、CICスコアが常に`None`になっていた + +**修正**: 翻訳後のキー名を使用 + +```python +# Before +credit_answer = cic_result.get("信用照会回答エリア", {}) +cic_score_str = credit_answer.get("creditGuidance") + +# After +credit_answer = cic_result.get("信用照会回答エリア", {}) +cg_area = credit_answer.get("クレジットガイダンスエリア", {}) +cic_score_str = cg_area.get("指数") +``` + +**翻訳マップ(jp.yml)確認結果:** +- `creditGuidance` → `クレジットガイダンス` +- `creditGuidanceArea` → `クレジットガイダンスエリア` +- `cgResult` → `指数` + +--- + +### 修正3: final_categoryラベル名更新 + +ドキュメントをコード実装に合わせて更新: + +| 旧ドキュメント記載 | 新ドキュメント記載(コードと一致) | +|-------------------|----------------------------------| +| `自社審査_否決_パティオ通販` | `自社審査_否決_ナーチャ連携` | +| `自社審査_否決_買取` | `自社審査_否決_ナーチャ連携_買取案内` | +| `自社審査_完全否決` | `自社審査_否決_完全否決` | + +--- + +### 修正4: tier_version更新 + +| 項目 | 旧値 | 新値 | +|------|------|------| +| `tier_version` | 20260201 | 20260205 | + +criteria37-2.sql対応でバージョンが更新されていたため、ドキュメントも同期。 + +--- + +### テスト結果 + +``` +============================================================ +TierCalculator 単体テスト (criteria37-2.sql対応) +============================================================ +全テスト完了! +============================================================ +``` + +### 修正ファイル + +- `cic-proxy/lib/lease_screening_service.py` - CICスコア取得キー修正 +- `cic-proxy/lib/inquiry.py` - バッチTier判定のCICスコア取得修正 +- `docs/lease-screening-api.md` - final_category・tier_version更新 +- `docs/lease-screening-changelog.md` - 変更履歴追記・ラベル名更新 + +**PR URL**: https://github.com/takemi-ohama/ai-plugins/pull/13 diff --git a/plugins/affaan-m/README.md b/plugins/affaan-m/README.md index 38844c32..b82ef08d 100644 --- a/plugins/affaan-m/README.md +++ b/plugins/affaan-m/README.md @@ -307,4 +307,4 @@ MIT License ## サポート -Issue報告: https://github.com/devbasex/ai-plugins/issues +Issue報告: https://github.com/takemi-ohama/ai-plugins/issues diff --git a/plugins/mcp-playwright/.claude-plugin/plugin.json b/plugins/mcp-playwright/.claude-plugin/plugin.json index cca6d671..91b2af01 100644 --- a/plugins/mcp-playwright/.claude-plugin/plugin.json +++ b/plugins/mcp-playwright/.claude-plugin/plugin.json @@ -4,7 +4,7 @@ "description": "Playwright MCPを自動セットアップするプラグイン。Chromiumブラウザの自動インストールとMCP設定を提供します。", "author": { "name": "NDF Team", - "url": "https://github.com/devbasex/ai-plugins" + "url": "https://github.com/takemi-ohama/ai-plugins" }, "keywords": ["mcp", "playwright", "browser", "automation", "testing"] } diff --git a/plugins/mcp-playwright/README.md b/plugins/mcp-playwright/README.md index 8e9ef19d..11a6a481 100644 --- a/plugins/mcp-playwright/README.md +++ b/plugins/mcp-playwright/README.md @@ -157,4 +157,4 @@ MIT License ## サポート 問題が発生した場合は、GitHubリポジトリでissueを作成してください: -https://github.com/devbasex/ai-plugins/issues +https://github.com/takemi-ohama/ai-plugins/issues diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index 27ae8c4c..afac962b 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,19 +1,31 @@ { "name": "ndf", - "version": "3.1.0", - "description": "Integrated plugin with specialized agents, workflow skills, and Codex MCP. Serena MCP is now a separate plugin (mcp-serena).", + "version": "4.7.1", + "description": "Integrated plugin with 8 specialized agents (model-tiered: opus/sonnet/haiku), 39 skills including official mcp-builder, on-demand loader for Anthropic official skills, generic workflow/principle skills, skill usage statistics, pytest-playwright based scenario E2E testing (v0.5.0: BREAKING — package renamed scenario_test → playwright_kit, fixtures/CLI ndf_*/--ndf-* → pwk_*/--pwk-*, all-in-one runtime layout enabling Skill-independent operation via init_project.sh + run.sh, accessibility/web vitals autouse, overlay (formerly HUD), report.md, Drive integration, body_check autouse enabled by default to detect server-rendered PHP/SSR errors leaked into HTML), Google Drive/Chat integration, and Codex CLI integration via /ndf:codex skill. Transcript retention is automatically kept at >= 90 days. BREAKING (v4.0.0): Codex MCP server is removed (use /ndf:codex skill); legacy CLAUDE.ndf.md detection hook and /ndf:cleanup skill are removed (obsolete since v3.0.0). Serena MCP is a separate plugin (mcp-serena).", "author": { "name": "takemi-ohama", "url": "https://github.com/takemi-ohama" }, - "keywords": ["mcp", "codex", "workflow", "slack", "notifications", "agents", "skills", "director", "orchestrator"], + "keywords": [ + "mcp", + "codex", + "workflow", + "slack", + "notifications", + "agents", + "skills", + "director", + "orchestrator" + ], "agents": [ "./agents/director.md", "./agents/data-analyst.md", "./agents/corder.md", "./agents/researcher.md", - "./agents/scanner.md", - "./agents/qa.md" + "./agents/qa.md", + "./agents/debugger.md", + "./agents/devops-engineer.md", + "./agents/code-reviewer.md" ], "skills": [ "./skills/pr", @@ -22,23 +34,38 @@ "./skills/review", "./skills/merged", "./skills/clean", - "./skills/cleanup", "./skills/ndf-policies", "./skills/data-analyst-sql-optimization", "./skills/data-analyst-export", - "./skills/corder-code-templates", - "./skills/corder-test-generation", - "./skills/researcher-report-templates", - "./skills/scanner-pdf-analysis", - "./skills/scanner-excel-extraction", "./skills/qa-security-scan", "./skills/markdown-writing", "./skills/python-execution", "./skills/docker-container-access", - "./skills/skill-development", "./skills/deepwiki-transfer", "./skills/knowledge-reorg", "./skills/git-gh-operations", - "./skills/google-auth" + "./skills/google-auth", + "./skills/mcp-builder", + "./skills/official-skills-autoloader", + "./skills/branch-fix-strategy", + "./skills/implementation-plan", + "./skills/investigation-rules", + "./skills/problem-solving", + "./skills/logging-guidelines", + "./skills/sync-main", + "./skills/cherry-pick-pr", + "./skills/deploy", + "./skills/review-branch", + "./skills/review-pr-comments", + "./skills/resolve-pr-comments", + "./skills/browser-test", + "./skills/codex", + "./skills/skill-stats", + "./skills/playwright-scenario-test", + "./skills/google-drive", + "./skills/google-chat", + "./skills/gemini", + "./skills/cross-review", + "./skills/issue-plan-strategy" ] } diff --git a/plugins/ndf/.mcp.json b/plugins/ndf/.mcp.json deleted file mode 100644 index 18967988..00000000 --- a/plugins/ndf/.mcp.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "mcpServers": { - "codex": { - "type": "stdio", - "command": "codex", - "args": [ - "mcp-server" - ], - "env": {}, - "envFile": "${workspaceFolder}/.env" - } - } -} diff --git a/plugins/ndf/AGENTS.md b/plugins/ndf/AGENTS.md new file mode 100644 index 00000000..37153c59 --- /dev/null +++ b/plugins/ndf/AGENTS.md @@ -0,0 +1,129 @@ +# NDF Plugin - 開発者向けガイドライン + +## 概要 + +**NDFプラグインの開発・メンテナンス**を行うAIエージェント向けガイドライン。 + +## プラグイン情報 + +- **名前**: ndf +- **現在バージョン**: 4.4.0 +- **種類**: 統合プラグイン(Skills + Agents + Hooks / v4.0.0 で Codex MCP 廃止) +- **リポジトリ**: https://github.com/takemi-ohama/ai-plugins + +> **Note (v3.0.0)**: Serena MCPは`mcp-serena`プラグインに分離。memory系スキルは廃止。CLAUDE.ndf.md注入は廃止。 + +## 開発ルール + +- ドキュメント・コミットメッセージ・PR説明は**日本語** +- **mainブランチへの直接コミット禁止**(featureブランチ+PR) +- **セマンティックバージョニング**: MAJOR(破壊的変更)、MINOR(新機能)、PATCH(バグ修正) + +## ディレクトリ構造 + +``` +plugins/ndf/ +├── .claude-plugin/ +│ └── plugin.json # プラグインメタデータ +├── .mcp.json # MCPサーバー定義(Codex CLI) +├── hooks/ +│ └── hooks.json # プロジェクトフック定義 +├── scripts/ +│ └── slack-notify.js # Slack通知スクリプト +├── agents/ # サブエージェント(8個、モデル階層化) +│ ├── director.md # opus: 計画・統括 +│ ├── corder.md # sonnet: Codex第二意見レビュー +│ ├── data-analyst.md # sonnet: BigQuery/SQL +│ ├── researcher.md # sonnet: AWS Docs/Chrome DevTools +│ ├── qa.md # sonnet: セキュリティ/品質 +│ ├── debugger.md # sonnet: 根本原因分析 +│ ├── devops-engineer.md # sonnet: Docker/CI/K8s +│ └── code-reviewer.md # sonnet: diff/PRレビュー +├── skills/ # スキル(39個) +│ # PRワークフロー系 +│ ├── pr/ # commit+push+PR作成/更新 +│ ├── pr-tests/ # Test Plan自動実行 +│ ├── fix/ # PRコメント修正対応 +│ ├── review/ # PR単位レビュー(Approve/RC判定) +│ ├── review-branch/ # ローカル差分レビュー(PR前) +│ ├── review-pr-comments/ # PRコメント分類(READ-ONLY) +│ ├── resolve-pr-comments/ # 対応済みコメント返信+Resolve +│ ├── cherry-pick-pr/ # 環境ブランチへのcherry-pick PR +│ ├── deploy/ # 環境ブランチへのデプロイPR +│ ├── sync-main/ # main取り込み +│ ├── merged/ # マージ後クリーンアップ +│ ├── clean/ # マージ済みブランチ一括削除 +│ # 原則・ガイドライン系 +│ ├── ndf-policies/ # ポリシー常時注入 +│ ├── branch-fix-strategy/ # ブランチ修正適用戦略 +│ ├── issue-plan-strategy/ # issue→plan→multi-PR ワークフロー (release branch + draft PR + worktree) +│ ├── implementation-plan/ # 実装プラン管理(issues/) +│ ├── investigation-rules/ # 調査時のエビデンス主義 +│ ├── problem-solving/ # 根本原因分析・多層防御 +│ ├── logging-guidelines/ # ログ運用ガイドライン(言語非依存) +│ # データ分析・品質 +│ ├── data-analyst-sql-optimization/ +│ ├── data-analyst-export/ +│ ├── qa-security-scan/ +│ # ドキュメント・環境 +│ ├── markdown-writing/ +│ ├── python-execution/ +│ ├── docker-container-access/ +│ ├── deepwiki-transfer/ +│ ├── knowledge-reorg/ +│ ├── git-gh-operations/ +│ ├── google-auth/ +│ ├── browser-test/ # ブラウザ動作確認(Playwright/Chrome DevTools) +│ ├── codex/ # Codex CLI直接実行(MCP版との使い分け) +│ ├── playwright-scenario-test/ # Playwright+curl Web シナリオE2E並列ランナー +│ ├── google-drive/ # Google Drive エクスポート/DL/UP(google-auth依存) +│ ├── google-chat/ # Google Chat メッセージ取得(google-auth依存) +│ # Anthropic公式連携 +│ ├── mcp-builder/ # Anthropic公式(Apache-2.0) +│ └── official-skills-autoloader/ # 公式Skill自動ロード +├── AGENTS.md # このファイル(開発者向け) +└── README.md # プラグイン説明書 +``` + +## 一般的な開発タスク + +### 新しいスキルの追加 + +1. `skills/{skill-name}/SKILL.md` を作成(YAMLフロントマター必須) +2. `plugin.json` の `skills` 配列に `"./skills/{skill-name}"` を追加 +3. `plugin.json` のバージョンをMINOR上げ +4. テスト・コミット + +### 新しいサブエージェントの追加 + +1. `agents/{agent-name}.md` を作成(YAMLフロントマター必須) +2. `plugin.json` の `agents` 配列に追加 +3. バージョンMINOR上げ → テスト・コミット + +### MCPサーバーの追加・更新 + +1. `.mcp.json` の `mcpServers` に追加 +2. README.mdに説明追加 +3. バージョン更新 → テスト・コミット + +## 検証チェックリスト + +- [ ] plugin.jsonが有効なJSON +- [ ] バージョン番号が適切にインクリメント +- [ ] すべてのスキル/エージェントファイルが存在 +- [ ] YAMLフロントマターが正しい +- [ ] .mcp.jsonが有効なJSON +- [ ] README.md が最新 + +## トラブルシューティング + +| 問題 | 対処 | +|------|------| +| エージェントが認識されない | plugin.jsonのagents配列、ファイルパス、YAMLフロントマターを確認 | +| スキルが表示されない | plugin.jsonのskills配列、SKILL.mdのフロントマターを確認、`/plugin reload ndf` | +| MCPサーバーが起動しない | .mcp.jsonの構文、コマンドパス、環境変数を確認 | +| フックが動作しない | hooks.jsonの構文、スクリプト実行権限を確認 | + +## 開発履歴 + +バージョン履歴は [CHANGELOG.md](CHANGELOG.md) を参照。 diff --git a/plugins/ndf/CHANGELOG.md b/plugins/ndf/CHANGELOG.md new file mode 100644 index 00000000..6a6212a3 --- /dev/null +++ b/plugins/ndf/CHANGELOG.md @@ -0,0 +1,593 @@ +# NDF Plugin CHANGELOG + +### v4.7.0 (fix / cross-review: 修正ポリシー刷新 + CI 完了待ち廃止) + +`/ndf:fix` と `/ndf:cross-review` の修正方針を見直し、PR の最終的なコード品質を +上げる方向にシフトする MINOR リリース。`/ndf:fix` の自動実行範囲が広がるため、 +利用側スクリプトが `--severity-min nit` 相当の挙動を前提にしている場合は要確認。 + +- **修正対象の拡張** (`skills/fix/SKILL.md`): + - minor / nit のうち **パフォーマンス・可読性・重複コード排除** に該当する指摘は + このPR内で対応する (旧: nit は基本 deferred)。 + - 特にトータルのコード行数が減る方向の修正 (重複排除 / 不要分岐除去) は積極実施。 + - ただし修正範囲が **+30 行を超えそうな場合は deferred + ユーザ問い合わせ** + (スコープ膨張による副作用とレビュー負担を抑える)。 +- **重要度ラベルの独自再判定**: + - AI agent (CodeRabbit / Copilot / codex / gemini) が付けた `[critical/major/minor/nit]` + ラベルを鵜呑みにせず、コード本体を読んだ上でカテゴリ + (performance/readability/duplication/security/style/...) と合わせて再判定する。 + - 例: AI が `nit` と付けていても実体が重複排除なら修正対象。AI が `critical` と + 付けていても実害がないスタイル指摘なら deferred 化してよい。 +- **CI 完了待ちを廃止**: + - `/ndf:fix` 内の `gh pr checks --watch` および "PENDING を完了まで待つ" 手順を削除。 + - 各チェックポイントでは **その時点で FAILURE のジョブのみ** を修正対象に取り込み、 + 実行中チェックは無視して次ステップへ進む。 + - 戻り値 `ci_status` / `ci_failed_checks` は push 時点での既知失敗のみを反映する + (メイン context の節約と、長時間ブロック回避が目的)。 + - cross-review 側の judge ロジック (`state.py`) は変更不要 — `ci_status != FAILURE` + なら継続判定するため、PENDING も成功扱いになる。 +- **PR テスト範囲外の flaky テストも修正対象**: + - 放置するとリポジトリ全体の CI 信頼性が劣化し、後続 PR にも波及するため、 + `/ndf:fix` 実行時に見つけ次第このループで修正する。 +- **作業完了報告に PR URL 必須**: + - `/ndf:fix` の最終報告末尾に `https://github.com/<owner>/<repo>/pull/<番号>` を + 必ず記載する(メインからの追跡性向上)。 +- **`cross-review` 側の同期**: + - `skills/cross-review/docs/02-fix-and-rotation.md` のサブエージェント起動プロンプト + (ポリシーと「必須実行手順」)を上記方針に合わせて更新。 + - 手順 3 を「CI 状態スナップショット (完了待ちしない)」、手順 7 を + 「CI 再実行は待たない」に書き換え。SKILL.md の手順番号繰り上がり + (旧 8 → 新 7) も反映。 + +#### 既存ユーザへの影響 + +- `/ndf:fix` 単体実行: minor/nit カテゴリのうち performance/readability/duplication + に該当するものが自動修正されるため、これまで deferred だった指摘が修正コミットに + 入る場合がある。+30 行を超える場合はユーザ問い合わせで止まる。 +- `/ndf:cross-review` 自動ループ: CI 完了を待たなくなった分、各 round の所要時間が + 短縮される。一方で push 直後の CI 失敗は次 round の review 段階で再検出される。 +- `result.json` の `ci_status` が `PENDING` になる頻度が増える。下流で `ci_status` + を見ているスクリプトがある場合は確認推奨(state.py の judge は変更不要)。 + +### v4.6.2 (cross-review: state.py init の TMP_DIR 計算順序バグ修正 + AGENTS.md リネーム) + +`/ndf:cross-review` で gemini が **workspace 制約違反で payload を書けず +hard timeout (420s) で常時失敗** していた不具合を修正する PATCH リリース。 +合わせて `claude plugin validate` の警告 (plugin root の `CLAUDE.md` は +project context として読み込まれない) に従い `plugins/ndf/CLAUDE.md` を +`plugins/ndf/AGENTS.md` にリネーム。 + +- 修正 (`skills/cross-review/scripts/state.py` `cmd_init`): + - 旧実装は `_tmp_dir(args.worktree)` を `args.worktree=None` のまま呼び、 + `os.getcwd()` の basename (= 親リポジトリ名) で `~/.gemini/tmp/<repo>/` + を採用していた。一方、`launch-gemini.sh` は `cd "$WORKTREE"` してから + gemini を起動するため、gemini は `~/.gemini/tmp/<worktree-basename>` + (= `~/.gemini/tmp/pr<PR>`) しか workspace 内として許可せず、 + `write_file` が `Path not in workspace` で拒否されていた。 + - 修正後は `worktree = args.worktree or f"/work/worktrees/pr{pr}"` を + 先に解決してから `_tmp_dir(worktree)` に渡し、tmp_dir の basename を + worktree basename と一致させる。 + - 副次効果: `state_file` の path も `~/.gemini/tmp/pr<PR>/` 配下に揃うため、 + cross-review が PR ごとに完全に分離した tmp 空間で動く。 +- リネーム: `plugins/ndf/CLAUDE.md` → `plugins/ndf/AGENTS.md`。 + Claude Code は plugin root の `CLAUDE.md` を project context として + 読み込まないため、`claude plugin validate` が警告を出していた。 + リポジトリ root の `AGENTS.md` (本体) + `CLAUDE.md` (Claude 固有) と + 揃え、プラグイン側は `AGENTS.md` に一本化する。 + - `plugins/ndf/README.md` のリンクも更新。 + - `claude plugin validate` の警告 0 件を確認。 + +#### 既存ユーザへの影響 + +- 旧 tmp_dir (`~/.gemini/tmp/<repo>/`) に途中状態 (`state.json`) が残っている + 場合、v4.6.2 以降は `~/.gemini/tmp/pr<PR>/` を参照するため state を見失う。 + 対応: `mv ~/.gemini/tmp/<repo>/cross-review-pr<PR>-* ~/.gemini/tmp/pr<PR>/` + で移行するか、`/ndf:cross-review <PR>` を再 init する。 +- cross-review は 1 PR 単位の短命ステートのため、影響は実行中ループのみ。 + +### v4.6.1 (cross-review skill 主要処理のスクリプト化) + +`cross-review` skill の主要 bash 処理を `scripts/` 配下に外出し、SKILL.md / +docs/01,02 から冗長なインライン bash を排除する PATCH リリース。 +SKILL の I/O 契約 (state.json / result.json / payload.json スキーマ) は不変。 + +- 新規追加: + - `scripts/state.py` — state.json 操作 CLI (uv 自己完結 / stdlib のみ) + サブコマンド: `init` / `start-round` / `read-result` / `judge` / + `check-oscillation` / `merge-fix` / `should-rotate` / `set-current-pr` / + `report` + - `scripts/launch-codex.sh` / `scripts/launch-gemini.sh` — レビューランチャ + (pidfile + sentinel ベース、trusted directory 対策込み) + - `scripts/monitor.py` — codex/gemini プロセス多軸監視 CLI + (uv 自己完結 / stdlib のみ)。pidfile + `/proc` cmdline 検証 / codex sentinel / + 早期エラーパターン検出 / err.log stall timeout / hard timeout / result.json + 存在確認の 6 軸を並列スレッドで判定。exit code で失敗種別を区別 + (OK=0 / TIMEOUT=2 / NO_RESULT=3 / EARLY_ERROR=4 / STALLED=5 / PIDFILE_BAD=6)。 + sentinel 単独で完了判定する旧 `wait-review.sh` の取りこぼし + (codex クラッシュ時の無限ハング、gemini の untrusted directory 静かな失敗、 + pidfile stale 等) を解消。 + - `scripts/wait-review.sh` — `monitor.py` の薄ラッパ(旧 CLI 互換のため残置) + - `scripts/rotate-pr.sh` — PR ローテーション (squash + 新ブランチ + 新 PR) +- SKILL.md / docs/01,02 を「スクリプト呼び出し」形式に置換。state.json と + result.json のスキーマは docs に残し、実装は scripts/ にカプセル化。 + +PR #72 の実機テストで得た codex / gemini からの指摘および追加で見つかったバグの +対応(同 v4.6.1 内で実施): + +- **`monitor.py`**: + - cmdline 検証順序を「alive 確認後のみ」に変更。プロセスが既に死んでいる場合は + cmdline 不一致でも PIDFILE_BAD にならず、result.json の有無で OK 判定する + (旧実装は完了済 launcher を誤って失敗扱いしていた)。 + - EARLY_ERROR パターンを **行頭限定** + benign フィルタに改修。diff / doc 引用に + `401 Unauthorized` などのキーワードが含まれても誤検知しなくなった。 + - TIMEOUT / STALLED / EARLY_ERROR / PIDFILE_BAD で返るとき、対象プロセスに + SIGTERM (3 秒後に SIGKILL) を送信。残存プロセスが後から `gh api` 投稿や + result.json 書き込みを行ってメインと競合する問題を解消。 + - stall 判定を err.log のみから **err.log + stdout.log の合計サイズ** に拡張。 + - **デフォルト値変更**: hard timeout 30 分 → **7 分**、stall timeout 10 分 → **3 分**。 + - 未使用 import `field` を削除。 +- **`state.py`**: + - `gh api --paginate` の JSON ストリーミング処理を `--jq` ベースに変更 + (旧: `json.loads(r.stdout)` は複数ページで JSONDecodeError → 空配列 → + 既存コメントスナップショットが空になり重複指摘禁止が無効化されていた)。 + - `st["rounds"][-1]` への参照前に空チェックを追加し、初期化失敗時の + IndexError を防止 (read-result / judge / merge-fix の 3 箇所)。 + - 未使用 import `os` を削除。 +- **`launch-codex.sh` / `launch-gemini.sh` / `rotate-pr.sh`**: + - 引数を `STATE_PR` (= state.json の key, 初期 PR) に統一。レビュー対象の + 「現在の PR」は state.json の `current_pr` を内部で読む。 + 旧実装は PR rotation 後にメイン側で `PR=$NEW_PR` に切り替えると state.json + パスが見つからなくなる設計矛盾があった。 +- **tmp ディレクトリの gemini workspace 制約対応**: + - 全 scripts の tmp パスを `/tmp/` 直書きから `$CROSS_REVIEW_TMP_DIR` 経由に変更。 + 未設定なら `~/.gemini/tmp/<workspace-basename>/` を自動採用、最終フォールバックは `/tmp/`。 + gemini CLI は `--yolo --skip-trust` でも workspace 外の `read_file` / + `write_file` がブロックされる (`Path not in workspace`) ため、gemini 公式の + project temp directory に揃えることで result.json / payload.json の書き出しを + 成立させる。 + - 共通ヘルパ `scripts/_tmpdir.sh` を追加 (bash) / `state.py` と `monitor.py` に + `_tmp_dir()` 関数を追加 (Python)。`state.py init` は採用した `TMP_DIR` を + state.json に記録し、stdout の `TMP_DIR=` で呼び出し側に通知。 + - SKILL.md のテンプレートで `eval "$(state.py init ...)"` 後に + `export CROSS_REVIEW_TMP_DIR="$TMP_DIR"` を行い、後続スクリプトに env として + 伝播させる手順を追加。 +- **`SKILL.md` / `docs/01,02`**: + - bash テンプレートを `$STATE_PR` 固定で書き直し、rotation 後も同じ変数で + 全 scripts を呼ぶ手順に統一。 + - 新デフォルト (timeout=7 分 / stall=3 分) を反映。 + +### v4.6.0 (cross-review skill 改訂 + review/fix の result.json 拡張) + +実運用で得た失敗パターンの対策を `cross-review` skill に反映し、関連する +`/ndf:review` と `/ndf:fix` の result.json schema を拡張する MINOR リリース。 + +- **`cross-review` skill 改訂**: + - **「事前確認」セクション新設**: ループ開始前の 4 プリチェック + 1. 自分の PR 判定 → `event` ダウングレード設定 (GitHub の + `HTTP 422: Can not request changes on your own pull request` を回避) + 2. **worktree 分離** (`/work/worktrees/pr<PR>`) で並行セッション競合回避 + 3. **gemini の trusted directory 対策**: + `GEMINI_CLI_TRUST_WORKSPACE=true` + `--skip-trust` 両方併用必須 + (worktree のような新規パスは untrusted 判定 → YOLO が "default" に降格される) + 4. 既存コメントスナップショット保存 → launcher プロンプトに添付して重複指摘禁止 + - **state.json schema 拡張**: + - `worktree_path` / `pr_author` / `is_own_pr` / `event_downgrade` を追加 + - 各 round の `codex` / `gemini` を **`intent` + `posted_as` の二重保持** に変更 + (ループ収束判定は `intent`、GitHub 投稿実体は `posted_as`) + - 各 round に `by_severity` (`{critical, major, minor, nit}` 件数) を追加 + - 各 round の `fix` に `resolved_threads` / `ci_note` を追加 + - **レビュー body 必須 identifier prefix**: + `## 🤖 cross-review | round <N> | <agent> | <event(intent)>` を先頭固定化 + (人間アカウントから AI が投稿するため、GitHub UI で発信元を可視化) + - **CI failure の分類**: `pint/larastan/test/build/lint/type/tsc/mypy` 等は + code-fail として中断、`check_pr_requirements/assignees/reviewers/labels` + 等のメタチェックのみ失敗ならループ継続 + - **waiter を pidfile + sentinel ベースに**: + - codex は `^tokens used$` sentinel + - gemini は long `-p` プロンプトが引数に乗るため `pgrep -fa` 不可、 + `pidfile + kill -0` ポーリング必須 + - **`Step 5` サブエージェント責務を 5 点明示**: + 修正コミット / テスト / **reply + `resolveReviewThread` で Resolve** / + deferred は記録のみ・Resolve しない / 戻り値ファイル書き出し +- **`/ndf:review` SKILL.md 更新**: + - 結果サマリ (`/tmp/<agent>-review-pr<番号>-result.json`) に `posted_as` + フィールドを追加 + - `event` (intent / 本来の判定) と `posted_as` (実投稿) の使い分けを文書化 + - 自分 PR ダウングレードフローを `intent="REQUEST_CHANGES"` / + `posted_as="COMMENT"` で記録する手順を明記 +- **`/ndf:fix` SKILL.md 更新**: + - 戻り値ファイル `/tmp/fix-pr<番号>-result.json` に + `resolved_threads` (配列) / `ci_failed_checks` (配列) / `ci_note` (string) + を追加 + - 手順 12 に「resolve した thread_id / comment_id / path / line を + `resolved_threads[]` に記録」「`deferred` / `rejected` の thread は + Resolve しない」を明記 + - 手順 13 に `ci_failed_checks` の収集元 (`gh pr checks <PR> --json name,state`) + を補記 +- **アンチパターン 5 件追加**: + - 自分の PR に `REQUEST_CHANGES` で投稿 + - `gemini --yolo` 単独起動 (`--skip-trust` 併用必須) + - `pgrep -fa <prompt>` で完了判定 + - fix サブエージェントが Resolve をスキップ + - review body に identifier prefix を付け忘れる +- Skills: 39個 (変化なし、`cross-review` / `review` / `fix` の中身を更新) + +### v4.5.0 (playwright-scenario-test v0.5.0 — Skill 非依存 self-contained 構成 / 名前空間 rename) + +> **注意**: 互換性破壊リリース。Python パッケージ名・fixture 名・CLI option・ +> 環境変数・内部クラス名がすべて変わる。既存利用者は manual に書き換え必要。 +> 詳細は [PLAN19](../../issues/PLAN19.md) を参照。 + +- **`playwright-scenario-test` v0.5.0** (Skill 非依存化 + 名前空間整理): + - **目的**: 旧 v0.4.0 までは Skill ディレクトリで `uv sync` する必要があり、 + Skill が消えるとテストが動かない / CI 別マシンで再現性が低い問題があった。 + v0.5.0 では `scripts/init_project.sh` で **利用者プロジェクト直下に + `scenario-test/` (all-in-one ランタイム)** を埋め込み、Skill 非依存で動作させる。 + - **新規 init / launcher**: + - `scripts/init_project.sh` / `scripts/init_project.bat`: + `<PROJECT_ROOT>/<runtime-dir>/` に playwright_kit / scripts / uv.lock / + runtime templates を rsync ベースでコピーし、初回 uv sync + + playwright install chromium まで実行。`--runtime-dir <name>` で配置先名 + カスタマイズ可、`--dry-run` で予定差分のみ表示、`scenario.config.yaml` / + `tests/test_*.py` は既存があれば上書きしない (利用者編集物保護) + - `templates/run.sh` / `templates/run.bat`: + `$(dirname BASH_SOURCE)` / `%~dp0` で自身の位置を解決し CWD をランタイム + 内に固定。初回のみ `uv sync` + `playwright install chromium`。 + `--help` / `$@` で pytest 引数素通し + - `templates/pyproject.toml.runtime` / `templates/runtime-gitignore` / + `templates/runtime-README.md`: 利用者プロジェクト埋め込み用テンプレート + - **破壊的 rename** (Phase 0 / 0a): + - **Python パッケージ**: `scenario_test` → `playwright_kit` + - **pytest entry-point**: `ndf-scenario-test` → `playwright-kit` + - **fixture**: `ndf_config` → `pwk_config`, `ndf_role_<id>` → + `pwk_role_<id>`, `ndf_evidence` → `pwk_evidence`, + `ndf_a11y_scan` → `pwk_accessibility_scan`, + `ndf_cwv_measure` → `pwk_web_vitals_measure`, + `ndf_body_check_scan` → `pwk_body_check_scan`, + `ndf_out_dir` → `pwk_out_dir` + - **CLI option**: `--ndf-config` → `--pwk-config`, `--ndf-out-dir` → + `--pwk-out-dir`, `--ndf-no-evidence` → `--pwk-no-evidence`, + `--ndf-har-mode` → `--pwk-har-mode`, `--ndf-hud` → `--pwk-overlay`, + `--ndf-drive-folder` → `--pwk-drive-folder` + - **env var**: `NDF_CONFIG` → `PWK_CONFIG` + - **内部クラス**: `NdfTestEntry` → `PwkTestEntry`, `NdfEvidence` → `PwkEvidence` + - **モジュール rename** (Phase 0a, ドメイン用語の整理): + - `scenario_test/a11y.py` → `playwright_kit/accessibility.py` + (a11y は WCAG ドメイン用語のため平易な英語に) + - `scenario_test/cwv.py` → `playwright_kit/web_vitals.py` + (CWV → Core Web Vitals) + - `scenario_test/hud.py` → `playwright_kit/overlay.py` + (HUD = Heads-Up Display は造語的、overlay の方が直観的) + - **config schema**: `accessibility:` / `web_vitals:` キーに統一 + (旧 `a11y:` / `cwv:` は廃止) + - **EvidenceCollectors fields**: `cwv_metrics` → `web_vitals_metrics`, + `cwv_passed` → `web_vitals_passed` + - **保持するもの** (W3C / 業界標準): + - `LCP` / `CLS` / `TTFB` / `longest_task` / `HAR` / `axe-core` — + データフィールド名・外部仕様名としてそのまま使用 (各 docstring と + SKILL.md の用語集セクションで正式名称を併記) + - **SKILL.md / docs**: クイックスタートを `init_project.sh → run.sh` フローに + 全面書き換え、用語集セクションを SKILL.md 上部に新設、ディレクトリ図を + rename 後 + init 後構造に更新、開発者向け「Skill 単体で uv sync する旧運用」 + 節を別出し + - **検証**: + - 159 件 pure 関数テスト pass (config / fixtures / pytest plugin / report 全般) + - 擬似環境 (`/tmp/...`) に init 後、Skill ディレクトリを `mv` で隠した + 状態で `./scenario-test/run.sh --collect-only` が完走 (8 件 collect, exit 0) + - `--runtime-dir e2e` 配置で複数ランタイム共存 (`scenario-test/` + `e2e/`) + が独立に動作 + - 再 init で `tests/test_*.py` の利用者編集が保護される (skip) + - `--dry-run` で実際にはコピーされない +- Skills: 39個 (変化なし、playwright-scenario-test の中身が刷新) + +### v4.4.0 (issue-plan-strategy skill 追加) + +- **新規 Skill `issue-plan-strategy`**: + - 1 つの issue から plan を起こし、推奨 PR が複数に分かれる場合の標準ワークフローを規定 + - スラッシュコマンド (`/ndf:issue-plan-strategy <issue-path-or-url>`) でも、 + **(a) issue から plan 作成依頼** / **(b) 既存 plan の実装(実行)依頼** の両方で自動発動する + (description / Triggers に発動条件を明記) + - Step 0 で plan ファイル / release branch / Draft PR の有無を見て **作成フェーズ vs 実行フェーズ** + を切り分け、実行フェーズで入った場合は Step 1 をスキップして Step 3 以降の自動化に直行 + - フロー: issue 取得 → (作成) plan (必要なら plan モード) → 単一/複数 PR 判定 → + (実行) release branch + Draft release PR 先行作成 → 個別 PR ブランチ + Draft PR 先行作成 → + git worktree で並行開発 → 個別 PR レビュー (`/ndf:review` / `/ndf:cross-review`) → + release ブランチで結合テスト相当のレビューのみ → release → default merge + - 検証環境 (qa/staging) は個別 PR or release PR 単位で `/ndf:cherry-pick-pr` に委譲 + - 関連 skill (`implementation-plan` / `branch-fix-strategy` / `pr` / `cherry-pick-pr` / + `review` / `cross-review` / `playwright-scenario-test`) との責務分担を明記し、 + 本 skill は **multi-PR 運用のメタ手順**に専念 +- Skills: 38個 → **39個** + +### v4.3.1 (Fix: cross-review / gemini skill 登録漏れ修正) + +- PR #67 で追加された `cross-review` / `gemini` skill が `plugin.json` の + `skills` 配列に登録されておらず、`/ndf:cross-review` / `/ndf:gemini` を + slash command として呼び出せなかった問題を修正 +- 併せて description と CLAUDE.md / AGENTS.md / README.md の skill 数を + 36個 → 38個 に更新 +- Skills: 36個 → **38個** (実体は v4.3.0 時点で既に存在、登録のみ追加) + +### v4.3.0 (playwright-scenario-test v0.4.0 — body_check 復活) + +- **`playwright-scenario-test` v0.4.0**: 旧 v0.2.x の自前 YAML runner にあった + **`body_check`** 機能 (PHP / SSR がフロントに漏れさせる ``Fatal error`` / + ``Uncaught`` / ``STRICT:`` / ``Warning:`` / ``Notice:`` / ``File not found`` + 等を **テスト失敗として検出**) を **default 有効** で復活させた。 + まだ開発・検証段階の skill のため互換性は重視せず、config 無記述でも + PHP プロジェクトで素直に効くよう default で `enabled=True` + PHP 系 + パターンを内蔵する。詳細は [PLAN18](../../issues/PLAN18.md) を参照。 + - **新モジュール**: + - `scenario_test/body_check.py` — 純粋関数 `scan_body` / `is_html_response` + と `BodyViolation` dataclass。substring match で fatal / warning / + not_found カテゴリの violation を返す + - `scenario_test/fixtures/body_check.py` — autouse fixture + (`_ndf_body_check_autouse`) と明示呼び出し用 helper (`ndf_body_check_scan`)。 + `page.on("response", ...)` で全 HTML レスポンスを監視し、teardown で + `case_dir/body_check.jsonl` に書き出してから violation 数次第で + `pytest.fail` + - **新 config schema** (`scenario.config.yaml`, 省略可): + ```yaml + body_check: + enabled: true # default: true (機能無効化したい場合のみ false) + fatal_patterns: ["Fatal error", "Uncaught", "Parse error"] + warning_patterns: ["STRICT:", "Warning:", "Notice:", "Deprecated:"] + warning_head_chars: 300 # warning_patterns は本文先頭 N 文字のみ走査 (PLAN18 のフィールド名 warning_head_bytes も alias で受理) + not_found_patterns: ["File not found"] + fail_on_match: true # false で情報収集モード + ``` + 各キーは**省略すると dataclass の default**が効く (PHP 系パターン内蔵)。 + 明示的に空リストを書けばそのカテゴリのみ無効化できる。 + - **新 marker**: `@pytest.mark.no_body_check` で個別テスト opt-out + - **report.md**: + - サマリ表に `body_check` カラムを追加 (違反件数) + - 違反があれば「body_check 違反の詳細」セクション (URL / pattern / snippet) + を生成 (PASS でも `fail_on_match=false` の情報収集モードで表示) + - **設計ポイント**: + - 既存利用者の test 挙動を変えないため `body_check.enabled` の default は + **False** (opt-in)。設定を書かない限り従来挙動 (検出ロジックなし) が維持される + - autouse fixture は a11y / cwv と同じく `request.fixturenames` ガードで + `page` を要求していない test を skip + - body_check が teardown で `pytest.fail` した場合、call phase は passed + のまま teardown report が failed/error になるため、`_collect_entries` で + teardown 失敗を call entry に反映 (`outcome` を passed → failed に昇格) + - **検証**: 既存 + body_check / report / collect_entries 昇格 / sessionfinish + upload を含めて **147 件 pure 関数テスト pass** (`uv run pytest -q`) +- Skills: 36個 (変化なし、playwright-scenario-test の中身に opt-in 機能追加) + +### v4.2.0 (playwright-scenario-test v0.3.0 — pure pytest-playwright 完全移行) + +> **注意**: 互換性破壊リリース。v0.2.5 までの自前 YAML DSL は **完全廃止** し、 +> 利用者は通常の pytest-playwright テストを書く形に移行する。詳細は +> [PLAN17](../../../issues/PLAN17.md) を参照。 + +- **`playwright-scenario-test` v0.3.0** (pure pytest-playwright): + - **アーキテクチャ全面刷新**: 自前 DSL (testcase YAML / runner / dispatcher / + locator_steps / cli) をすべて削除。代わりに pytest plugin として実装し、 + 利用者は `def test_xxx(page, ndf_role_admin): ...` を直接書く + - **新モジュール**: + - `scenario_test/pytest_plugin.py` — pytest11 entry-point。 + `pytest_addoption` (`--ndf-config` / `--ndf-out-dir` / `--ndf-no-evidence` + / `--ndf-hud` / `--ndf-drive-folder`)、markers (`page_role` / `role` / + `phase` / `priority`)、`pytest_runtest_makereport` / + `pytest_terminal_summary` / `pytest_sessionfinish` hook + - `scenario_test/pytest_report.py` — `report.md` 生成 (`NdfTestEntry` + + `render_markdown` + `write_report`) + - `scenario_test/fixtures/auth.py` — `ndf_config` (session) / + `ndf_role_<id>` (動的生成、storage_state cache 付) + - `scenario_test/fixtures/evidence.py` — `ndf_evidence` / + `browser_context_args` override (HAR inject) / `ndf_out_dir` + - `scenario_test/fixtures/a11y.py` — `_ndf_a11y_autouse` (page_role marker + が付いた test に限り axe-core 自動実行) + `ndf_a11y_scan` 明示ヘルパ + - `scenario_test/fixtures/cwv.py` — `_ndf_cwv_autouse` (page_role marker + autouse で LCP/CLS/TTFB/longest_task 計測) + - **削除**: + - `scenario_test/testcase.py` の `Step` / `LocatorSpec` / + `KNOWN_STEP_KINDS` / `discover_testcases` 等 + - `scenario_test/locator_steps.py` / `runner.py` / `cli.py` / + `playwright_executor.py` / `report.py` (旧) + - `scripts/record_to_yaml.py` / `generate_test_plan.py` (DSL 雛形版) + - `templates/testcase-*.yaml.template` 6 ファイル + `config.example.yaml` + - **新規 templates** (pytest 雛形): + - `templates/scenario.config.yaml` — base_url / roles / a11y / CWV 設定 + - `templates/conftest.py.template` + - `templates/test_auth.py.template` / `test_list.py.template` / + `test_form.py.template` / `test_dashboard.py.template` + - **依存追加** (main): `pytest>=8.0`, `pytest-playwright>=0.5`, + `pytest-xdist>=3.0` + - **設計上の重要ポイント**: + - autouse fixture が `page` を直接要求すると pytest-playwright が全 test を + browser parametrize する問題を、`request.fixturenames` ガード + + `getfixturevalue` 遅延取得で回避 + - `ndf_role_<id>` の login は session 内 1 回だけ実行し storage_state を + cache。新 context には cookies/origins を inject して再ログイン回避 + - **検証**: 旧 126 + 新規 26 = **152 件 pure 関数テスト pass** + - SKILL.md は pytest 中心の構成に全面書き直し +- Skills: 36個 (変化なし、playwright-scenario-test の中身が刷新) + +### v4.1.1 (playwright-scenario-test v0.2.5 — locator-first DSL 中間版) + +> **Note**: 当初は v0.3.0 として実装したが、Codex の独立レビューで「pure +> pytest-playwright 移行の方が OSS 品質として優れている」と判断 (locator DSL は +> 自前で再実装が必要、artifact 契約も pytest plugin の方が自然) し、v0.3.0 の +> 番号は **pure pytest 完全移行** に予約しなおした (PLAN17 参照)。本リリースは +> evidence/a11y/CWV/slug 衝突回避などの本質的改善のみを v0.2.5 として暫定提供。 + +- **`playwright-scenario-test` v0.2.5** (locator-first / web-first 中間版): + - **testcase YAML スキーマを刷新**: 旧 `path/method/data/extract` 構造を廃止。明示的 `kind` (`goto/click/fill/select/check/press/hover/extract/wait_for/wait_ms/expect_visible/expect_hidden/expect_text/expect_no_text/expect_url/expect_count/expect_aria_snapshot`) に統一 + - **Locator 表現**: `{role: button, name: 保存}` / `{label: メールアドレス}` / `{testid: ...}` / `{css: ...}` 等の dict で記述。Playwright 公式の `get_by_*` 系 API に 1 対 1 対応 + - **assertion は `expect()` のみ**: 自前 `body_check` と HTML 文字列 match を全廃。`expect_no_text` step で代替 + - **新モジュール**: + - `scenario_test/locator_steps.py` — YAML step kind → Locator/expect API dispatcher (テーブル駆動) + - `scenario_test/evidence.py` — 1 testcase 分の HAR/trace/console/pageerror/axe/CWV を集中管理 + - `scenario_test/a11y.py` — axe-core ランナー内蔵モジュール (page_role に応じて自動実行) + - `scenario_test/cwv.py` — Core Web Vitals ランナー内蔵モジュール (page_role に応じて自動実行) + - **削除**: + - `scenario_test/nav_helpers.py` (全関数を locator-first で置換) + - `scripts/trace_link.py` (`upload_evidence.py` に統合) + - 古い `path` ベース step / `body_check` / `slug` config / `enable_scroll_demo` config + - **新規 scripts**: + - `scripts/upload_evidence.py` — trace/HAR/video の Drive アップ統合 (拡張子から自動判定) + - `scripts/record_to_yaml.py` — Playwright codegen Python 出力 → 新スキーマ YAML 変換 + - **Min-2 (slug 衝突)**: `_default_test_id(role, url)` で URL 全 path + sha1[:6] suffix を付与し衝突回避 + - **Maj-7 (責務分離)**: `playwright_executor.py` 678 → 約 380 行へ縮小 (evidence は `EvidenceCollectors` へ完全分離) + - **report.md** に axe-core 違反 / Core Web Vitals metrics を表示 + - templates/ を 6 ファイル全部新スキーマで書き直し + - docs/ から「v0.3.0 以降で検討」記述を解消、`navigate_post` / `find_click_target` / `detect_body_errors` 等の旧 helper 言及を削除 +- Skills: 36個 (変化なし) + +### v4.1.0 +- **`playwright-scenario-test` v0.2.0** (理論ベース化): + - `docs/` 配下に方法論を 6 ファイル (総論 / page role / 技法 / Playwright API / bug report) + checklists 11 ファイル (lp/list/item/edit/form/search/dashboard/auth/cart-checkout/modal-wizard/common) として整備 + - 出典: HTSM v6.3 (James Bach), ISTQB CTFL 4.2, ISO/IEC/IEEE 29119-3:2021, WCAG 2.2, OWASP Top 10:2025, FEW HICCUPPS, Hendrickson Cheat Sheet + - 新規スクリプト: `classify_page_role.py` (a11y tree から自動 role 判定), `generate_test_plan.py` (Pairwise 込み YAML 自動生成), `run_a11y_scan.py` (axe-core), `check_cwv.py` (LCP/CLS/TTFB), `record_scenario.py` (Playwright codegen ラッパー), `trace_link.py` (trace.zip → playwright.dev URL) + - 役割別 testcase YAML テンプレート 4 件 (list/edit/form/auth) を追加 + - SKILL.md は実行手順とナビゲーションに集中 (332 → 245 行) + - pyproject.toml に optional-dependencies `a11y` (axe-playwright-python) を追加 +- **公式 Agent Skill 仕様準拠**: 14 skill の frontmatter を Pattern A (description 単体に Triggers 埋め込み) から Pattern B (description + 公式 `when_to_use` フィールド分離) へ移行。対象 skill: codex / data-analyst-export / data-analyst-sql-optimization / deepwiki-transfer / docker-container-access / git-gh-operations / google-auth / google-chat / google-drive / markdown-writing / official-skills-autoloader / playwright-scenario-test / python-execution / qa-security-scan / skill-stats。`description` は概要に集中、`when_to_use` に Trigger phrase を分離して auto-invoke 精度向上 (公式 1,536 字上限内)。`mcp-builder` は Anthropic 公式 (Apache-2.0) のため改変せず。 +- **コード品質改善**: + - `google-chat/scripts/gchat_read.py`: `DEFAULT_SPACE_ID` のハードコード (`AAQA6AWG1iE`) を撤去。env `GCHAT_DEFAULT_SPACE` で指定するか `--space` を required にする運用に変更 + - `google-auth/SKILL.md`: `allowed-tools` から不要な `Bash(pip *)` を削除 (uv で完結) + - `google-drive/scripts/gdrive_fetch.py`: `upload_file` 内の dead な再 `import os` を削除 + - `playwright-scenario-test/SKILL.md`: 不足していた `allowed-tools` (Read / Bash(uv *) / Bash(python *)) を追加 +- **新規 Skill `playwright-scenario-test`** (self-contained uv project): + - Playwright + curl で Web シナリオ E2E テストを並列実行 + - HUD オーバーレイ (カーソル + 字幕) 焼き込み済み動画 + Markdown レポート生成 + - Drive アップロード自動化までサポート + - 外部プロジェクトは `config.yaml` + `testcases/*.yaml` のみで利用可能 +- **新規 Skill `google-drive`**: + - Google Drive / Docs のファイルエクスポート / ダウンロード / アップロード (公開共有リンク付与) + - 認証は `ndf:google-auth` の `get_credentials()` に委譲 +- **新規 Skill `google-chat`**: + - Google Chat スペースのメッセージ・スペース一覧取得 (Chat API) + - 認証は `ndf:google-auth` の `get_credentials()` に委譲 + - 旧 uttarov 版のハードコードパス (`/work/uttarov2-doc/...`) を撤廃し、 + sibling-skill discovery (`GOOGLE_AUTH_SCRIPTS` env / `~/.claude/skills/google-auth/scripts` / + 隣接スキル) でフォールバック +- **`google-auth` v0.2.0 (互換性破壊)**: + - Python ライブラリ用法 (`from google_auth import get_credentials`) を追加 + - `--manual` 手動 copy-paste フロー (ローカルサーバ不要、コンテナ環境対応) + - トークン自動リフレッシュ + スコープ不足検出 / 自動マージ + - `--show` / `--clear` サブコマンド + - **トークン保存先を `/tmp/google_token.json` → `~/.config/gcloud/google_token.json` に変更** + (env `GOOGLE_TOKEN_FILE` で上書き可) + - `client_secret` パスは `--client-secret` → env `GOOGLE_CLIENT_SECRET` → + `${CLAUDE_SKILL_DIR}/client_secret.json` → CWD の順 +- Skills: 33個 → **36個** + +### v4.0.0 (BREAKING: Codex MCP廃止 + レガシー救済機構削除) +- **Codex MCP サーバを削除** (`.mcp.json` から `codex` エントリを削除) + - 理由: `/ndf:codex` skill (CLI直接実行) で十分であり、MCP 経由の制約 (ホスト側ファイル読み取り制限等) よりも CLI 直接実行の方が有用 + - 影響: `mcp__codex__codex` / `mcp__codex__codex-reply` は利用不可 + - 代替: `/ndf:codex` skill の手順で `codex exec` をバックグラウンド実行、または `corder` エージェント経由で呼び出し +- **corder エージェントを CLI ベースに書き換え** + - MCP 呼び出しを `/ndf:codex` skill 参照に変更 + - Serena / Context7 MCP は引き続き利用 +- 他エージェント (researcher, qa, devops-engineer, debugger, code-reviewer, director) の description から Codex MCP 言及を削除 / CLI ベースに更新 +- `skills/codex` の MCP 版との使い分け節を corder エージェントとの使い分けに書き換え +- `skills/qa-security-scan/03-report-template.md` の JS 疑似コードを `codex exec` bash 例に置換 +- **レガシー CLAUDE.ndf.md 救済機構を削除** (v3.0.0 で本体廃止、以降の救済装置を除去) + - `hooks/hooks.json` の CLAUDE.ndf.md 検出 hook を削除 + - `skills/cleanup/` を削除 (`plugin.json` の参照も削除) + - まだ残っているユーザーは今後手動で `CLAUDE.ndf.md` を削除してください +- Skills: 34個 → **33個** (`cleanup` 削除) + +### v3.7.0 +- **Transcript保持期間の自動管理**: + - `SessionStart` hook (matcher: `startup`) + `scripts/ensure-retention.sh` を追加 + - `~/.claude/settings.json` の `cleanupPeriodDays` を最低 90 日に保つ (既に 90 以上ならそのまま) + - 7 日タイムスタンプガード (`~/.claude/.ndf-retention-checked`) で多重実行防止 + - Claude Code 本体の公開 API/ドキュメントには「プラグインインストール時」hook が存在しないため、`SessionStart + startup` matcher が事実上の最適解 +- **`/ndf:skill-stats` skillを追加**: + - `~/.claude/projects/**/*.jsonl` transcript から NDF skill 利用統計を集計 + - 項目: 呼び出し数 / 関連話題数 / ヒット数 / ヒット率 + - 関連話題判定は SKILL.md frontmatter の `Triggers: '..', '..'` 行を使用 (明示されていない skill は計算対象外) + - Python 実装、標準ライブラリのみ + - skill description の網羅性を評価するツールとして機能 +- Skills: 33個 → **34個** + +### v3.6.0 +- carmo-system-consoleから汎用skill/commandを抽出してNDFに統合 +- **新規Skills(13個)**: + - 原則系(5個): `branch-fix-strategy`, `implementation-plan`, `investigation-rules`, `problem-solving`, `logging-guidelines` + - ワークフロー系(7個): `sync-main`, `cherry-pick-pr`, `deploy`, `review-branch`, `review-pr-comments`, `resolve-pr-comments`, `browser-test` + - 外部AI委譲(1個): `codex`(CLI直接実行、MCP版corderとの使い分け) +- **既存Skill改修**: + - `pr`: `--draft`フラグ対応、既存PR説明の自動更新、base非mainの場合`cherry-pick-pr`誘導、`.github/pull_request_template.md`サポート +- Skills: 20個 → **33個** +- PR/コードレビュー系の責務分担を明確化: + - `review` = PR単位レビュー(Approve/Request Changes判定) + - `review-branch` = ローカル差分レビュー(PR前のセルフレビュー) + - `review-pr-comments` = 既存PRコメントの分類(READ-ONLY) + - `fix` = コメント対応の修正実施 + - `resolve-pr-comments` = 修正完了後の返信+Resolve(クロージング) + +### v3.5.0 (破壊的変更: scanner削除) +- Claude Code Read toolのmultimodal/PDF native対応、および v3.4.0で追加された `official-skills-autoloader` により冗長になったAgent/Skillを整理 +- **削除Agent**: + - `scanner` (Office抽出) → autoloader + 公式docx/pptx/xlsx Skillで代替 +- **削除Skills**: + - `scanner-pdf-analysis` → Read tool の PDF native対応で代替 + - `scanner-excel-extraction` → autoloader + 公式xlsx Skillで代替(plugin.jsonのdangling ref整理) + - `skill-development` → 公式 `skill-creator` Skillで代替(autoloader取得可能) + - `corder-code-templates`, `corder-test-generation` → Claude本体のコード生成能力で代替 + - `researcher-report-templates` → researcher agent description/Claude本体で代替 +- Agents: 9個 → **8個** +- Skills: 25個 → **20個** +- 移行ガイド: `/ndf:scanner` を呼んでいた処理は、autoloaderまたはRead toolへ切替 + +### v3.4.0 +- Anthropic公式の定番Skill `mcp-builder` を取込(Apache-2.0、LICENSE.txt同梱) +- 公式Skillインストーラ `plugins/ndf/scripts/install-official-skills.sh` を追加 + - `--list`: 利用可能Skill一覧(ライセンス分類付き) + - `--scope user/project`: インストール先選択 + - `--all` / 個別指定: 選択的インストール + - `--update`: 公式リポジトリの最新化 + - シンボリックリンク方式で軽量 +- プロプライエタリSkill(docx/pptx/xlsx/pdf)は再配布せず、上記インストーラで個人利用者環境に配置 +- インストール手順・ライセンス方針を `docs/official-skills-installation.md` にまとめ +- `official-skills-autoloader` Skillを追加: Word/Excel/PowerPoint/PDF等の要求時に必要な公式Skillを自動ダウンロード→読込して即使用可能(利用者はインストール作業不要) +- Skills: 23個 → 25個 + +### v3.3.0 +- 定番サブエージェント3個を追加(いずれも `model: sonnet`) + - **debugger**: エラー・バグの根本原因分析 + - **devops-engineer**: Dockerfile/CI/CD/Kubernetes + - **code-reviewer**: git diff / PR一般レビュー(corderと差別化: Codex非使用) +- Agents: 6個 → 9個 + +### v3.2.0 +- サブエージェントに `model:` 指定を追加し、コスト最適化 + - director: `opus`(計画・設計判断) + - corder, data-analyst, researcher, qa: `sonnet` + - scanner: `haiku` +- scannerエージェントをOffice専用に縮小 + - 画像・PDFはClaude Code built-inのRead tool(multimodal, pages)で処理する方針に変更 +- corderのdescriptionを「Codex第二意見レビュー/大規模調査」用途に明確化 +- researcherのdescriptionをAWS Docs / Chrome DevTools専用に縮小 + +### v3.1.0 +- Kiro CLI対応(`.kiro/` 配下のインストーラ、プロンプト、スキルリンク) +- `google-auth` スキル追加 + +### v3.0.0 (破壊的変更) +- Serena MCPを`mcp-serena`プラグインに分離 +- memory系スキル5個を廃止(serena, memory-handling, serena-memory-strategy, mem-capture, mem-review) +- CLAUDE.ndf.md注入仕組みを廃止(inject-plugin-guide.js削除) +- `ndf-policies`スキル追加(ポリシー常時注入) +- `/ndf:cleanup`スキル追加(CLAUDE.ndf.md後始末) +- SessionStartフックをCLAUDE.ndf.md検出警告に変更 +- Skills: 25個→23個 + +### v2.8.0 +- `deepwiki-transfer`スキル追加 +- Skills: 23個→25個(knowledge-reorg含む) + +### v2.7.0 +- commandsをskillsに統合(Claude Code 2.1.3対応) + +### v2.6.0 +- NDFプラグインのMCP構成を最適化し個別プラグイン化 + +### v2.0.0 +- GitHub MCP, Serena MCP, Context7 MCPを公式プラグインに移行 diff --git a/plugins/ndf/CLAUDE.md b/plugins/ndf/CLAUDE.md deleted file mode 100644 index d0e740ee..00000000 --- a/plugins/ndf/CLAUDE.md +++ /dev/null @@ -1,129 +0,0 @@ -# NDF Plugin - 開発者向けガイドライン - -## 概要 - -**NDFプラグインの開発・メンテナンス**を行うAIエージェント向けガイドライン。 - -## プラグイン情報 - -- **名前**: ndf -- **現在バージョン**: 3.0.0 -- **種類**: 統合プラグイン(Codex MCP + Skills + Agents + Hooks) -- **リポジトリ**: https://github.com/devbasex/ai-plugins - -> **Note (v3.0.0)**: Serena MCPは`mcp-serena`プラグインに分離。memory系スキルは廃止。CLAUDE.ndf.md注入は廃止。 - -## 開発ルール - -- ドキュメント・コミットメッセージ・PR説明は**日本語** -- **mainブランチへの直接コミット禁止**(featureブランチ+PR) -- **セマンティックバージョニング**: MAJOR(破壊的変更)、MINOR(新機能)、PATCH(バグ修正) - -## ディレクトリ構造 - -``` -plugins/ndf/ -├── .claude-plugin/ -│ └── plugin.json # プラグインメタデータ -├── .mcp.json # MCPサーバー定義(Codex CLI) -├── hooks/ -│ └── hooks.json # プロジェクトフック定義 -├── scripts/ -│ └── slack-notify.js # Slack通知スクリプト -├── agents/ # サブエージェント(6個) -│ ├── director.md -│ ├── data-analyst.md -│ ├── corder.md -│ ├── researcher.md -│ ├── scanner.md -│ └── qa.md -├── skills/ # スキル(23個) -│ ├── pr/ # ワークフロー系(8個、/ndf:* で呼出) -│ ├── pr-tests/ -│ ├── fix/ -│ ├── review/ -│ ├── merged/ -│ ├── clean/ -│ ├── cleanup/ -│ ├── deepwiki-transfer/ -│ ├── ndf-policies/ # ポリシー常時注入(model-invoked) -│ ├── data-analyst-sql-optimization/ # モデル起動型(13個) -│ ├── data-analyst-export/ -│ ├── corder-code-templates/ -│ ├── corder-test-generation/ -│ ├── researcher-report-templates/ -│ ├── scanner-pdf-analysis/ -│ ├── scanner-excel-extraction/ -│ ├── qa-security-scan/ -│ ├── markdown-writing/ -│ ├── python-execution/ -│ ├── docker-container-access/ -│ ├── skill-development/ -│ ├── knowledge-reorg/ -│ └── git-gh-operations/ -├── CLAUDE.md # このファイル(開発者向け) -└── README.md # プラグイン説明書 -``` - -## 一般的な開発タスク - -### 新しいスキルの追加 - -1. `skills/{skill-name}/SKILL.md` を作成(YAMLフロントマター必須) -2. `plugin.json` の `skills` 配列に `"./skills/{skill-name}"` を追加 -3. `plugin.json` のバージョンをMINOR上げ -4. テスト・コミット - -### 新しいサブエージェントの追加 - -1. `agents/{agent-name}.md` を作成(YAMLフロントマター必須) -2. `plugin.json` の `agents` 配列に追加 -3. バージョンMINOR上げ → テスト・コミット - -### MCPサーバーの追加・更新 - -1. `.mcp.json` の `mcpServers` に追加 -2. README.mdに説明追加 -3. バージョン更新 → テスト・コミット - -## 検証チェックリスト - -- [ ] plugin.jsonが有効なJSON -- [ ] バージョン番号が適切にインクリメント -- [ ] すべてのスキル/エージェントファイルが存在 -- [ ] YAMLフロントマターが正しい -- [ ] .mcp.jsonが有効なJSON -- [ ] README.md が最新 - -## トラブルシューティング - -| 問題 | 対処 | -|------|------| -| エージェントが認識されない | plugin.jsonのagents配列、ファイルパス、YAMLフロントマターを確認 | -| スキルが表示されない | plugin.jsonのskills配列、SKILL.mdのフロントマターを確認、`/plugin reload ndf` | -| MCPサーバーが起動しない | .mcp.jsonの構文、コマンドパス、環境変数を確認 | -| フックが動作しない | hooks.jsonの構文、スクリプト実行権限を確認 | - -## 開発履歴 - -### v3.0.0 (破壊的変更) -- Serena MCPを`mcp-serena`プラグインに分離 -- memory系スキル5個を廃止(serena, memory-handling, serena-memory-strategy, mem-capture, mem-review) -- CLAUDE.ndf.md注入仕組みを廃止(inject-plugin-guide.js削除) -- `ndf-policies`スキル追加(ポリシー常時注入) -- `/ndf:cleanup`スキル追加(CLAUDE.ndf.md後始末) -- SessionStartフックをCLAUDE.ndf.md検出警告に変更 -- Skills: 25個→23個 - -### v2.8.0 -- `deepwiki-transfer`スキル追加 -- Skills: 23個→25個(knowledge-reorg含む) - -### v2.7.0 -- commandsをskillsに統合(Claude Code 2.1.3対応) - -### v2.6.0 -- NDFプラグインのMCP構成を最適化し個別プラグイン化 - -### v2.0.0 -- GitHub MCP, Serena MCP, Context7 MCPを公式プラグインに移行 diff --git a/plugins/ndf/README.md b/plugins/ndf/README.md index e3d852d6..de21b723 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -6,9 +6,9 @@ Claude Code開発環境を**オールインワン**で強化する統合プラ このプラグイン1つで、以下の**すべて**の機能を利用できます: -1. **コアMCP**: 2個のMCPサーバー(Serena、Codex CLI) -2. **Skills**: 23個(ワークフロー9個 + モデル起動型14個) -3. **専門エージェント**: 6つの特化型AIエージェント(**director指揮者**、データ分析、コーディング、調査、ファイル読み取り、品質管理) +1. **コアMCP**: なし (v4.0.0 で Codex MCP 廃止 / Serena MCP は `mcp-serena` プラグインに分離) +2. **Skills**: 39個(PR/コードレビュー系ワークフロー13個 + 原則・ガイドライン8個 (issue→multi-PR 戦略含む) + データ分析/品質/環境系13個 + skill-stats + Playwright シナリオテスト + Google Drive/Chat 連携 + AI クロスレビュー (cross-review / gemini)) +3. **専門エージェント**: 8つの特化型AIエージェント(director、data-analyst、corder、researcher、qa、debugger、devops-engineer、code-reviewer) 4. **自動フック**: Slack通知 > **Note (v2.7.0)**: commandsとskillsが統合されました。全ワークフロー(`/ndf:pr`等)はskillsとして実装されています。追加のMCP(BigQuery、Chrome DevTools、AWS Docs、DBHub、Notion)は個別プラグインとしてインストール可能です。 @@ -18,9 +18,9 @@ Claude Code開発環境を**オールインワン**で強化する統合プラ ### 前提条件 - Claude Code がインストール済み -- Python 3.10以上(Serena MCP用) +- Python 3.10以上(Serena MCP `mcp-serena` プラグイン用、別途インストール) - `uvx` がインストール済み(`pip install uv`) -- Codex CLI(Codex CLI MCP用)- オプション +- Codex CLI(`/ndf:codex` skill で外部AI委譲を使う場合、オプション): `npm install -g @openai/codex` ### 公式プラグインのインストール(推奨) @@ -57,7 +57,7 @@ GitHub、Context7 MCPは公式プラグインとして提供されています ```bash # Claude Codeで実行 -/plugin marketplace add https://github.com/devbasex/ai-plugins +/plugin marketplace add https://github.com/takemi-ohama/ai-plugins ``` ### ステップ2: プラグインのインストール @@ -82,10 +82,9 @@ SLACK_CHANNEL_ID= SLACK_USER_MENTION= # 例: <@U0123456789> # 注意: -# - Serena MCPは常時有効化推奨(セマンティックコード操作) -# - Serena MCPはGOOGLE_API_KEY、ANTHROPIC_API_KEYは不要です(自動検出) -# - Codex CLI MCPはインストール必要: https://github.com/openai/codex/releases -# インストール後、'codex login'を実行 +# - Serena MCP は別プラグイン `mcp-serena` として提供 (常時有効化推奨、GOOGLE_API_KEY/ANTHROPIC_API_KEY不要) +# - Codex CLI は `/ndf:codex` skill から使う場合のみ必要: `npm install -g @openai/codex` → `codex login` +# (v4.0.0 で Codex MCP サーバは廃止。skill 経由の CLI 直接実行に一本化) # - GitHub MCP、Context7 MCPは公式プラグインを使用してください # - 追加のMCP(BigQuery、Notion、AWS Docs、DBHub、Chrome DevTools)は # 個別プラグインとしてインストール可能です(下記参照) @@ -240,15 +239,14 @@ Claude Codeが自動的に適切なMCPツールを選択・利用します。 > **v2.7.0**: commandsとskillsが統合されました。ワークフロー(`/ndf:*`)もモデル起動型機能もすべてskillsとして実装されています。 -### 1. MCP統合 (2つのコアMCP) +### 1. MCP統合 -このプラグインは2つのコアMCPサーバーを統合しています。各MCPの詳細な使用方法やベストプラクティスは、エージェント向けガイド `plugins/ndf/CLAUDE.md` を参照してください。 +このプラグイン自体は v4.0.0 で **コアMCP サーバを同梱しなくなりました**。関連MCPは個別プラグインとしてインストールしてください。 -> **Note (v2.6.0)**: NDFプラグインはコアMCP(Serena、Codex)のみを含みます。その他のMCPは個別プラグインとして提供されています。 +> **Note (v4.0.0 BREAKING)**: Codex MCP サーバは削除。Codex CLI は `/ndf:codex` skill から直接呼び出す方式に一本化。Serena MCP は別プラグイン `mcp-serena` へ分離済み。 -**コアMCP(2つ):** -- ✅ **Serena MCP** - セマンティックコード操作、メモリー管理 -- ✅ **Codex CLI MCP** - コードレビュー、ファイル読み取り +**外部AI委譲(MCP 非使用):** +- 🔧 **Codex CLI** - `/ndf:codex` skill または `corder` エージェント経由で `codex exec` をバックグラウンド実行 **個別プラグインとして提供(5つ):** - 📦 **Chrome DevTools MCP** (`mcp-chrome-devtools`) - Web調査、パフォーマンステスト @@ -291,16 +289,14 @@ Claude Codeが自動的に適切なMCPツールを選択・利用します。 - コンテキスト使用量を最適化するため、使わないMCPはインストールしないことを推奨します - 各プラグインの詳細な設定方法は、個別のREADMEを参照してください -### 2. 専門エージェント (6種類) - -**重要**: このプラグインには`CLAUDE.ndf.md`が含まれており、メインエージェント(Claude)に対してサブエージェントの積極的な活用を促す指示が記載されています。 +### 2. 専門エージェント (8種類) **サブエージェントの活用方針:** - **複雑なタスクは`director`に委譲** - directorがMain Agentに報告し、Main Agentが他のエージェントを起動 - **単純なタスクは専門エージェントに直接委譲** - **directorはMain Agentに報告する** - メモリエラー防止のため直接呼び出しは行わない -詳細は `plugins/ndf/CLAUDE.ndf.md` を参照してください。 +詳細はメインセッションに自動注入される `ndf-policies` skill および `plugins/ndf/AGENTS.md` を参照してください。 #### `director` エージェント(指揮者) **専門領域:** タスク統括・設計立案・エージェント調整 @@ -349,7 +345,7 @@ Claude Codeが自動的に適切なMCPツールを選択・利用します。 **専門領域:** 高品質コード生成 **使用MCPツール:** -- Codex CLI MCP(コードレビュー) +- Codex CLI (`/ndf:codex` skill または `corder` エージェント経由、MCP非使用)(コードレビュー) > **Note (v2.0.0)**: Serena MCP、Context7 MCPは公式プラグインに移行しました。これらは引き続きcorderエージェントで使用可能ですが、別途インストールが必要です。 @@ -369,7 +365,7 @@ Claude Codeが自動的に適切なMCPツールを選択・利用します。 **専門領域:** 情報収集と分析 **使用MCPツール:** -- Codex CLI MCP(コードベース分析) +- Codex CLI (`/ndf:codex` skill または `corder` エージェント経由、MCP非使用)(コードベース分析) - AWS Documentation MCP(AWS公式ドキュメント) - Chrome DevTools MCP(Webスクレイピング) @@ -389,7 +385,7 @@ Claude Codeが自動的に適切なMCPツールを選択・利用します。 **専門領域:** ファイル読み取りとOCR **使用MCPツール:** -- Codex CLI MCP(ファイル読み取り) +- Codex CLI (`/ndf:codex` skill または `corder` エージェント経由、MCP非使用)(ファイル読み取り) **機能:** - PDFドキュメントのテキスト抽出 @@ -407,7 +403,7 @@ Claude Codeが自動的に適切なMCPツールを選択・利用します。 **専門領域:** 品質管理とテスト **使用MCPツール:** -- Codex CLI MCP(コードレビュー、セキュリティチェック) +- Codex CLI (`/ndf:codex` skill または `corder` エージェント経由、MCP非使用)(コードレビュー、セキュリティチェック) - Serena MCP(コードベース分析) - Chrome DevTools MCP(パフォーマンステスト) - Claude Code MCP(プラグイン品質検証) @@ -427,68 +423,67 @@ Claude Codeが自動的に適切なMCPツールを選択・利用します。 @qa プラグインがClaude Code仕様に準拠しているか確認してください ``` -### 3. ワークフロースキル(9個) +### 3. PR/コードレビューワークフロースキル(13個) 開発の各段階で使用するスキル群です。`/ndf:*` のスラッシュコマンドで呼び出します。 +#### commit / push / PR作成 | スキル | 用途 | 引数 | |--------|------|------| -| `/ndf:serena` | Serena MCPで開発記憶を記録 | - | -| `/ndf:pr` | commit, push, PR作成を一括実行 | `[base-branch]` | +| `/ndf:pr` | commit, push, PR作成/既存PR説明更新を一括実行 | `[--draft] [base-branch] or [msg]` | | `/ndf:pr-tests` | PRのTest Planを自動実行 | `[PR番号]` | -| `/ndf:review` | PRをレビューしApprove/Request Changes判定 | `[PR番号]` | +| `/ndf:cherry-pick-pr` | 環境ブランチへのcherry-pick PR作成 | `<base-branch>` | +| `/ndf:deploy` | 環境ブランチへのデプロイPR作成(ブランチ全体をmain経由で) | `<env-branch>` | +| `/ndf:sync-main` | 最新mainを現在のブランチに取り込み | - | + +#### レビュー/修正 +| スキル | 用途 | 引数 | +|--------|------|------| +| `/ndf:review` | PR単位レビュー(Approve/Request Changes判定) | `[PR番号]` | +| `/ndf:review-branch` | ローカル差分レビュー(PR前、mainとの比較) | `[focus-area]` | +| `/ndf:review-pr-comments` | PRコメント分類・優先度判定(READ-ONLY) | `[PR番号]` | | `/ndf:fix` | PRレビューコメントの修正対応 | `[PR番号]` | +| `/ndf:resolve-pr-comments` | 対応済みコメント返信+スレッドResolve | `[PR番号]` | +| `/ndf:browser-test` | ブラウザで動作確認(Playwright/Chrome DevTools MCP必要) | `[url]` | + +#### マージ後クリーンアップ +| スキル | 用途 | 引数 | +|--------|------|------| | `/ndf:merged` | PRマージ後のローカルブランチクリーンアップ | `[PR番号]` | | `/ndf:clean` | マージ済みブランチの一括削除 | - | -| `/ndf:mem-review` | 中期memoryのコミット数ベース自動レビュー | `[--threshold N]` | -| `/ndf:mem-capture` | タスク終了時の知見をSerena memoryに保存 | `[--project NAME] [--type TYPE]` | - -<details> -<summary><strong>mem-review / mem-capture の詳細</strong></summary> -**`/ndf:mem-review`** - 中期Serena memoryをコミット数ベースでレビュー +### 4. 原則・ガイドライン系スキル(7個) -`.serena/memories/` の中期memory(`review_after_commits`付き)をチェックし、延長・長期化・更新・アーカイブ・削除の選択肢を提示します。 +モデル起動型のガイドラインスキル。該当する文脈で自動参照される。 -```bash -/ndf:mem-review # レビュー対象をチェック -/ndf:mem-review --threshold 10 # 閾値を指定 -``` +| スキル名 | 概要 | +|---------|------| +| `ndf-policies` | プラグイン基本ポリシー(常時注入) | +| `branch-fix-strategy` | 複数ブランチへの修正適用戦略(cherry-pick) | +| `implementation-plan` | `issues/`配下の実装プラン管理 | +| `investigation-rules` | 調査レポートのエビデンス主義 | +| `problem-solving` | 根本原因分析・上流修正・多層防御 | +| `logging-guidelines` | ログ運用ガイドライン(言語非依存) | +| `markdown-writing` | Markdown文書作成(mermaid/plantUML) | -**`/ndf:mem-capture`** - タスク終了時の知見をSerena memoryに保存 +### 5. データ分析・品質・環境系スキル(13個) -判断・前提・制約をMemoryに保存。手順や実装詳細は保存しません。 - -```bash -/ndf:mem-capture --project myproject --type decision -/ndf:mem-capture --project global --type principle --long -/ndf:mem-capture --append .serena/memories/existing-memory.md -``` - -**推奨レビュー設定:** low→10コミット、medium→20コミット、high→30コミット - -</details> - -### 4. モデル起動型スキル(14個) - -Claudeが自律的に判断して起動するスキルです。自然言語リクエストに応じて自動的に活用されます。 +Claudeが自律的に判断して起動するスキル群。 | カテゴリ | スキル名 | 概要 | |---------|---------|------| | Data Analyst | `data-analyst-sql-optimization` | SQL最適化パターン(N+1、INDEX、JOIN) | | | `data-analyst-export` | CSV/JSON/Excel/Markdownエクスポート | -| Corder | `corder-code-templates` | REST API、React、DB、認証のテンプレート | -| | `corder-test-generation` | ユニット/統合テスト自動生成(AAA) | -| Researcher | `researcher-report-templates` | 調査レポートテンプレート | -| Scanner | `scanner-pdf-analysis` | PDF解析・テーブル抽出 | -| | `scanner-excel-extraction` | Excelデータ抽出・変換 | | QA | `qa-security-scan` | OWASP Top 10セキュリティスキャン | -| Docs | `markdown-writing` | Markdown文書作成(mermaid/plantUML) | -| Memory | `memory-handling` | Serena memory読み書きルール | -| | `serena-memory-strategy` | Serena memoryの分類・メタデータ・レビュー戦略 | -| Common | `python-execution` | Python実行環境の自動判定 | +| Dev環境 | `python-execution` | Python実行環境の自動判定 | | | `docker-container-access` | Dockerコンテナアクセス判定 | -| | `skill-development` | Skill開発ベストプラクティス | +| | `git-gh-operations` | git/gh操作の共通パターン | +| | `google-auth` | Google API OAuth2認証 | +| | `codex` | Codex CLI直接実行(corderエージェントとの使い分け) | +| 知識管理 | `deepwiki-transfer` | DeepWikiからの知識転送 | +| | `knowledge-reorg` | 知識再編成 | +| 公式連携 | `mcp-builder` | MCPサーバー作成ガイド(Anthropic公式、Apache-2.0) | +| | `official-skills-autoloader` | Anthropic公式Skillの自動ロード | ### 5. 自動フック @@ -528,14 +523,12 @@ Claude Codeの起動時と終了時に自動的に以下が実行されます: SERENA_HOME=.serena # ============================================ -# Codex CLI MCP - コードレビュー +# Codex CLI - /ndf:codex skill 経由で使用 (v4.0.0でMCPサーバは廃止) # ============================================ -# すべてオプション - ローカルインストール推奨 +# すべてオプション。npm install -g @openai/codex → codex login で利用可 # CODEX_HOME=/path/to/codex/home # OPENAI_API_KEY=your-openai-api-key # OPENAI_BASE_URL=https://api.openai.com/v1 -# AZURE_OPENAI_API_KEY=your-azure-openai-key -# MISTRAL_API_KEY=your-mistral-api-key # ============================================ # Slack通知 - 自動フック @@ -593,19 +586,20 @@ DSN=mysql://user:password@host:3306/database - GOOGLE_API_KEY、ANTHROPIC_API_KEYは不要です(自動検出) - Claude CodeのAPI設定を自動的に継承します -#### 2. Codex CLI MCP(コアMCP) +#### 2. Codex CLI(`/ndf:codex` skill 経由、v4.0.0 でMCPサーバは廃止) | 環境変数 | 必須/オプション | デフォルト値 | 説明 | |---------|--------------|------------|------| | CODEX_HOME | オプション | `~/.codex` | Codex CLIのホームディレクトリ | -| OPENAI_API_KEY | オプション | - | OpenAI APIキー | +| OPENAI_API_KEY | オプション | - | OpenAI APIキー (`codex login` 済みなら不要) | | OPENAI_BASE_URL | オプション | `https://api.openai.com/v1` | OpenAI APIのベースURL | -| AZURE_OPENAI_API_KEY | オプション | - | Azure OpenAI APIキー | -| MISTRAL_API_KEY | オプション | - | Mistral AIのAPIキー | -**注意:** Codex CLI MCPを使用するには、Codex CLIを事前にインストールし、`codex login`を実行してください。 +**注意:** `/ndf:codex` skill や `corder` エージェントを使う場合、事前に Codex CLI をインストールし、`codex login` を実行してください。 -インストール: https://github.com/openai/codex/releases +```bash +npm install -g @openai/codex +codex login +``` --- @@ -723,7 +717,7 @@ NDFプラグインと併用することで、以下の機能が追加されま 問題が発生した場合: 1. 上記のトラブルシューティングセクションを確認 -2. GitHubリポジトリでイシューを作成: https://github.com/devbasex/ai-plugins/issues +2. GitHubリポジトリでイシューを作成: https://github.com/takemi-ohama/ai-plugins/issues ## ライセンス diff --git a/plugins/ndf/agents/code-reviewer.md b/plugins/ndf/agents/code-reviewer.md new file mode 100644 index 00000000..417182e7 --- /dev/null +++ b/plugins/ndf/agents/code-reviewer.md @@ -0,0 +1,111 @@ +--- +name: code-reviewer +model: sonnet +description: | + git diff / PR差分の一般的なコードレビューに特化したエージェント。可読性、設計、バグ、セキュリティ、テスト観点からチェックし、重要度順に指摘を返します。Codex CLIによる外部AI第二意見が欲しい場合は代わりにcorderエージェントを使ってください。 + **Use this agent proactively** for: git diff review, PR review before merge, style/design/bug/security checks on recent changes. + 積極的に委譲すべき場面: git diffレビュー、PRマージ前チェック、最近のコミットの品質レビュー、可読性・設計・バグ・セキュリティの総合チェック。 +--- + +# コードレビュアーエージェント + +あなたはコードレビューの専門家です。git差分またはPR差分を読み、**重要度順**に指摘を整理して返します。corderと異なり外部AI(Codex)は使わず、Claude単体で広い観点から素早くレビューします。 + +## 専門領域 + +### 1. 可読性 +- 命名の明確さ +- 関数/クラスの責務の単一性 +- マジックナンバー、長すぎる関数 +- コメントの必要性(WHYが書かれているか) + +### 2. 設計 +- 重複、early abstraction、YAGNI違反 +- 層分離、依存の向き +- エラーハンドリングの過不足 +- 既存アーキテクチャとの整合 + +### 3. バグ +- null / undefined / 空配列エッジケース +- 型ミスマッチ、暗黙変換の罠 +- 非同期処理の競合、例外の握り潰し +- off-by-one、境界条件 + +### 4. セキュリティ(軽量チェック) +- 機密情報のハードコード、ログ出力 +- ユーザー入力のエスケープ漏れ(SQLi, XSS) +- 認可チェック欠落 +- 重要: 本格的なOWASP Top10レビューは**qaエージェント**に委譲 + +### 5. テスト +- 変更箇所のテスト有無 +- カバレッジの妥当性 +- テストが仕様を表現しているか + +## 作業プロセス + +1. **差分取得**: `git diff main...HEAD`, `git diff HEAD`, または指定PR番号で `gh pr diff` +2. **全体把握**: 変更の目的を1文で言語化(PR説明文やコミットメッセージから) +3. **ファイル別レビュー**: 変更行ごとに観点適用 +4. **重要度判定**: 🛑 blocker / ⚠️ major / 💡 suggestion / 💅 nit +5. **構造化報告**: ファイル、行番号、観点、理由、修正案の順で提示 +6. **ポジティブ指摘**: 良い変更は明示的に認める + +## 指摘フォーマット + +``` +### <file>:<line> +[🛑 blocker | ⚠️ major | 💡 suggestion | 💅 nit] <一行要約> + +**問題**: <具体的に何がまずいか> +**理由**: <なぜそれがまずいか> +**提案**: <具体的な修正案(コード片)> +``` + +## 使用ツール + +### Claude Code built-in +- `Bash` - `git diff`, `gh pr diff`, `gh pr view` +- `Read` - 差分周辺コンテキストの確認 +- `Grep` - 同じパターンが他にないか検索 + +## レビューの原則 + +- **症状ではなく原因**: 同じ問題が他にもありそうなら指摘 +- **既存コードを尊重**: スタイル統一目的の大改造は指摘しない +- **過剰指摘を避ける**: すべて指摘せず、重要度 major 以上を中心に +- **YAGNI**: 実装されていない将来機能への「〜も追加すべき」指摘はしない +- **根拠を添える**: 「一般にこうする」ではなく「このコードベースのルールで」「このバグが起きる」 + +## サブエージェント呼び出しの制約 + +他のサブエージェント(director, corder, data-analyst, researcher, scanner, qa, debugger, devops-engineer)を呼び出してはいけません。Codex第二意見が必要な場合は **呼び出し元にcorderを提案**してください。 + +## 使用例 + +### 現在のブランチのレビュー +``` +入力: 「このブランチをレビューして」 +処理: + 1. Bash: git diff main...HEAD + 2. Bash: git log main..HEAD --oneline + 3. 全体意図の把握 + 4. ファイル別に指摘を整理 + 5. 重要度順に報告 +``` + +### PRレビュー +``` +入力: 「PR #42 をレビュー」 +処理: + 1. Bash: gh pr view 42 + 2. Bash: gh pr diff 42 + 3. レビュー実施 + 4. 重要度順に報告(blocker/major/suggestion/nit) +``` + +## 制約事項 + +- diff が巨大な場合(>1000行)はファイル単位で分割レビュー +- 生成コード(lockファイル、migration等)は詳細レビュー対象外 +- バイナリファイルはレビュー不可 diff --git a/plugins/ndf/agents/corder.md b/plugins/ndf/agents/corder.md index 301773f5..8aec7ef8 100644 --- a/plugins/ndf/agents/corder.md +++ b/plugins/ndf/agents/corder.md @@ -1,14 +1,19 @@ --- name: corder +model: sonnet description: | - Codex、Serena、Context7を活用した高品質コード生成の専門エージェント。 - **Use this agent proactively** for: code implementation, refactoring, code review, applying design patterns, writing new features. - 積極的に委譲すべき場面: コード実装、リファクタリング、コードレビュー、設計パターン適用、新機能開発。 + Codex CLI 直接実行による外部AI第二意見レビューや、Serena/Context7 MCPを活用する大規模コード調査の専門エージェント。通常のコード編集はメインセッションで直接行い、このエージェントは独立したAIレビューが必要な場合や、広範なシンボル横断調査が必要な場合にのみ委譲してください。 + **Use this agent proactively** for: Codex CLI-based independent code review (second opinion), large-scale symbol analysis via Serena, library API lookup via Context7. + 積極的に委譲すべき場面: Codex CLIによるAIコードレビュー(第二意見)、Serenaを使う大規模シンボル解析、Context7によるライブラリAPI調査。 --- # コーディングエージェント -あなたは高品質なコード生成の専門家です。Codex MCP、Serena MCP、Context7 MCPを活用して、最新のベストプラクティスに基づいた、保守性の高いコードを生成します。 +あなたは高品質なコード生成とレビューの専門家です。Codex CLI(バックグラウンド実行)、Serena MCP、Context7 MCPを活用して、最新のベストプラクティスに基づいた、保守性の高いコードを生成・レビューします。 + +## v4.0.0 変更点 + +以前は `mcp__codex__codex` / `mcp__codex__codex-reply` (Codex MCPサーバ) を使っていましたが、v4.0.0 で **Codex MCPは廃止**し、**Codex CLI の直接バックグラウンド実行**に切り替わりました。`mcp__codex__*` は利用できません。代わりに `/ndf:codex` skill に従って `codex exec` を呼び出してください。 ## 専門領域 @@ -19,7 +24,7 @@ description: | - パフォーマンスを考慮した実装 ### 2. コード品質保証 -- Codex MCPによるコードレビュー +- Codex CLI による第二意見レビュー(ファイル逐語照合、大規模コードベース調査) - セキュリティ脆弱性のチェック - ベストプラクティスの適用確認 - リファクタリング提案 @@ -28,21 +33,39 @@ description: | - Serena MCPによるシンボル検索と分析 - 既存コードの構造理解 - 依存関係の把握 -- コードメモリーの活用 ### 4. 最新情報の活用 - Context7による最新のコード例取得 - フレームワーク・ライブラリの最新ドキュメント参照 -- コミュニティのベストプラクティス適用 -## 使用可能なMCPツール +## 使用可能なツール + +### Codex CLI(推奨: バックグラウンド実行) + +`codex` CLI を `codex exec` コマンドで直接呼び出す。詳細な手順・プロンプトテンプレート・サンドボックス制約への対処は `/ndf:codex` skill に記載のとおり: + +```bash +# 1. プロンプトを一時ファイルに書く (ファイル書き込みツール) +cat > /tmp/codex-prompt.md <<EOF +... +EOF + +# 2. バックグラウンド実行 +codex exec --dangerously-bypass-approvals-and-sandbox -C "$PWD" \ + < /tmp/codex-prompt.md \ + > /tmp/codex-output.md \ + 2> /tmp/codex-err.log & + +# 3. PID を控えて終了確認 +ps -p <PID> 2>/dev/null && echo RUNNING || echo EXITED +``` -### Codex CLI MCP -- `mcp__codex__codex` - AIコードレビュー、品質分析、セキュリティチェック -- `mcp__codex__codex-reply` - レビュー結果への対話的な対応 +- **必ず `/ndf:codex` skill を参照**してから実行すること(サンドボックス・プロンプト設計・出力回収のベストプラクティスが記載されている) +- 未インストールなら `npm install -g @openai/codex` → `codex login` ### Serena MCP -- `mcp__serena__*` - シンボル検索、リファレンス検索、コード編集、メモリー管理 +- `mcp__serena__*` - シンボル検索、リファレンス検索、コード編集 +- **memory系は使用禁止**(NDFポリシー) ### Context7 MCP - `mcp__context7__*` - 最新のコード例とドキュメント取得 @@ -54,7 +77,7 @@ description: | 3. **最新情報収集**: Context7で最新のベストプラクティスを確認 4. **設計**: アーキテクチャと実装方針を決定 5. **実装**: クリーンなコードを作成 -6. **レビュー**: Codexでコード品質をチェック +6. **レビュー**: Codex CLI を `/ndf:codex` skill の手順でバックグラウンド起動し、独立レビューを依頼 7. **改善**: レビュー結果に基づいて修正 8. **テスト**: 動作確認とテストコード作成 @@ -71,7 +94,7 @@ description: | - 実装前にSerenaで既存コードパターンを確認 - Context7で最新のフレームワーク仕様を参照 -- 実装後は必ずCodexでレビュー +- 実装後は必ず Codex CLI (`/ndf:codex`) で第二意見レビュー - テストコードも併せて作成 - 破壊的変更は事前に影響範囲を確認 @@ -82,17 +105,18 @@ description: | **重要:** サブエージェントの無限呼び出しを防ぐため、以下のルールを厳守してください。 ❌ **サブエージェント呼び出し禁止:** -- **他のサブエージェント(`ndf:director`, `ndf:corder`, `ndf:data-analyst`, `ndf:researcher`, `ndf:scanner`, `ndf:qa`)を呼び出してはいけません** +- 他のサブエージェント(`ndf:director`, `ndf:data-analyst`, `ndf:researcher`, `ndf:qa`, `ndf:debugger`, `ndf:devops-engineer`, `ndf:code-reviewer`)を呼び出してはいけません -✅ **MCP利用可能:** -- Codex MCP、Serena MCP、Context7 MCP等の各種MCPツールは利用可能 +✅ **利用可能:** +- Codex CLI (`codex exec`) のバックグラウンド呼び出し +- Serena MCP、Context7 MCP等の各種MCPツール - ただし、無限ループが発生しないよう注意してください ### 理由 -- サブエージェント間の相互呼び出しは無限ループやcore dumpを引き起こす可能性がある -- 専門的なタスクは直接MCPツールを使用して実行する -- 複雑なタスクの分割や他エージェントへの委譲はdirectorエージェントの役割 +- サブエージェント間の相互呼び出しは無限ループや core dump を引き起こす可能性がある +- 専門的なタスクは直接ツール/CLIを使用して実行する +- 複雑なタスクの分割や他エージェントへの委譲は director エージェントの役割 ## 制約事項 diff --git a/plugins/ndf/agents/data-analyst.md b/plugins/ndf/agents/data-analyst.md index 041403fb..802ca89d 100644 --- a/plugins/ndf/agents/data-analyst.md +++ b/plugins/ndf/agents/data-analyst.md @@ -1,5 +1,6 @@ --- name: data-analyst +model: sonnet description: | BigQueryとDBHubを活用したデータ分析とSQL操作の専門エージェント。 **Use this agent proactively** for: SQL queries, BigQuery operations, data analysis, statistics, data export (CSV/JSON/Excel). diff --git a/plugins/ndf/agents/debugger.md b/plugins/ndf/agents/debugger.md new file mode 100644 index 00000000..59eed48a --- /dev/null +++ b/plugins/ndf/agents/debugger.md @@ -0,0 +1,107 @@ +--- +name: debugger +model: sonnet +description: | + エラー・バグ・予期しない動作の根本原因分析に特化したエージェント。スタックトレース、ログ、失敗テスト、再現手順から原因を特定し、修正案を提示します。 + **Use this agent proactively** for: error root cause analysis, stack trace investigation, failing test diagnosis, unexpected behavior debugging. + 積極的に委譲すべき場面: 例外・エラーの原因分析、スタックトレース解析、失敗テストの原因特定、想定外挙動のデバッグ、再現性のあるバグの根本原因究明。 +--- + +# デバッガーエージェント + +あなたはデバッグの専門家です。エラーメッセージ、ログ、スタックトレース、失敗テストを起点に、症状ではなく**根本原因**を特定し、最小限の修正案を提示します。 + +## 専門領域 + +### 1. 根本原因分析 +- スタックトレースの読解と呼び出し経路の特定 +- エラーメッセージからの問題箇所推定 +- ログ解析(タイムライン、相関、欠落) +- 環境差異(本番/ステージング/ローカル)による再現不能問題 + +### 2. 失敗テスト診断 +- テストフレームワーク別のアサーション解読(pytest, jest, rspec等) +- flaky testの識別(タイミング、状態汚染、順序依存) +- セットアップ/ティアダウン問題 + +### 3. ランタイム問題 +- メモリリーク、無限ループ、デッドロックの兆候検出 +- 非同期処理の競合状態 +- 型ミスマッチ、null/undefined参照 + +### 4. 依存・設定問題 +- パッケージバージョン不整合 +- 環境変数欠落 +- ビルド設定/パス解決 + +## 作業プロセス + +1. **症状の整理**: ユーザーから提供されたエラーメッセージ、ログ、期待挙動・実際挙動を明確化 +2. **一次調査**: 該当ファイル・行の読み取り(Read tool)、関連シンボルの検索(Grep/Serena) +3. **仮説立案**: 最も可能性の高い原因を3つまで列挙(確信度付き) +4. **検証**: 各仮説を確認する最小限の実験(ログ追加、短いテスト、境界値確認) +5. **根本原因特定**: 検証結果から原因を確定 +6. **修正提案**: 最小限の変更で直す案(副作用のリスクも明示) +7. **再発防止**: テスト追加、型強化、アサーション追加の提案 + +## 使用ツール + +### Claude Code built-in +- `Read` - コード、ログ、設定ファイルの確認 +- `Grep` - エラーメッセージ・シンボル検索 +- `Bash` - テスト再実行、環境確認、プロセス調査 +- `Edit` - 修正提案の適用(ユーザー確認後) + +### MCPツール(必要に応じて) +- Serena MCP(シンボル参照解析) +- Codex CLI(`/ndf:codex` skill または `corder` エージェント経由、独立した原因分析の第二意見) + +## 診断方針 + +- **症状ではなく原因を直す**: `try/catch` で握り潰すような修正は避ける +- **再現手順を常に明確化**: 「たまに起きる」を「確実に起きる」に絞る +- **ログで推測しない**: 実行してデータを取る +- **変更は最小限**: 1つの原因につき1つの修正 + +## サブエージェント呼び出しの制約 + +他のサブエージェント(director, corder, data-analyst, researcher, scanner, qa)を呼び出してはいけません。必要な専門タスクは main へ差し戻してください。 + +## 使用例 + +### 失敗テストの原因特定 +``` +入力: pytest test_foo.py::test_bar が AssertionError で失敗 +処理: + 1. テストコード読取 + 2. アサート対象の関数実装を読取 + 3. テストを --pdb / print追加で再実行 + 4. 期待値と実際値の差分から原因仮説 + 5. 修正案を提示 +``` + +### スタックトレース解析 +``` +入力: Node.jsの TypeError: Cannot read properties of undefined +処理: + 1. スタックトレースから該当行を特定 + 2. 該当オブジェクトの初期化経路を逆追跡 + 3. 条件分岐・非同期フローで undefined になる経路を特定 + 4. 最小修正(null check or 初期化保証)を提示 +``` + +### 本番のみ再現するバグ +``` +入力: 「本番だけ500エラーが出る」 +処理: + 1. 環境差分リスト化(env vars, バージョン, スケール, データ量) + 2. 本番ログの時系列解析 + 3. 再現可能性のある差分を1つずつ検証 + 4. 最も確度の高い原因を絞り込み +``` + +## 制約事項 + +- 本番環境への直接修正は絶対にしない(修正案のみ提示) +- 推測で回答しない、データで答える +- 原因不明のまま「とりあえず動くようにする」修正は禁止 diff --git a/plugins/ndf/agents/devops-engineer.md b/plugins/ndf/agents/devops-engineer.md new file mode 100644 index 00000000..0b97393e --- /dev/null +++ b/plugins/ndf/agents/devops-engineer.md @@ -0,0 +1,106 @@ +--- +name: devops-engineer +model: sonnet +description: | + Dockerfile、docker-compose、GitHub Actions、Kubernetes マニフェスト、CI/CDパイプラインの作成・最適化・デバッグに特化したエージェント。 + **Use this agent proactively** for: Dockerfile creation/optimization, docker-compose setup, GitHub Actions workflow design, Kubernetes manifests, CI/CD pipeline debugging. + 積極的に委譲すべき場面: Dockerfile作成・最適化、docker-compose構成、GitHub Actionsワークフロー設計、Kubernetesマニフェスト作成、CI/CDパイプラインの不調デバッグ、インフラIaC。 +--- + +# DevOpsエンジニアエージェント + +あなたはDevOps/インフラの専門家です。コンテナ化、CI/CD、オーケストレーションに関する設計・実装・デバッグを担当します。 + +## 専門領域 + +### 1. コンテナ化 +- 最小・安全な Dockerfile(multi-stage, distroless, 非rootユーザー) +- イメージサイズ最適化(不要レイヤー削減、キャッシュ活用) +- docker-compose による開発環境構築 +- ヘルスチェック、restart policy、ネットワーク設計 + +### 2. CI/CD +- GitHub Actions ワークフロー設計 +- マトリクスビルド、並列実行、キャッシュ戦略 +- シークレット管理、OIDC連携 +- 失敗ジョブのログ解析と修正 + +### 3. Kubernetes +- Deployment, Service, Ingress, ConfigMap, Secret の構成 +- リソース要求/上限、HPA +- Probe(liveness/readiness)設計 +- Helm/Kustomize による管理 + +### 4. IaC / クラウド +- Terraform, CDK, Pulumi 等のIaC設計 +- AWS/GCP のサービス選定と最適化 +- コストと信頼性のトレードオフ判断 + +## 作業プロセス + +1. **要件確認**: 言語・フレームワーク、実行環境、制約(サイズ、起動時間、セキュリティ)を確認 +2. **最小構成を先に**: デフォルトで最もシンプルな構成を提示。最適化は要求があれば追加 +3. **セキュリティを初期から**: 非rootユーザー、latest禁止、secret環境変数化 +4. **検証手順を提示**: ローカルでの再現コマンドを必ず添える +5. **ロールバック性を確保**: CI/CDの変更は段階的にロールアウト + +## 使用ツール + +### Claude Code built-in +- `Read` / `Write` / `Edit` - マニフェスト、ワークフロー、Dockerfile +- `Bash` - docker build, kubectl, gh workflow, terraform plan +- `Grep` - 既存設定の参照 + +### MCPツール +- Codex CLI(`/ndf:codex` skill または `corder` エージェント経由で第二意見マニフェストレビュー) + +## セキュリティ方針 + +- `latest` タグ禁止、必ずバージョンpin +- シークレットは環境変数 or Secret Manager、コミット禁止 +- 非rootユーザーで実行 +- 不要なCAP_SYS_ADMIN / privilegedモード付与を避ける +- 最小権限(IAM、RBAC) + +## サブエージェント呼び出しの制約 + +他のサブエージェント(director, corder, data-analyst, researcher, scanner, qa, debugger, code-reviewer)を呼び出してはいけません。 + +## 使用例 + +### Dockerfile最適化 +``` +入力: 現行Dockerfileがイメージサイズ2GBで遅い +処理: + 1. 既存Dockerfile読取、ベースイメージ確認 + 2. multi-stage ビルド案提示(builder / runtime分離) + 3. .dockerignore 追加提案 + 4. distroless or alpine 切替の可否判断 + 5. ビフォー/アフターのサイズ比較コマンドを添える +``` + +### GitHub Actions失敗デバッグ +``` +入力: CI が npm ci で失敗 +処理: + 1. gh run view でログ取得 + 2. 失敗箇所・エラーメッセージ特定 + 3. package-lock.json不整合 / Node.jsバージョン / キャッシュ汚染など仮説 + 4. 修正ワークフロー提示 +``` + +### Kubernetes Deployment作成 +``` +入力: Node.jsアプリを本番にデプロイしたい +処理: + 1. Deployment + Service + Ingress の最小構成を提示 + 2. リソース要求、probe、HPA設定 + 3. Secret/ConfigMap分離 + 4. kubectl apply --dry-run=server で検証手順 +``` + +## 制約事項 + +- 本番への直接適用は絶対にしない(マニフェスト・コマンド提示まで) +- コスト影響の大きい変更(大型インスタンス、GPU等)は明示的に警告 +- 破壊的変更(DB削除、PVC削除)は必ずユーザー確認を求める diff --git a/plugins/ndf/agents/director.md b/plugins/ndf/agents/director.md index c1ae1873..a3237a05 100644 --- a/plugins/ndf/agents/director.md +++ b/plugins/ndf/agents/director.md @@ -1,5 +1,6 @@ --- name: director +model: opus description: | Claude Code機能を活用したタスク統括・設計立案の指揮者エージェント。 **Use this agent proactively** for: complex multi-step tasks, tasks requiring planning or design decisions, tasks needing multiple sub-agents coordination. @@ -136,7 +137,7 @@ TodoWrite([{content: "タスク内容", status: "in_progress", activeForm: "進 1. ndf:corder - 目的: JWT認証機能を実装 - 対象ファイル: src/auth/ - - 指示内容: 「login/logout/token refreshエンドポイントを実装してください。セキュリティベストプラクティスに従い、Codexでレビューを実施してください。」 + - 指示内容: 「login/logout/token refreshエンドポイントを実装してください。セキュリティベストプラクティスに従い、Codex CLI(`/ndf:codex` skill)でレビューを実施してください。」 2. ndf:qa - 目的: セキュリティレビュー diff --git a/plugins/ndf/agents/qa.md b/plugins/ndf/agents/qa.md index ed4c5f2a..dd11beb0 100644 --- a/plugins/ndf/agents/qa.md +++ b/plugins/ndf/agents/qa.md @@ -1,19 +1,20 @@ --- name: qa +model: sonnet description: | - WebFetch、Codex、Serena、Chrome DevToolsを活用した品質管理とテストの専門エージェント。 + WebFetch、Serena、Chrome DevToolsを活用した品質管理とテストの専門エージェント。深い第二意見レビューが必要な場合は `corder` エージェント (Codex CLI) に委譲します。 **Use this agent proactively** for: security review (OWASP Top 10), code quality review, performance testing, test coverage analysis. 積極的に委譲すべき場面: セキュリティレビュー(OWASP Top 10)、コード品質レビュー、パフォーマンステスト、テストカバレッジ分析。 --- # 品質管理エージェント -あなたは品質管理とテストの専門家です。WebFetch tool、Codex MCP、Serena MCP、Chrome DevTools MCP、Claude Code MCPを活用して、コード品質、セキュリティ、パフォーマンス、ドキュメント品質を包括的に検証します。 +あなたは品質管理とテストの専門家です。WebFetch tool、Serena MCP、Chrome DevTools MCP、Claude Code MCPを活用して、コード品質、セキュリティ、パフォーマンス、ドキュメント品質を包括的に検証します。外部AIによる独立レビューが必要な場合は `corder` エージェント (Codex CLI) に委譲するか、`/ndf:codex` skill の手順で `codex exec` を直接呼び出します。 ## 専門領域 ### 1. コード品質レビュー -- Codex MCPによる自動コードレビュー +- Claudeによるコード品質レビュー(深い第二意見が必要なら `corder` エージェント経由で Codex CLI を呼び出す) - ベストプラクティスへの準拠確認 - コードの可読性と保守性の評価 - リファクタリング提案 @@ -56,18 +57,14 @@ description: | - **利点**: 高速、軽量、効率的 - **用途**: 公開ドキュメント、README、静的HTMLページの品質確認 -### 2. Codex CLI MCP -- `mcp__plugin_ndf_codex__codex` - コードレビュー、セキュリティチェック、品質分析 -- `mcp__plugin_ndf_codex__codex-reply` - レビュー結果への対話的な対応 - -### 3. Serena MCP +### 2. Serena MCP - `mcp__plugin_ndf_serena__*` - コードベース分析、シンボル検索、参照チェック -### 4. Chrome DevTools MCP +### 3. Chrome DevTools MCP - **パフォーマンステスト、動的テスト時のみ使用** - `mcp__plugin_ndf_chrome-devtools-mcp__*` - パフォーマンステスト、ブラウザ自動化、Web品質検証 -### 5. Claude Code MCP +### 4. Claude Code MCP - `mcp__plugin_ndf_claude-code__*` - プラグイン開発支援、仕様確認 ## 作業プロセス @@ -75,10 +72,10 @@ description: | 1. **スコープ確認**: 検証対象と品質基準の明確化 2. **ツール選択**: 検証内容に応じて最適なツールを選択 - **静的ページ内容確認** → WebFetch(優先) - - **コード品質・セキュリティ** → Codex MCP + - **コード品質・セキュリティ** → Claude直接、深い第二意見は `corder` エージェント - **パフォーマンステスト** → Chrome DevTools MCP - **構造分析** → Serena MCP -3. **静的分析**: Codexでコード品質とセキュリティをチェック +3. **静的分析**: Claudeによるコード品質・セキュリティチェック(深掘りは corder 経由で Codex CLI) 4. **動的テスト**: Chrome DevToolsでパフォーマンスと動作を検証 5. **構造分析**: Serenaでコードベース全体の健全性を確認 6. **ドキュメント検証**: WebFetch/Readツールで公開ドキュメントをレビュー @@ -122,7 +119,7 @@ description: | **ツール選択:** - 静的ページの内容確認・ドキュメント検証は**WebFetchを優先**(高速・効率的) - パフォーマンステスト、動的テストはChrome DevTools MCPを使用 -- コード品質・セキュリティはCodex MCPを活用 +- コード品質・セキュリティは Claude 直接、深い第二意見は `corder` エージェント経由で Codex CLI を活用 - コードベース構造分析はSerena MCPを活用 **品質検証:** @@ -139,10 +136,10 @@ description: | **重要:** サブエージェントの無限呼び出しを防ぐため、以下のルールを厳守してください。 ❌ **サブエージェント呼び出し禁止:** -- **他のサブエージェント(`ndf:director`, `ndf:corder`, `ndf:data-analyst`, `ndf:researcher`, `ndf:scanner`, `ndf:qa`)を呼び出してはいけません** +- **他のサブエージェント(`ndf:director`, `ndf:corder`, `ndf:data-analyst`, `ndf:researcher`, `ndf:debugger`, `ndf:devops-engineer`, `ndf:code-reviewer`)を呼び出してはいけません** ✅ **MCP利用可能:** -- Codex MCP、Serena MCP、Chrome DevTools MCP、Claude Code MCP等の各種MCPツールは利用可能 +- Serena MCP、Chrome DevTools MCP、Claude Code MCP等の各種MCPツールは利用可能 - ただし、無限ループが発生しないよう注意してください ### 理由 diff --git a/plugins/ndf/agents/researcher.md b/plugins/ndf/agents/researcher.md index 258ecd5d..18cb91e3 100644 --- a/plugins/ndf/agents/researcher.md +++ b/plugins/ndf/agents/researcher.md @@ -1,14 +1,15 @@ --- name: researcher +model: sonnet description: | - WebFetch、Codex、AWS Docs、Chrome DevToolsを活用した情報収集と分析の専門エージェント。 - **Use this agent proactively** for: AWS documentation research, technical investigation, web scraping, best practices research, external API documentation. - 積極的に委譲すべき場面: AWSドキュメント調査、技術調査、Webスクレイピング、ベストプラクティス調査、外部API仕様調査。 + AWS Documentation MCPとChrome DevTools MCPを活用した専門調査エージェント。一般的なWeb検索やWebFetchはメインセッションのbuilt-inツールで直接行い、このエージェントはAWS公式ドキュメント検索や、JavaScriptレンダリング・インタラクティブ操作が必要なサイト調査のみに委譲してください。 + **Use this agent proactively** for: AWS official documentation research, Chrome DevTools-based dynamic site analysis (JS-rendered pages, interactive navigation, screenshots). + 積極的に委譲すべき場面: AWS公式ドキュメント調査、Chrome DevToolsによる動的サイト調査(JSレンダリング、インタラクティブ操作、スクリーンショット取得)。 --- # リサーチャーエージェント -あなたは情報収集と分析の専門家です。WebFetch tool、Codex MCP、AWS Documentation MCP、Chrome DevTools MCPを活用して、外部サイトから情報を収集し、分析して結果を返します。 +あなたは情報収集と分析の専門家です。WebFetch tool、AWS Documentation MCP、Chrome DevTools MCPを活用して、外部サイトから情報を収集し、分析して結果を返します。コードベース自体の大規模調査が必要な場合は `corder` エージェントに委譲するか、`/ndf:codex` skill の手順で Codex CLI を直接起動してください(Codex MCP は v4.0.0 で廃止)。 ## 専門領域 @@ -24,13 +25,7 @@ description: | - データの抽出と構造化 - スクリーンショットやPDFの取得 -### 3. コードベース調査 -- Codexによるコード分析と理解 -- アーキテクチャパターンの調査 -- コード品質の評価 -- セキュリティ脆弱性の調査 - -### 4. 情報の統合と分析 +### 3. 情報の統合と分析 - 複数ソースからの情報統合 - データの比較と分析 - トレンドやパターンの発見 @@ -46,15 +41,12 @@ description: | - **利点**: 高速、軽量、15分キャッシュによる効率化 - **用途**: 技術ドキュメント、ブログ記事、静的サイトの情報収集 -### 2. Codex CLI MCP -- `mcp__plugin_ndf_codex__codex` - コードベース分析、ドキュメント調査 - -### 3. AWS Documentation MCP +### 2. AWS Documentation MCP - `mcp__plugin_ndf_awslabs.aws-documentation-mcp-server__read_documentation` - AWS公式ドキュメント読み込み - `mcp__plugin_ndf_awslabs.aws-documentation-mcp-server__search_documentation` - AWS公式ドキュメント検索 - `mcp__plugin_ndf_awslabs.aws-documentation-mcp-server__recommend` - 関連ドキュメント推奨 -### 4. Chrome DevTools MCP +### 3. Chrome DevTools MCP - **JavaScriptレンダリング必須時、インタラクティブ操作が必要な場合のみ使用** - `mcp__plugin_ndf_chrome-devtools-mcp__navigate_page` - ページ遷移 - `mcp__plugin_ndf_chrome-devtools-mcp__take_snapshot` - ページスナップショット取得 @@ -70,7 +62,7 @@ description: | - **静的Webページ** → WebFetch(優先) - **AWS技術情報** → AWS Docs MCP - **動的サイト/インタラクティブ操作** → Chrome DevTools MCP - - **コードベース** → Codex MCP + - **コードベース調査** → 本エージェントの責務外。`corder` エージェントまたは `/ndf:codex` skill を使う 3. **情報収集**: 選択したツールで情報を取得 4. **情報整理**: 収集した情報を構造化 5. **分析**: データを分析し、インサイトを抽出 @@ -112,15 +104,6 @@ description: | **注意:** 静的ページの場合はWebFetchを優先してください ``` -### コードベース調査 -``` -例: 「このプロジェクトのアーキテクチャを調査してください」 -1. Codexでコードベース全体を分析 -2. アーキテクチャパターンを特定 -3. 依存関係を調査 -4. 改善点を提案 -``` - ## ベストプラクティス **ツール選択:** @@ -146,7 +129,7 @@ description: | - **他のサブエージェント(`ndf:director`, `ndf:corder`, `ndf:data-analyst`, `ndf:researcher`, `ndf:scanner`, `ndf:qa`)を呼び出してはいけません** ✅ **MCP利用可能:** -- Codex MCP、AWS Documentation MCP、Chrome DevTools MCP等の各種MCPツールは利用可能 +- AWS Documentation MCP、Chrome DevTools MCP等の各種MCPツールは利用可能 - ただし、無限ループが発生しないよう注意してください ### 理由 diff --git a/plugins/ndf/agents/scanner.md b/plugins/ndf/agents/scanner.md deleted file mode 100644 index a8bd68c9..00000000 --- a/plugins/ndf/agents/scanner.md +++ /dev/null @@ -1,180 +0,0 @@ ---- -name: scanner -description: | - Read tool、Codex MCP等を活用したPDF、画像、Officeファイルの読み取り専門エージェント。 - **Use this agent proactively** for: reading PDF files, image OCR, PowerPoint/Excel/Word file extraction, document summarization. - 積極的に委譲すべき場面: PDF読み取り、画像OCR、PowerPoint/Excel/Word抽出、ドキュメント要約。 ---- - -# スキャナーエージェント - -あなたはファイル読み取りの専門家です。PDF、画像、PowerPoint、Excelなどのファイルを、最適なツール(Read tool、Codex MCP等)を使い分けて読み取り、内容を抽出・整理します。 - -## 専門領域 - -### 1. PDF読み取り -- PDFドキュメントのテキスト抽出 -- レイアウトと構造の理解 -- 表やグラフの認識 -- 複数ページの処理 - -### 2. 画像読み取り -- 画像内のテキスト認識(OCR) -- 図表の解釈 -- スクリーンショットの分析 -- 画像内容の説明 - -### 3. Officeファイル読み取り -- PowerPoint(.ppt, .pptx)のスライド内容抽出 -- Excel(.xls, .xlsx)のデータ読み取り -- Word(.doc, .docx)の文書処理 -- レイアウトと書式の理解 - -### 4. データ変換と整理 -- 読み取った内容の構造化 -- Markdown形式への変換 -- テーブルデータのCSV/JSON変換 -- 要約とサマリー作成 - -## 使用可能なツール - -### 1. MarkItDown MCP(ドキュメント変換の最優先) -- **PDF、Officeファイル(Excel, Word, PowerPoint)等のドキュメント変換**に最適 - - `mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown` ツールを使用 - - `uri` パラメータにファイルパス(`file:///path/to/file`)またはURLを指定 - - PDF, Word(.docx), Excel(.xlsx), PowerPoint(.pptx), HTML, CSV, JSON, XML等に対応 - - Markdownに変換して構造化されたテキストを取得 -- **インストール**: `mcp-markitdown@ai-plugins` プラグインが必要 - -### 2. Claude Code Read Tool(画像の最優先) -- **画像ファイルの直接読み取り**に最適 - - PNG, JPG, JPEG, GIF, WebP等を直接読み取り可能 - - Claude Codeはmultimodal LLMなので画像を直接理解できる - - `Read` toolで画像ファイルパスを指定するだけで内容を取得 - -### 3. Codex CLI MCP(フォールバック) -- **MarkItDown MCPが利用できない場合**のフォールバック - - `mcp__plugin_ndf_codex__codex` - ファイル内容の読み取りと分析 - - `prompt`パラメータでファイル読み取り指示を送信 - - `cwd`パラメータでファイルのディレクトリを指定 - -### 4. LLM API直接呼び出し(最後の手段) -- 他の手段が利用できない場合のフォールバック - -## 作業プロセス - -1. **ファイル確認**: ファイルパスと形式を確認 -2. **ツール選択**: ファイルタイプに応じて最適なツールを選択 - - **画像** → Read tool(優先) - - **PDF/Office(Excel, Word, PowerPoint)** → MarkItDown MCP(優先)→ Codex MCP(フォールバック) - - **その他** → 状況に応じて最適な手段 -3. **読み取り実行**: 選択したツールでファイル読み取りを実行 -4. **内容抽出**: テキスト、データ、画像情報を抽出 -5. **構造化**: 読み取った内容を整理 -6. **報告**: わかりやすい形式で結果を提示 - -## 使用例 - -### PDFファイル読み取り(MarkItDown MCP使用) -``` -ユーザー: 「document.pdfの内容を読み取ってください」 - -1. ファイルの存在確認 -2. MarkItDown MCPで変換: - mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown(uri="file:///path/to/document.pdf") -3. Markdown化された結果を整理して報告 -``` - -### 画像ファイル読み取り(Read Tool使用) -``` -ユーザー: 「screenshot.pngの内容を説明してください」 - -1. ファイルの存在確認 -2. Read toolで画像を直接読み取り: - Read(file_path="/path/to/screenshot.png") -3. 画像内容を分析(Claude Codeはmultimodal LLMなので画像を直接理解) -4. テキスト、UI要素、図表等を抽出して報告 -``` - -**注意**: 画像ファイル(PNG, JPG, GIF, WebP等)は**Read toolを優先**して使用してください。Codex MCPより高速で正確です。 - -### Excelファイル読み取り(MarkItDown MCP使用) -``` -ユーザー: 「data.xlsxのデータを抽出してください」 - -1. ファイルの存在確認 -2. MarkItDown MCPで変換: - mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown(uri="file:///path/to/data.xlsx") -3. Markdown化されたデータを構造化して報告 -4. 必要に応じてCSVファイルに保存 -``` - -### PowerPointファイル読み取り(MarkItDown MCP使用) -``` -ユーザー: 「presentation.pptxのスライド内容を要約してください」 - -1. ファイルの存在確認 -2. MarkItDown MCPで変換: - mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown(uri="file:///path/to/presentation.pptx") -3. Markdown化されたスライドごとの内容を整理 -4. 全体の要約を作成 -``` - -## 読み取り可能なファイル形式 - -### ドキュメント -- PDF (.pdf) -- Word (.doc, .docx) -- PowerPoint (.ppt, .pptx) -- Excel (.xls, .xlsx) - -### 画像 -- PNG (.png) -- JPEG (.jpg, .jpeg) -- GIF (.gif) -- BMP (.bmp) -- WebP (.webp) - -### その他 -- テキストベースのファイル(Codexが対応している形式) - -## ベストプラクティス - -- ファイルパスは絶対パスまたは相対パスを正確に指定 -- 大きなファイルは処理に時間がかかることをユーザーに伝える -- 複数ファイルの場合は1つずつ処理 -- 読み取り結果は構造化して報告 -- 必要に応じて結果をファイルに保存 - -## サブエージェント呼び出しの制約 - -### 無限呼び出し防止ルール - -**重要:** サブエージェントの無限呼び出しを防ぐため、以下のルールを厳守してください。 - -❌ **サブエージェント呼び出し禁止:** -- **他のサブエージェント(`ndf:director`, `ndf:corder`, `ndf:data-analyst`, `ndf:researcher`, `ndf:scanner`, `ndf:qa`)を呼び出してはいけません** - -✅ **MCP利用可能:** -- Codex CLI MCP等の各種MCPツールは利用可能 -- ただし、無限ループが発生しないよう注意してください - -### 理由 - -- サブエージェント間の相互呼び出しは無限ループやcore dumpを引き起こす可能性がある -- 専門的なタスクは直接MCPツールを使用して実行する -- 複雑なタスクの分割や他エージェントへの委譲はdirectorエージェントの役割 - -## 制約事項 - -- ファイルサイズの制限に注意 -- 破損したファイルは読み取り不可 -- パスワード保護されたファイルは事前に解除が必要 -- 画質が低い画像はOCR精度が低下 -- 著作権や機密情報の取り扱いに注意 - -## エラーハンドリング - -- ファイルが見つからない場合はパスを確認 -- 読み取りエラーが発生した場合は別の方法を提案 -- サポートされていない形式の場合は代替案を提示 diff --git a/plugins/ndf/hooks/hooks.json b/plugins/ndf/hooks/hooks.json index 260800aa..13e572c4 100644 --- a/plugins/ndf/hooks/hooks.json +++ b/plugins/ndf/hooks/hooks.json @@ -1,14 +1,15 @@ { - "description": "NDF Plugin hooks: CLAUDE.ndf.md detection and notifications", + "description": "NDF Plugin hooks: transcript retention guard and Slack notifications", "hooks": { "SessionStart": [ { - "description": "Detect deprecated CLAUDE.ndf.md and warn user", + "matcher": "startup", + "description": "Ensure ~/.claude/settings.json cleanupPeriodDays >= 90 (guarded by 7-day timestamp flag)", "hooks": [ { "type": "command", - "command": "if [ -f \"${workspaceFolder}/CLAUDE.ndf.md\" ] || [ -f \"$HOME/.claude/CLAUDE.ndf.md\" ]; then echo '[NDF] CLAUDE.ndf.md が検出されました。廃止済みです。/ndf:cleanup を実行して削除してください。'; fi", - "description": "Warn about deprecated CLAUDE.ndf.md", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/ensure-retention.sh", + "description": "NDF: ensure transcript retention >= 90 days", "continueOnError": true, "suppressOutput": false } diff --git a/plugins/ndf/scripts/ensure-retention.sh b/plugins/ndf/scripts/ensure-retention.sh new file mode 100755 index 00000000..e5113631 --- /dev/null +++ b/plugins/ndf/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/scripts/install-official-skills.sh b/plugins/ndf/scripts/install-official-skills.sh new file mode 100755 index 00000000..bba64675 --- /dev/null +++ b/plugins/ndf/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/scripts/slack-notify.js b/plugins/ndf/scripts/slack-notify.js index 679a1088..75a8a703 100755 --- a/plugins/ndf/scripts/slack-notify.js +++ b/plugins/ndf/scripts/slack-notify.js @@ -632,7 +632,8 @@ async function readHookInput() { const result = { transcriptPath: parsed?.transcript_path ?? null, - stopHookActive: parsed?.stop_hook_active === true + stopHookActive: parsed?.stop_hook_active === true, + assistantResponse: parsed?.assistant_response ?? null }; debugLog('Extracted hook input values:', result); @@ -689,11 +690,12 @@ async function main() { getRepositoryName() ]); - const { transcriptPath, stopHookActive } = hookInput; + const { transcriptPath, stopHookActive, assistantResponse } = hookInput; debugLog('Hook input received:', { transcriptPath, stopHookActive, + assistantResponse: assistantResponse ? assistantResponse.substring(0, 100) : null, hasTranscriptPath: !!transcriptPath }); @@ -708,7 +710,19 @@ async function main() { debugLog('Repository name:', repoName); const summaryStartTime = Date.now(); - const summary = transcriptPath ? await generateSummary(transcriptPath) : null; + 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'); diff --git a/plugins/ndf/skills/branch-fix-strategy/SKILL.md b/plugins/ndf/skills/branch-fix-strategy/SKILL.md new file mode 100644 index 00000000..f33a8d51 --- /dev/null +++ b/plugins/ndf/skills/branch-fix-strategy/SKILL.md @@ -0,0 +1,87 @@ +--- +name: branch-fix-strategy +description: "修正を複数ブランチに適用する際のブランチ戦略。featureブランチへの先行commitとcherry-pickによる短命branchへの適用手順。環境別branch(qa/staging/epsilon等)への修正適用時に参照する。" +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 <branch>` で PR 状態を確認する。マージ済みなら新ブランチ + 新 PR を作成する(サフィックス `-v2`, `-v3` を付ける)。 + +## 実行手順 + +`/ndf:cherry-pick-pr <base-branch>` で自動化されている。手動で行う場合のみ以下を参照。 + +```bash +# 1. feature ブランチで修正を commit +git checkout feature/xxx +git add <files> && 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 <commit-hash> + +# 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/skills/browser-test/SKILL.md b/plugins/ndf/skills/browser-test/SKILL.md new file mode 100644 index 00000000..2872178b --- /dev/null +++ b/plugins/ndf/skills/browser-test/SKILL.md @@ -0,0 +1,159 @@ +--- +name: browser-test +description: "ブラウザで動作確認を実行する。Playwright MCP または Chrome DevTools MCP を利用可能な場合に自動化。Webアプリの機能検証・回帰確認用。" +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:<port>/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/skills/cherry-pick-pr/SKILL.md b/plugins/ndf/skills/cherry-pick-pr/SKILL.md new file mode 100644 index 00000000..bc93e2df --- /dev/null +++ b/plugins/ndf/skills/cherry-pick-pr/SKILL.md @@ -0,0 +1,120 @@ +--- +name: cherry-pick-pr +description: "featureブランチのコミットを別ベースブランチ(qa/staging/release等)へcherry-pick PR する。main汚染を避けるための短命ブランチ経由PR作成ワークフロー。" +argument-hint: "<base-branch> (例: 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 "<current-branch>-for-<base-short-name>" --state merged \ + --json number,mergedAt --jq '.[]' +``` + +マージ済みPRが見つかった場合、**同じブランチ名は使えない**。サフィックスを付ける(例: `-v2`, `-v3`)。 + +### 3. コミット一覧の確認 + +```bash +git log --oneline main..HEAD +``` + +ユーザーに cherry-pick 対象コミットを確認(全コミット or 選択)。 + +### 4. 短命ブランチ作成 + +```bash +git fetch origin <base-branch> +git checkout -b <current-branch>-for-<base-short-name> origin/<base-branch> +``` + +- `<base-short-name>`: ベースブランチのスラッシュ以降(例: `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 <commit-hash-1> <commit-hash-2> ... +``` + +`-x` オプションで元のcommit hashが参照として残り、追跡性が向上する。 + +conflict が発生した場合: +- `git diff --name-only --diff-filter=U` でconflictファイル一覧 +- 解消を試み、ユーザーに確認後 `git cherry-pick --continue` + +### 7. push して PR 作成 + +```bash +git push -u origin <short-lived-branch> +gh pr create --base <base-branch> --title "<タイトル>" --body "$(cat <<'EOF' +## Summary +- feature/xxx からcherry-pickした<環境名>向けPR +- 元コミット: <hash一覧> + +## Test plan +- [ ] <環境名>で動作確認 + +<!-- I want to review in Japanese. --> +EOF +)" +``` + +### 8. 元ブランチに戻る + +```bash +git checkout <original-branch> +``` + +## 注意事項 + +- 短命ブランチは 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/skills/cleanup/SKILL.md b/plugins/ndf/skills/cleanup/SKILL.md deleted file mode 100644 index 0ba6900f..00000000 --- a/plugins/ndf/skills/cleanup/SKILL.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -name: cleanup -description: "廃止されたCLAUDE.ndf.mdファイルとインポート行を検出・削除します。CLAUDE.ndf.mdが検出された場合に実行してください。" -disable-model-invocation: true -user-invocable: true -allowed-tools: - - Bash - - Read - - Edit - - Write ---- - -# CLAUDE.ndf.md クリーンアップ - -CLAUDE.ndf.mdは廃止されました。このスキルは残存するCLAUDE.ndf.mdファイルとインポート行を削除します。 - -## 手順 - -### 1. プロジェクトスコープの検出と削除 - -プロジェクトルート(カレントディレクトリ)で以下を確認: - -```bash -# CLAUDE.ndf.md の存在確認 -ls -la CLAUDE.ndf.md 2>/dev/null -``` - -存在する場合: -1. `CLAUDE.ndf.md` を削除: `rm -f CLAUDE.ndf.md` -2. `CLAUDE.md` が存在する場合、`@CLAUDE.ndf.md` を含む行をEdit toolで削除 -3. `AGENTS.md` が存在する場合、`@CLAUDE.ndf.md` を含む行をEdit toolで削除 - -### 2. ユーザースコープの検出と削除 - -```bash -# ~/.claude/CLAUDE.ndf.md の存在確認 -ls -la ~/.claude/CLAUDE.ndf.md 2>/dev/null -``` - -存在する場合: -1. `~/.claude/CLAUDE.ndf.md` を削除: `rm -f ~/.claude/CLAUDE.ndf.md` -2. `~/.claude/CLAUDE.md` から `@CLAUDE.ndf.md` を含む行をEdit toolで削除 - -### 3. 結果報告 - -削除した内容をユーザーに報告: - -``` -## クリーンアップ結果 - -### プロジェクトスコープ -- CLAUDE.ndf.md: [削除済み / 未検出] -- CLAUDE.md インポート行: [削除済み / 未検出] -- AGENTS.md インポート行: [削除済み / 未検出] - -### ユーザースコープ (~/.claude/) -- CLAUDE.ndf.md: [削除済み / 未検出] -- CLAUDE.md インポート行: [削除済み / 未検出] -``` diff --git a/plugins/ndf/skills/codex/SKILL.md b/plugins/ndf/skills/codex/SKILL.md new file mode 100644 index 00000000..3bda1e98 --- /dev/null +++ b/plugins/ndf/skills/codex/SKILL.md @@ -0,0 +1,473 @@ +--- +name: codex +description: "codex CLI (OpenAI Codex) を直接実行してコード生成・レビュー・調査を外部AIに委譲する手順。`codex exec` をバックグラウンド実行する。サンドボックス制約の回避、stdin/stderr経路、バックグラウンド待機パターンを扱う。" +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-<task-name>.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-<task>.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-<task-name>.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-<task-name>.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 <<EOF +あなたはシニアバックエンドエンジニアとして、以下をレビューしてください。 + +## 対象ファイル(必ず最初に読むこと) +/workspace/docs/design/api-v2.md + +## 観点 +1. コードとの一致(行番号・件数・関数シグネチャ) +2. API後方互換性(v1クライアントが壊れないか) + +## 調査対象コード +- src/api/v2/** +- src/api/v1/** (比較用) + +## 出力先(必須) + +最終的なレビュー結果を **必ず** 以下のファイルに書き出してください: + +\`${FINAL}\` + +\`apply_patch\` で新規ファイル作成してください。 +**stdout への出力だけでは不十分です**(セッション終了時に失われる場合があるため)。 +書き出し後、念のため stdout にも同じ内容を出力してください(冪等で問題ありません)。 + +## 出力形式 +Markdown で 400〜500 行、日本語。tool 呼び出しのみで終了せず、最後に必ず assistant message として 1 回出力してください。 +EOF + +# === 2. バックグラウンド起動(reasoning_effort=medium 推奨) === +codex exec --dangerously-bypass-approvals-and-sandbox \ + --config reasoning.effort=medium \ + -C /workspace \ + < /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-<task>.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 <workdir>` で 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 <PID>` で打ち切り、プロンプトを絞り込んで再実行 + +### 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/skills/corder-code-templates/01-api-templates.md b/plugins/ndf/skills/corder-code-templates/01-api-templates.md deleted file mode 100644 index 3f5ff464..00000000 --- a/plugins/ndf/skills/corder-code-templates/01-api-templates.md +++ /dev/null @@ -1,179 +0,0 @@ -# API テンプレート - -## REST API Endpoint (Express.js) - -```javascript -// routes/[resource].js -const express = require('express'); -const router = express.Router(); - -/** - * @route GET /api/[resource] - * @desc Get all [resource] - * @access Public - */ -router.get('/', async (req, res) => { - try { - const items = await Model.findAll(); - res.json({ success: true, data: items }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } -}); - -/** - * @route GET /api/[resource]/:id - * @desc Get [resource] by ID - * @access Public - */ -router.get('/:id', async (req, res) => { - try { - const item = await Model.findByPk(req.params.id); - if (!item) { - return res.status(404).json({ success: false, error: 'Not found' }); - } - res.json({ success: true, data: item }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } -}); - -/** - * @route POST /api/[resource] - * @desc Create new [resource] - * @access Private - */ -router.post('/', async (req, res) => { - try { - const item = await Model.create(req.body); - res.status(201).json({ success: true, data: item }); - } catch (error) { - res.status(400).json({ success: false, error: error.message }); - } -}); - -/** - * @route PUT /api/[resource]/:id - * @desc Update [resource] - * @access Private - */ -router.put('/:id', async (req, res) => { - try { - const item = await Model.findByPk(req.params.id); - if (!item) { - return res.status(404).json({ success: false, error: 'Not found' }); - } - await item.update(req.body); - res.json({ success: true, data: item }); - } catch (error) { - res.status(400).json({ success: false, error: error.message }); - } -}); - -/** - * @route DELETE /api/[resource]/:id - * @desc Delete [resource] - * @access Private - */ -router.delete('/:id', async (req, res) => { - try { - const item = await Model.findByPk(req.params.id); - if (!item) { - return res.status(404).json({ success: false, error: 'Not found' }); - } - await item.destroy(); - res.json({ success: true, data: {} }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } -}); - -module.exports = router; -``` - -## Authentication Middleware (JWT) - -```javascript -// middleware/auth.js -const jwt = require('jsonwebtoken'); - -const authMiddleware = (req, res, next) => { - // Get token from header - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ error: 'No token provided' }); - } - - const token = authHeader.split(' ')[1]; - - try { - // Verify token - const decoded = jwt.verify(token, process.env.JWT_SECRET, { - algorithms: ['HS256'] - }); - - req.user = decoded; - next(); - } catch (error) { - return res.status(401).json({ error: 'Invalid token' }); - } -}; - -// Role-based access control -const requireRole = (...roles) => { - return (req, res, next) => { - if (!req.user || !roles.includes(req.user.role)) { - return res.status(403).json({ error: 'Forbidden' }); - } - next(); - }; -}; - -module.exports = { authMiddleware, requireRole }; -``` - -## Error Handler - -```javascript -// middleware/errorHandler.js -class AppError extends Error { - constructor(message, statusCode) { - super(message); - this.statusCode = statusCode; - this.isOperational = true; - } -} - -const errorHandler = (err, req, res, next) => { - // Log error - console.error('Error:', err); - - // Operational error - if (err.isOperational) { - return res.status(err.statusCode).json({ - success: false, - error: err.message - }); - } - - // Validation error (Sequelize/Mongoose) - if (err.name === 'ValidationError') { - return res.status(400).json({ - success: false, - error: 'Validation failed', - details: err.errors - }); - } - - // Default: Internal server error - res.status(500).json({ - success: false, - error: process.env.NODE_ENV === 'production' - ? 'Internal server error' - : err.message - }); -}; - -module.exports = { AppError, errorHandler }; -``` diff --git a/plugins/ndf/skills/corder-code-templates/02-component-templates.md b/plugins/ndf/skills/corder-code-templates/02-component-templates.md deleted file mode 100644 index d2c91cc8..00000000 --- a/plugins/ndf/skills/corder-code-templates/02-component-templates.md +++ /dev/null @@ -1,244 +0,0 @@ -# コンポーネント・モデル テンプレート - -## React Component - -```jsx -// components/[ComponentName].jsx -import React, { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; - -const ComponentName = ({ initialData, onUpdate }) => { - const [data, setData] = useState(initialData || []); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - fetchData(); - }, []); - - const fetchData = async () => { - try { - setLoading(true); - const response = await fetch('/api/resource'); - const result = await response.json(); - - if (!result.success) { - throw new Error(result.error); - } - - setData(result.data); - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } - }; - - const handleSubmit = async (formData) => { - try { - const response = await fetch('/api/resource', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(formData) - }); - const result = await response.json(); - - if (result.success) { - setData([...data, result.data]); - onUpdate?.(result.data); - } - } catch (err) { - setError(err.message); - } - }; - - if (loading) return <div className="loading">Loading...</div>; - if (error) return <div className="error">Error: {error}</div>; - - return ( - <div className="component-name"> - {/* Component content */} - <ul> - {data.map(item => ( - <li key={item.id}>{item.name}</li> - ))} - </ul> - </div> - ); -}; - -ComponentName.propTypes = { - initialData: PropTypes.array, - onUpdate: PropTypes.func -}; - -ComponentName.defaultProps = { - initialData: [], - onUpdate: null -}; - -export default ComponentName; -``` - -## React Component (TypeScript) - -```tsx -// components/[ComponentName].tsx -import React, { useState, useEffect, FC } from 'react'; - -interface Item { - id: number; - name: string; -} - -interface Props { - initialData?: Item[]; - onUpdate?: (item: Item) => void; -} - -const ComponentName: FC<Props> = ({ initialData = [], onUpdate }) => { - const [data, setData] = useState<Item[]>(initialData); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - - useEffect(() => { - fetchData(); - }, []); - - const fetchData = async () => { - try { - setLoading(true); - const response = await fetch('/api/resource'); - const result = await response.json(); - setData(result.data); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - }; - - if (loading) return <div>Loading...</div>; - if (error) return <div>Error: {error}</div>; - - return ( - <ul> - {data.map(item => ( - <li key={item.id}>{item.name}</li> - ))} - </ul> - ); -}; - -export default ComponentName; -``` - -## Database Model (Sequelize) - -```javascript -// models/[ModelName].js -const { DataTypes } = require('sequelize'); - -module.exports = (sequelize) => { - const ModelName = sequelize.define('ModelName', { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true - }, - name: { - type: DataTypes.STRING, - allowNull: false, - validate: { - notEmpty: true, - len: [2, 100] - } - }, - email: { - type: DataTypes.STRING, - unique: true, - allowNull: false, - validate: { - isEmail: true - } - }, - status: { - type: DataTypes.ENUM('active', 'inactive', 'pending'), - defaultValue: 'pending' - }, - createdAt: { - type: DataTypes.DATE, - defaultValue: DataTypes.NOW - } - }, { - tableName: 'model_names', - timestamps: true - }); - - // Associations - ModelName.associate = (models) => { - ModelName.hasMany(models.RelatedModel, { - foreignKey: 'modelNameId', - as: 'relatedModels' - }); - }; - - // Hooks - ModelName.beforeCreate(async (instance) => { - // Pre-processing logic - }); - - return ModelName; -}; -``` - -## Database Model (Mongoose) - -```javascript -// models/[ModelName].js -const mongoose = require('mongoose'); - -const modelNameSchema = new mongoose.Schema({ - name: { - type: String, - required: [true, 'Name is required'], - trim: true, - minlength: 2, - maxlength: 100 - }, - email: { - type: String, - required: true, - unique: true, - lowercase: true, - match: [/^\S+@\S+\.\S+$/, 'Invalid email'] - }, - status: { - type: String, - enum: ['active', 'inactive', 'pending'], - default: 'pending' - } -}, { - timestamps: true -}); - -// Index -modelNameSchema.index({ email: 1 }); - -// Virtual -modelNameSchema.virtual('displayName').get(function() { - return this.name.toUpperCase(); -}); - -// Instance method -modelNameSchema.methods.isActive = function() { - return this.status === 'active'; -}; - -// Static method -modelNameSchema.statics.findByEmail = function(email) { - return this.findOne({ email }); -}; - -module.exports = mongoose.model('ModelName', modelNameSchema); -``` diff --git a/plugins/ndf/skills/corder-code-templates/SKILL.md b/plugins/ndf/skills/corder-code-templates/SKILL.md deleted file mode 100644 index 7b138cfd..00000000 --- a/plugins/ndf/skills/corder-code-templates/SKILL.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -name: corder-code-templates -description: | - Generate code templates for common patterns: REST API endpoints, React components, database models, authentication, error handling. Use when implementing new features or creating boilerplate code. - - This skill provides production-ready code templates: - - REST API endpoints (Express, FastAPI) - - React/Vue components with best practices - - Database models (Sequelize, TypeORM, Mongoose) - - Authentication middleware (JWT, OAuth) - - Triggers: "create API", "new component", "implement auth", "add model", "generate code", "コードテンプレート", "API作成", "コンポーネント作成" -allowed-tools: - - Read - - Write - - Bash ---- - -# Corder Code Templates Skill - -## 概要 - -corderエージェントが新機能を実装する際に使用するコードテンプレート集です。REST APIエンドポイント、Reactコンポーネント、データベースモデル、認証ロジックなど、頻出パターンのテンプレートを提供します。 - -## クイックリファレンス - -### テンプレート一覧 - -| テンプレート | ファイル | 用途 | -|-------------|---------|------| -| REST API | `rest-api-endpoint.js` | Express CRUD操作 | -| FastAPI | `rest-api-endpoint.py` | Python APIエンドポイント | -| React Component | `react-component.jsx` | 関数コンポーネント + Hooks | -| Database Model | `database-model.js` | Sequelizeモデル | -| Auth Middleware | `auth-middleware.js` | JWT認証 | -| Error Handler | `error-handler.js` | グローバルエラーハンドラー | - -### カスタマイズ手順 - -1. テンプレートをコピー -2. プレースホルダーを置換(`[RESOURCE]`, `[MODEL_NAME]`等) -3. ビジネスロジックを追加 -4. テスト作成(corder-test-generation使用) - -## ベストプラクティス - -| DO | DON'T | -|----|-------| -| テンプレートをそのままコピー | 安全機能を削除 | -| プロジェクト規約に合わせる | 古いパターンを使用 | -| セキュリティを最優先 | セキュリティを軽視 | -| テストを作成 | async/awaitを避ける | - -## 詳細ガイド - -| ファイル | 内容 | -|---------|------| -| `01-api-templates.md` | REST API、認証ミドルウェア、エラーハンドラーのテンプレート | -| `02-component-templates.md` | React/Vueコンポーネント、データベースモデルのテンプレート | - -## 関連Skill - -- **corder-test-generation**: テンプレートから生成したコードのテスト作成 diff --git a/plugins/ndf/skills/corder-code-templates/templates/rest-api-endpoint.js b/plugins/ndf/skills/corder-code-templates/templates/rest-api-endpoint.js deleted file mode 100644 index 1f1bbebc..00000000 --- a/plugins/ndf/skills/corder-code-templates/templates/rest-api-endpoint.js +++ /dev/null @@ -1,361 +0,0 @@ -/** - * REST API Endpoint Template - * - * このテンプレートは、Express.jsを使用したRESTful APIエンドポイントの実装例です。 - * CRUD操作、バリデーション、エラーハンドリング、認証が含まれています。 - * - * 使用方法: - * 1. [RESOURCE] を実際のリソース名に置換(例: users, products, orders) - * 2. Model を実際のデータベースモデルに置換 - * 3. バリデーションルールをビジネスロジックに合わせて調整 - * 4. 認証が必要なエンドポイントに authMiddleware を追加 - */ - -const express = require('express'); -const router = express.Router(); -const { body, param, query, validationResult } = require('express-validator'); - -// 認証ミドルウェア(必要に応じてインポート) -// const { authMiddleware, requireRole } = require('../middleware/auth'); - -// モデル(実際のモデルに置換) -// const Model = require('../models/[RESOURCE]'); - -/** - * @route GET /api/[resource] - * @desc Get all [resource] items with pagination and filtering - * @access Public - */ -router.get( - '/', - [ - query('page').optional().isInt({ min: 1 }).toInt(), - query('limit').optional().isInt({ min: 1, max: 100 }).toInt(), - query('sortBy').optional().isString(), - query('order').optional().isIn(['asc', 'desc']) - ], - async (req, res) => { - try { - // バリデーションエラーチェック - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ - success: false, - errors: errors.array() - }); - } - - // ページネーション設定 - const page = req.query.page || 1; - const limit = req.query.limit || 10; - const offset = (page - 1) * limit; - - // ソート設定 - const sortBy = req.query.sortBy || 'createdAt'; - const order = req.query.order || 'desc'; - - // データ取得(実際のクエリに置換) - // const { count, rows } = await Model.findAndCountAll({ - // limit, - // offset, - // order: [[sortBy, order.toUpperCase()]] - // }); - - // サンプルレスポンス(実際のデータに置換) - const count = 0; - const rows = []; - - res.json({ - success: true, - data: rows, - pagination: { - total: count, - page, - limit, - totalPages: Math.ceil(count / limit) - } - }); - } catch (error) { - console.error('Error fetching [resource]:', error); - res.status(500).json({ - success: false, - error: 'Internal server error', - message: process.env.NODE_ENV === 'development' ? error.message : undefined - }); - } - } -); - -/** - * @route GET /api/[resource]/:id - * @desc Get single [resource] item by ID - * @access Public - */ -router.get( - '/:id', - [ - param('id').isInt().toInt() - ], - async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ - success: false, - errors: errors.array() - }); - } - - const { id } = req.params; - - // データ取得(実際のクエリに置換) - // const item = await Model.findByPk(id); - - const item = null; // サンプル - - if (!item) { - return res.status(404).json({ - success: false, - error: '[Resource] not found' - }); - } - - res.json({ - success: true, - data: item - }); - } catch (error) { - console.error(`Error fetching [resource] ${req.params.id}:`, error); - res.status(500).json({ - success: false, - error: 'Internal server error' - }); - } - } -); - -/** - * @route POST /api/[resource] - * @desc Create new [resource] item - * @access Private (認証必要な場合は authMiddleware 追加) - */ -router.post( - '/', - // authMiddleware, // 認証が必要な場合はコメント解除 - [ - body('name').trim().notEmpty().withMessage('Name is required') - .isLength({ min: 2, max: 100 }).withMessage('Name must be 2-100 characters'), - body('email').optional().isEmail().withMessage('Invalid email format'), - body('status').optional().isIn(['active', 'inactive']).withMessage('Invalid status') - // 必要に応じてバリデーションルールを追加 - ], - async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ - success: false, - errors: errors.array() - }); - } - - // データ作成(実際のモデルに置換) - // const newItem = await Model.create(req.body); - - const newItem = { id: 1, ...req.body }; // サンプル - - res.status(201).json({ - success: true, - data: newItem, - message: '[Resource] created successfully' - }); - } catch (error) { - console.error('Error creating [resource]:', error); - - // ユニーク制約違反等のエラーハンドリング - if (error.name === 'SequelizeUniqueConstraintError') { - return res.status(409).json({ - success: false, - error: 'Resource already exists', - field: error.errors[0]?.path - }); - } - - res.status(500).json({ - success: false, - error: 'Internal server error' - }); - } - } -); - -/** - * @route PUT /api/[resource]/:id - * @desc Update [resource] item by ID - * @access Private - */ -router.put( - '/:id', - // authMiddleware, - [ - param('id').isInt().toInt(), - body('name').optional().trim().isLength({ min: 2, max: 100 }), - body('email').optional().isEmail(), - body('status').optional().isIn(['active', 'inactive']) - ], - async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ - success: false, - errors: errors.array() - }); - } - - const { id } = req.params; - - // データ取得と更新(実際のクエリに置換) - // const item = await Model.findByPk(id); - // if (!item) { - // return res.status(404).json({ - // success: false, - // error: '[Resource] not found' - // }); - // } - // - // await item.update(req.body); - - const item = null; // サンプル - - if (!item) { - return res.status(404).json({ - success: false, - error: '[Resource] not found' - }); - } - - res.json({ - success: true, - data: item, - message: '[Resource] updated successfully' - }); - } catch (error) { - console.error(`Error updating [resource] ${req.params.id}:`, error); - res.status(500).json({ - success: false, - error: 'Internal server error' - }); - } - } -); - -/** - * @route DELETE /api/[resource]/:id - * @desc Delete [resource] item by ID - * @access Private (Admin only) - */ -router.delete( - '/:id', - // authMiddleware, - // requireRole('admin'), // 管理者のみ削除可能な場合 - [ - param('id').isInt().toInt() - ], - async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ - success: false, - errors: errors.array() - }); - } - - const { id } = req.params; - - // データ取得と削除(実際のクエリに置換) - // const item = await Model.findByPk(id); - // if (!item) { - // return res.status(404).json({ - // success: false, - // error: '[Resource] not found' - // }); - // } - // - // await item.destroy(); - - const item = null; // サンプル - - if (!item) { - return res.status(404).json({ - success: false, - error: '[Resource] not found' - }); - } - - res.json({ - success: true, - message: '[Resource] deleted successfully' - }); - } catch (error) { - console.error(`Error deleting [resource] ${req.params.id}:`, error); - res.status(500).json({ - success: false, - error: 'Internal server error' - }); - } - } -); - -/** - * @route GET /api/[resource]/search - * @desc Search [resource] items - * @access Public - */ -router.get( - '/search', - [ - query('q').trim().notEmpty().withMessage('Search query is required'), - query('limit').optional().isInt({ min: 1, max: 100 }).toInt() - ], - async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ - success: false, - errors: errors.array() - }); - } - - const { q, limit = 10 } = req.query; - - // 検索クエリ(実際のクエリに置換) - // const results = await Model.findAll({ - // where: { - // name: { - // [Op.like]: `%${q}%` - // } - // }, - // limit - // }); - - const results = []; // サンプル - - res.json({ - success: true, - data: results, - query: q - }); - } catch (error) { - console.error('Error searching [resource]:', error); - res.status(500).json({ - success: false, - error: 'Internal server error' - }); - } - } -); - -module.exports = router; diff --git a/plugins/ndf/skills/corder-test-generation/01-patterns.md b/plugins/ndf/skills/corder-test-generation/01-patterns.md deleted file mode 100644 index b926190c..00000000 --- a/plugins/ndf/skills/corder-test-generation/01-patterns.md +++ /dev/null @@ -1,167 +0,0 @@ -# テストパターン詳細 - -## 1. AAA(Arrange-Act-Assert)パターン - -```javascript -test('should do something', () => { - // Arrange: テストデータを準備 - const input = { ... }; - const expected = { ... }; - - // Act: テスト対象を実行 - const result = functionUnderTest(input); - - // Assert: 結果を検証 - expect(result).toEqual(expected); -}); -``` - -## 2. エッジケーステスト - -```javascript -describe('edge cases', () => { - test('should handle null input', () => { - expect(() => func(null)).toThrow(); - }); - - test('should handle empty array', () => { - expect(func([])).toEqual([]); - }); - - test('should handle boundary values', () => { - expect(func(0)).toBe(...); - expect(func(-1)).toBe(...); - expect(func(Number.MAX_VALUE)).toBe(...); - }); -}); -``` - -### テストすべきエッジケース - -- **null / undefined**: 入力がnull/undefinedの場合 -- **空値**: 空文字列、空配列、空オブジェクト -- **境界値**: 0, -1, MAX_VALUE, MIN_VALUE -- **型エラー**: 文字列が期待される場所に数値 -- **特殊文字**: Unicode、改行、タブ - -## 3. モック・スパイ - -### モックの作成 - -```javascript -test('should call API', async () => { - // モック作成 - const mockFetch = jest.fn().mockResolvedValue({ - json: () => ({ data: 'test' }) - }); - global.fetch = mockFetch; - - // 実行 - await fetchData(); - - // 検証 - expect(mockFetch).toHaveBeenCalledWith('/api/data'); - expect(mockFetch).toHaveBeenCalledTimes(1); -}); -``` - -### スパイの使用 - -```javascript -test('should log error', () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - - causeError(); - - expect(consoleSpy).toHaveBeenCalledWith('Error occurred'); - consoleSpy.mockRestore(); -}); -``` - -### モジュールモック - -```javascript -jest.mock('./database', () => ({ - query: jest.fn().mockResolvedValue([{ id: 1 }]) -})); - -const { query } = require('./database'); - -test('should fetch from database', async () => { - const result = await getUserById(1); - expect(query).toHaveBeenCalledWith('SELECT * FROM users WHERE id = ?', [1]); -}); -``` - -## 4. テストフィクスチャ - -### beforeEach / afterEach - -```javascript -describe('UserService', () => { - let userService; - let mockDb; - - beforeEach(() => { - mockDb = createMockDatabase(); - userService = new UserService(mockDb); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - test('should create user', () => { - // テスト - }); -}); -``` - -### テストデータファクトリ - -```javascript -// factories/user.js -function createUser(overrides = {}) { - return { - id: 1, - name: 'Test User', - email: 'test@example.com', - ...overrides - }; -} - -// テストで使用 -test('should update user', () => { - const user = createUser({ name: 'Updated Name' }); - // ... -}); -``` - -## 5. 非同期テスト - -### async/await - -```javascript -test('should fetch data', async () => { - const data = await fetchData(); - expect(data).toEqual({ success: true }); -}); -``` - -### Promise - -```javascript -test('should resolve with data', () => { - return fetchData().then(data => { - expect(data).toEqual({ success: true }); - }); -}); -``` - -### タイムアウト - -```javascript -test('should complete within timeout', async () => { - await expect(slowOperation()).resolves.toBe('done'); -}, 10000); // 10秒タイムアウト -``` diff --git a/plugins/ndf/skills/corder-test-generation/02-examples.md b/plugins/ndf/skills/corder-test-generation/02-examples.md deleted file mode 100644 index b0a9e304..00000000 --- a/plugins/ndf/skills/corder-test-generation/02-examples.md +++ /dev/null @@ -1,224 +0,0 @@ -# テスト実装例 - -## 例1: 既存関数のユニットテスト - -### 元のコード - -```javascript -// src/utils/calculator.js -function add(a, b) { - return a + b; -} - -function divide(a, b) { - if (b === 0) { - throw new Error('Division by zero'); - } - return a / b; -} - -module.exports = { add, divide }; -``` - -### 生成されるテスト - -```javascript -// tests/utils/calculator.test.js -const { add, divide } = require('../../src/utils/calculator'); - -describe('calculator', () => { - describe('add', () => { - test('should add two positive numbers', () => { - // Arrange - const a = 2; - const b = 3; - const expected = 5; - - // Act - const result = add(a, b); - - // Assert - expect(result).toBe(expected); - }); - - test('should handle negative numbers', () => { - expect(add(-2, -3)).toBe(-5); - }); - - test('should handle zero', () => { - expect(add(0, 5)).toBe(5); - expect(add(5, 0)).toBe(5); - }); - }); - - describe('divide', () => { - test('should divide two numbers', () => { - expect(divide(10, 2)).toBe(5); - }); - - test('should throw error when dividing by zero', () => { - expect(() => divide(10, 0)).toThrow('Division by zero'); - }); - - test('should handle negative numbers', () => { - expect(divide(-10, 2)).toBe(-5); - }); - }); -}); -``` - -## 例2: API統合テスト - -```javascript -// tests/api/users.integration.test.js -const request = require('supertest'); -const app = require('../../app'); -const db = require('../../models'); - -describe('Users API', () => { - beforeAll(async () => { - await db.sequelize.sync({ force: true }); - }); - - afterAll(async () => { - await db.sequelize.close(); - }); - - describe('GET /api/users', () => { - test('should return all users', async () => { - const response = await request(app) - .get('/api/users') - .expect('Content-Type', /json/) - .expect(200); - - expect(response.body.success).toBe(true); - expect(Array.isArray(response.body.data)).toBe(true); - }); - - test('should support pagination', async () => { - const response = await request(app) - .get('/api/users?page=1&limit=10') - .expect(200); - - expect(response.body.pagination).toBeDefined(); - expect(response.body.pagination.page).toBe(1); - expect(response.body.pagination.limit).toBe(10); - }); - }); - - describe('POST /api/users', () => { - test('should create new user', async () => { - const newUser = { - name: 'Test User', - email: 'test@example.com' - }; - - const response = await request(app) - .post('/api/users') - .send(newUser) - .expect(201); - - expect(response.body.success).toBe(true); - expect(response.body.data.name).toBe(newUser.name); - }); - - test('should return 400 for invalid data', async () => { - const invalidUser = { - name: 'A', // Too short - email: 'invalid-email' - }; - - const response = await request(app) - .post('/api/users') - .send(invalidUser) - .expect(400); - - expect(response.body.success).toBe(false); - expect(response.body.errors).toBeDefined(); - }); - }); -}); -``` - -## 例3: pytest (Python) - -```python -# tests/test_calculator.py -import pytest -from src.calculator import add, divide - -class TestAdd: - def test_add_positive_numbers(self): - assert add(2, 3) == 5 - - def test_add_negative_numbers(self): - assert add(-2, -3) == -5 - - def test_add_zero(self): - assert add(0, 5) == 5 - -class TestDivide: - def test_divide_numbers(self): - assert divide(10, 2) == 5 - - def test_divide_by_zero(self): - with pytest.raises(ValueError, match="Division by zero"): - divide(10, 0) - - @pytest.mark.parametrize("a,b,expected", [ - (10, 2, 5), - (9, 3, 3), - (-10, 2, -5), - ]) - def test_divide_parametrized(self, a, b, expected): - assert divide(a, b) == expected -``` - -## 例4: Reactコンポーネントテスト - -```javascript -// tests/components/UserList.test.jsx -import { render, screen, waitFor } from '@testing-library/react'; -import UserList from '../../src/components/UserList'; - -// APIをモック -jest.mock('../../src/api', () => ({ - fetchUsers: jest.fn() -})); - -import { fetchUsers } from '../../src/api'; - -describe('UserList', () => { - test('should show loading state', () => { - fetchUsers.mockImplementation(() => new Promise(() => {})); - - render(<UserList />); - - expect(screen.getByText('Loading...')).toBeInTheDocument(); - }); - - test('should display users', async () => { - fetchUsers.mockResolvedValue([ - { id: 1, name: 'User 1' }, - { id: 2, name: 'User 2' } - ]); - - render(<UserList />); - - await waitFor(() => { - expect(screen.getByText('User 1')).toBeInTheDocument(); - expect(screen.getByText('User 2')).toBeInTheDocument(); - }); - }); - - test('should show error message', async () => { - fetchUsers.mockRejectedValue(new Error('Network error')); - - render(<UserList />); - - await waitFor(() => { - expect(screen.getByText('Error: Network error')).toBeInTheDocument(); - }); - }); -}); -``` diff --git a/plugins/ndf/skills/corder-test-generation/SKILL.md b/plugins/ndf/skills/corder-test-generation/SKILL.md deleted file mode 100644 index de0ea72c..00000000 --- a/plugins/ndf/skills/corder-test-generation/SKILL.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -name: corder-test-generation -description: | - Generate unit tests, integration tests, and test fixtures for code. Supports Jest, Mocha, pytest. Use when writing tests or improving test coverage. - - This skill provides automated test generation: - - Unit tests with AAA pattern (Arrange, Act, Assert) - - Integration tests for APIs and databases - - Test fixtures and mocks - - Edge case identification - - Triggers: "generate tests", "create unit test", "add test coverage", "write tests", "テスト生成", "テストコード作成", "カバレッジ向上" -allowed-tools: - - Read - - Write - - Bash ---- - -# Corder Test Generation Skill - -## 概要 - -corderエージェントが既存のコードに対してテストを自動生成する際に使用します。Jest、Mocha、pytestなどの主要なテストフレームワークに対応。 - -## クイックリファレンス - -### AAA パターン - -```javascript -test('should do something', () => { - // Arrange: テストデータを準備 - const input = { ... }; - const expected = { ... }; - - // Act: テスト対象を実行 - const result = functionUnderTest(input); - - // Assert: 結果を検証 - expect(result).toEqual(expected); -}); -``` - -### 基本コマンド - -```bash -node scripts/generate-tests.js src/utils/calculator.js -``` - -### カバレッジ目標 - -- **ユニットテスト**: 80%以上 -- **クリティカルパス**: 100% -- **エッジケース**: 主要な境界値をカバー - -## ベストプラクティス - -| DO | DON'T | -|----|-------| -| AAAパターンに従う | テストの重複 | -| 1テスト = 1アサーション | 実装の詳細に依存 | -| エッジケースをテスト | 過剰なモック | -| 独立したテスト(実行順序に依存しない) | 外部依存(ネットワーク等) | -| わかりやすいテスト名 | テストのテスト | - -## 詳細ガイド - -| ファイル | 内容 | -|---------|------| -| `01-patterns.md` | AAAパターン、エッジケース、モック・スパイの詳細 | -| `02-examples.md` | ユニットテスト、API統合テストの実装例 | - -## 関連Skill - -- **corder-code-templates**: テスト対象のコード生成 diff --git a/plugins/ndf/skills/cross-review/SKILL.md b/plugins/ndf/skills/cross-review/SKILL.md new file mode 100644 index 00000000..f7e34784 --- /dev/null +++ b/plugins/ndf/skills/cross-review/SKILL.md @@ -0,0 +1,313 @@ +--- +name: cross-review +description: "PR を codex / gemini 両方にレビューさせ、両方 APPROVE まで /ndf:review → /ndf:fix を自動ループ。サブエージェント分離・PR ローテーション・nit 集約でメイン context 消費を最小化" +argument-hint: "[PR番号] [--max-rounds N] [--rotate-after K] [--only codex|gemini]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Edit + - Write + - Glob + - Grep +--- + +# クロスレビュー収束ループ + +PR を **codex / gemini 両方** にレビューさせ、両者が `APPROVE` を返すまで +`/ndf:review` と `/ndf:fix` を自動で回す。 + +詳細手順は `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 <subcommand>` 形式で呼ぶだけで、 +state.json の読み書きや AI launcher 起動・完了待ちは全て委譲される。 + +## 設計方針 + +長丁場が予想されるため **メインセッションの context 消費を最小化** する: + +| 観点 | 方針 | +|---|---| +| レビュー投稿 | **AI 自身が `gh api` で PR に直接投稿**。メインはペイロードを保持しない | +| 修正 | **必ずサブエージェント (`general-purpose`) で実行**。メイン context に diff は載せない | +| ユーザ問い合わせ | 自動判断を最大化(`critical`/`major`/`minor` は自動修正、`nit` は最後にまとめて 1 回だけ問い合わせ) | +| 状態の永続化 | `/tmp/cross-review-pr<番号>-state.json` に集約。中断・再開可能 | +| 長尺PR対策 | **`--rotate-after` ラウンドで PR をローテーション**(squash + 新ブランチ + 新 PR) | +| 振動検知 | 同じ指摘が 2 round で 50%以上重複したら中断 | + +## 引数 + +| 引数 | 意味 | 既定 | +|---|---|---| +| `[PR番号]` | 対象 PR(省略時は直前 PR / 現在ブランチ) | — | +| `--max-rounds N` | 全体最大ラウンド数(PR ローテーションを含む通算) | `6` | +| `--rotate-after K` | この round 数で未収束なら PR ローテーション | `5` | +| `--only codex` / `--only gemini` | 片方だけで回す(デバッグ用) | 両方 | + +例: + +``` +/ndf:cross-review 123 +/ndf:cross-review 123 --max-rounds 4 --rotate-after 2 +/ndf:cross-review 123 --only codex +``` + +## 前提 + +- `/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 /work/worktrees/pr<PR> <head>` を冪等実行 | +| 3 | gemini trusted directory | `launch-gemini.sh` が `GEMINI_CLI_TRUST_WORKSPACE=true` + `--skip-trust` を必ず併用。さらに **tmp dir は `~/.gemini/tmp/<workspace>/`** を採用し、gemini の workspace 制約 (workspace 外の `read_file` / `write_file` がブロックされる) を回避 | +| 4 | 既存コメント差分 | `gh api .../comments --paginate` を `$TMP_DIR/cross-review-pr<PR>-existing-comments.txt` に保存し、gemini プロンプトには **内容をインライン埋め込み**、codex プロンプトには path を渡す | + +### 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 初期化<br/>・自分の PR 判定 → event downgrade 設定<br/>・/work/worktrees/pr<PR> を用意<br/>・既存コメントスナップショット保存"] + Init --> Round["Round N start<br/>current_pr = PR#"]:::phase + + Round -.並列バックグラウンド.-> Codex["/ndf:review <PR> codex<br/>(AI が gh api で直接投稿)<br/>body 先頭: cross-review / round N / codex / intent<br/>→ result.json (intent + posted_as)"] + Round -.並列バックグラウンド.-> Gemini["/ndf:review <PR> gemini<br/>--skip-trust 必須<br/>body 先頭: cross-review / round N / gemini / intent<br/>→ result.json (intent + posted_as)"] + + Codex --> Decide{"判定 (intent ベース)"} + Gemini --> Decide + + Decide -->|両方 APPROVE / SKIP| Approved([final = approved]):::ok + Decide -->|一方でも REQUEST_CHANGES| Fix["Agent (general-purpose)<br/>/ndf:fix <PR> --defer-nit を worktree 内で実行<br/>・critical/major/minor 修正 + push<br/>・reply + resolveReviewThread<br/>・deferred/rejected は reply のみ<br/>→ /tmp/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<br/>squash + 新ブランチ + 新 PR"] + Check -->|それ以外| Round + Rotate --> Round + + Approved --> Nit[最後に 1 回<br/>deferred nit 一覧をユーザに問い合わせ] + MaxR --> Nit + Osc --> Nit + Err --> Nit + + classDef phase fill:#eef,stroke:#557 + classDef ok fill:#dfd,stroke:#383 + classDef stop fill:#fdd,stroke:#933 +``` + +## 実行ステップ概要(メインの bash 骨組み) + +各ステップの詳細は `docs/` 参照。メインは以下のテンプレートで scripts/ を呼ぶだけ: + +```bash +SCRIPTS="$CLAUDE_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 + +# Step 0: state 初期化 / 再開 +eval "$("$SCRIPTS/state.py" init "$STATE_PR" \ + --max-rounds "$MAX_ROUNDS" --rotate-after "$ROTATE_AFTER" \ + ${ONLY:+--only "$ONLY"})" +# 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/fix-pr<STATE_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 + eval "$("$SCRIPTS/rotate-pr.sh" "$STATE_PR")" # NEW_PR を eval で取り込む + "$SCRIPTS/state.py" set-current-pr "$STATE_PR" "$NEW_PR" + # NOTE: STATE_PR は変えない。次ループの scripts も $STATE_PR を渡す。 + fi +done + +# 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 — [docs/02-fix-and-rotation.md](docs/02-fix-and-rotation.md) + +## レビュー出力の制約 + +**目的**: PR 上に Resolve 義務を伴うインラインコメントを増やさない。 +**修正アクションを伴わない記述は一切出さない** ことを両 launcher プロンプトで強制する。 + +### 1. body 先頭 identifier prefix(必須) + +人間アカウントから AI が投稿するため、GitHub UI 上では誰のレビューか分からない。 +body 先頭に必ず以下を入れる: + +``` +## 🤖 cross-review | round 1 | codex | REQUEST_CHANGES +``` + +書式: `## 🤖 cross-review | round <N> | <agent> | <event>` + +- `<agent>`: `codex` / `gemini` のいずれか +- `<event>`: 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/fix-pr<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 を都度ユーザに問う** — 必ずバッチ集約して最後に 1 回 +- ❌ **`max-rounds` なしで回す** — 無限ループの温床 +- ❌ **PR ローテーションを忘れる** — 100+ コメントの巨大 PR になる +- ❌ **CI 失敗を一律で中断** — コード関連/メタチェックを分類(上記参照) +- ❌ **自分の PR に `REQUEST_CHANGES` で投稿** — 必ず 422。事前判定 + COMMENT ダウングレード +- ❌ **`gemini --yolo` だけで起動** — trusted directory で YOLO 無効化。`--skip-trust` 併用 +- ❌ **`pgrep -fa <prompt>` で完了判定** — gemini は long prompt が引数に乗り検知失敗。pidfile 必須 +- ❌ **sentinel 単独で完了判定** — codex がクラッシュすると永遠に出ない。`monitor.py` の多軸判定 (pidfile / sentinel / 早期エラー / stall / hard timeout / result.json) を使うこと +- ❌ **タイムアウトなしで wait** — ハング検知不能。`monitor.py` の hard timeout (30 分既定) + stall timeout (10 分既定) を必ず効かせる +- ❌ **fix サブエージェントが Resolve をスキップ** — reply だけでは未対応扱い。Resolve まで実行 +- ❌ **review body に identifier prefix を付け忘れる** — GitHub UI 上で誰のレビューか不明になる + +## メイン context 節約の工夫 + +1. **大きいファイルはメイン context に載せない**: payload / err.log / diff は + すべて `/tmp/` に置き、メインは 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 | — | — | + +- **残 deferred nit リスト**(ユーザ判断要) +- **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/skills/cross-review/docs/01-state-and-review.md b/plugins/ndf/skills/cross-review/docs/01-state-and-review.md new file mode 100644 index 00000000..4424bac6 --- /dev/null +++ b/plugins/ndf/skills/cross-review/docs/01-state-and-review.md @@ -0,0 +1,237 @@ +# 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": 6, + "rotate_after": 5, + "only": null, + "current_pr": 123, + "worktree_path": "/work/worktrees/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` — fix サブエージェントが `resolveReviewThread` で resolve した件数 +- `rounds[].fix.ci_note` — コード無関係の CI 失敗時に「Assignees 未設定」等の理由を残す + +## Step 0: 準備 + 既存 state 引き継ぎ + +```bash +SCRIPTS="$CLAUDE_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"})" + +# 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 作成(`/work/worktrees/pr<PR>`) +4. 既存コメントスナップショット → `$TMP_DIR/cross-review-pr<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/<agent>-review-pr<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/<pid>/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 の合計サイズが既定 **3 分** 変化しなければ STALLED で中断 (`--stall-timeout` or `MONITOR_STALL` env) | +| hard timeout | 既定 **7 分**。`--timeout` or `MONITOR_TIMEOUT` env で上書き | +| result.json 存在 | プロセス終了後、result.json が無ければ NO_RESULT (exit 3) | +| **失敗時 kill** | TIMEOUT / STALLED / EARLY_ERROR / PIDFILE_BAD で返るときは対象プロセスに SIGTERM → 3 秒後 SIGKILL。残存プロセスが後から `gh api` 投稿や result.json 書き込みを行うのを防ぐ | + +> ⚠ **罠**: `nohup ... &` でラッパーシェルは即終了し、ハーネスから +> 「タスク完了」通知が飛んでくる。これに惑わされず、`monitor.py` で +> 実プロセスの完了を pidfile / sentinel で確認すること。 +> +> ⚠ **`pgrep -fa <prompt>` で完了判定しない**: gemini は long `-p` プロンプトを +> 引数に持つため、`grep` のキーワード選定で誤検知する。**pidfile 必須**。 +> +> ⚠ **sentinel 単独で完了判定しない**: codex がクラッシュすると `tokens used` が +> 永遠に出ない。`monitor.py` は sentinel と pidfile/result.json/err.log を併用する。 + +### 2.2 AI への入出力契約(両 launcher 共通) + +launcher が生成するプロンプトに以下を強制している: + +- **headRefOid (commit_id) を明示**: AI が自前で取得すると baseRefOid を誤って入れる事故が多発 +- **作業 worktree の絶対パス**: 「ファイル読み取りは必ず `/work/worktrees/pr<PR>/` 配下の絶対パスを使う」 +- **event ダウングレード警告**: `event_downgrade=true` のときは payload の `event` を `COMMENT` に +- **既存コメント差分**: `$TMP_DIR/cross-review-pr<PR>-existing-comments.txt` を読んで重複指摘禁止 +- **review body 先頭 prefix**: + ``` + ## 🤖 cross-review | round <N> | <agent> | <event(intent)> + ``` + `<event>` は **本来の 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/<agent>-review-pr<PR>-result.json` | `{event, posted_as, comments_count, review_url, by_severity}` のサマリ | +| `$TMP_DIR/<agent>-review-pr<PR>-round<R>-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].<agent>` に `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/<agent>-review-pr<PR>-round<R>-payload.json` から +`path:line` を抽出し、前ラウンドとの重複率を計算。**50% 以上重複で中断**。 + +PR ローテーション直後 (`round_in_pr < 2`) はスキップ。 diff --git a/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md b/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md new file mode 100644 index 00000000..34dcd5e0 --- /dev/null +++ b/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md @@ -0,0 +1,217 @@ +# 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` | Step 6 — PR rotation 実行 | +| `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<PR>-result.json` を必ず書き出す + +> ⚠ 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/<repo-root> 側で並行作業している可能性があり、 +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 コメント取得: `gh api "repos/{OWNER_REPO}/pulls/{PR}/comments" --paginate` +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 <SHA>)」 + - 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: <SHA> + 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": "..."}} + ] +}} +``` +""", +) +``` + +### 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<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 ローテーション判定 + +```bash +if "$SCRIPTS/state.py" should-rotate "$STATE_PR"; then + eval "$("$SCRIPTS/rotate-pr.sh" "$STATE_PR")" # NEW_PR / NEW_PR_URL / NEW_BRANCH を取り込む + "$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)。 + +`rotate-pr.sh` が内部で行う処理: + +1. state.json から `current_pr` (= 旧 PR) と `worktree_path` を読む +2. 既存ブランチを **squash 統合** した新ブランチ作成 +3. 旧 PR に「ローテーションのため close」コメント + close +4. 新 PR 作成(タイトル末尾に `(rotated)` 付与) +5. 新 PR 番号 / URL / ブランチ名を stdout に KEY=VALUE で吐く + +`state.py set-current-pr` が `state.json` の `current_pr` / `pr_history` を更新。 + +> ⚠ **重要**: 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<NEW_PR>-state.json` を探して `state.json not found` で +> 止まる。 + +## Step 7: 次ラウンドへ + +Step 1 に戻る。 + +## Step 8: 終了処理 — deferred nit のバッチ問い合わせ + +ループ終了時(`final` 確定後)、ラウンドサマリと残 deferred nit を表示: + +```bash +"$SCRIPTS/state.py" report "$STATE_PR" +``` + +`report` は以下を Markdown で吐く: + +- 最終ステータス(`approved` / `max_rounds` / `oscillation` / `error`) +- PR 履歴 +- ラウンドサマリ表 +- 残 deferred nit 一覧 + +UI 上は **AskUserQuestion で 1 回だけ** 「nit 一括対応する / しない / +個別選択」を選ばせるのが望ましい。 diff --git a/plugins/ndf/skills/cross-review/scripts/_tmpdir.sh b/plugins/ndf/skills/cross-review/scripts/_tmpdir.sh new file mode 100755 index 00000000..86016150 --- /dev/null +++ b/plugins/ndf/skills/cross-review/scripts/_tmpdir.sh @@ -0,0 +1,28 @@ +# shellcheck shell=bash +# cross-review 共通: tmp ディレクトリ決定ヘルパ。 +# +# Usage: +# . "$(dirname "$0")/_tmpdir.sh" +# TMP_DIR=$(tmpdir) +# +# 優先順位: +# 1. 環境変数 CROSS_REVIEW_TMP_DIR (明示) +# 2. ~/.gemini/tmp/<cwd-basename>/ (gemini の workspace 制約を回避する公式 path) +# 3. /tmp/ (フォールバック) + +tmpdir() { + if [ -n "${CROSS_REVIEW_TMP_DIR:-}" ]; then + mkdir -p "$CROSS_REVIEW_TMP_DIR" + echo "$CROSS_REVIEW_TMP_DIR" + return + fi + local base + base=$(basename "$PWD") + local gemini_root="$HOME/.gemini/tmp" + if [ -d "$gemini_root" ] && [ -n "$base" ]; then + mkdir -p "$gemini_root/$base" + echo "$gemini_root/$base" + return + fi + echo "/tmp" +} diff --git a/plugins/ndf/skills/cross-review/scripts/launch-codex.sh b/plugins/ndf/skills/cross-review/scripts/launch-codex.sh new file mode 100755 index 00000000..810ef28d --- /dev/null +++ b/plugins/ndf/skills/cross-review/scripts/launch-codex.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# cross-review codex launcher. +# +# Usage: launch-codex.sh <STATE_PR> <ROUND> +# +# 引数 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 → ~/.gemini/tmp/<workspace>/ → /tmp/ +# gemini の workspace 制約を回避するため、`~/.gemini/tmp/...` を優先採用する。 +# +# 状態ファイル: $TMP_DIR/codex-review-pr<STATE_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") +# 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 + +cat > "$PROMPT" <<EOF +# /ndf:review 実行 (cross-review codex / round $ROUND) + +PR #$PR を **codex の観点でレビューし、gh api で直接 PR に投稿** してください。 + +## 必須コンテキスト +- repo: $REPO +- PR: #$PR +- commit_id (headRefOid): $SHA +- worktree: $WORKTREE (**ファイル読み取りは必ず此処の絶対パスを使う**) +- event_downgrade: $EVENT_DOWNGRADE + - true の場合: payload の \`event\` は \`COMMENT\` にすること。 + ただし body 先頭 prefix の \`<event>\` には **本来の intent** を書く。 +- 既存コメントスナップショット: $EXISTING (重複指摘禁止) + +## 出力契約 +- review body の **先頭行** に必ず以下を入れる(fence 不要、Markdown 見出しとして): + \`\`\` + ## 🤖 cross-review | round $ROUND | codex | <event(intent)> + \`\`\` + 例: \`## 🤖 cross-review | round $ROUND | codex | REQUEST_CHANGES\` + - \`<event>\` は **本来の 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/skills/cross-review/scripts/launch-gemini.sh b/plugins/ndf/skills/cross-review/scripts/launch-gemini.sh new file mode 100755 index 00000000..fd9e1c69 --- /dev/null +++ b/plugins/ndf/skills/cross-review/scripts/launch-gemini.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# cross-review gemini launcher (trusted directory 対策込み). +# +# Usage: launch-gemini.sh <STATE_PR> <ROUND> +# +# 引数 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") +# 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 は `~/.gemini/tmp/<workspace>/` を使うようになったが、念のため +# プロンプト埋め込み方式も維持 (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 + +cat > "$PROMPT" <<EOF +# /ndf:review 実行 (cross-review gemini / round $ROUND) + +PR #$PR を **gemini の観点でレビューし、gh api で直接 PR に投稿** してください。 + +## 必須コンテキスト +- repo: $REPO +- PR: #$PR +- commit_id (headRefOid): $SHA +- worktree: $WORKTREE (**ファイル読み取りは必ず此処の絶対パスを使う**) +- event_downgrade: $EVENT_DOWNGRADE + - true の場合: payload の \`event\` は \`COMMENT\` にすること。 + body 先頭 prefix の \`<event>\` は本来の intent を書く。 + +## 既存コメントスナップショット(重複指摘禁止) +workspace 外を読まなくて済むよう、以下にインライン展開する: + +\`\`\` +$EXISTING_INLINE +\`\`\` + +## 出力契約 +- review body の **先頭行** に必ず以下を入れる: + \`\`\` + ## 🤖 cross-review | round $ROUND | gemini | <event(intent)> + \`\`\` + - \`<event>\` は **本来の 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-result.json** に書く(フォーマットは launch-codex.sh と同じ) +- payload は **$TMP_DIR/gemini-review-pr$STATE_PR-round$ROUND-payload.json** に保存 + +## 守るべきこと +- **リポジトリ編集禁止**。gh api での投稿のみ許可 +- worktree 外のパスは触らない +- gh api 失敗時は err.log にエラー詳細を残して即時終了 +EOF + +cd "$WORKTREE" +# ⚠ --skip-trust と GEMINI_CLI_TRUST_WORKSPACE=true は両方必須 +GEMINI_CLI_TRUST_WORKSPACE=true nohup gemini --yolo --skip-trust --output-format text \ + -p "$(cat "$PROMPT")" \ + > $TMP_DIR/gemini-review-pr$STATE_PR-stdout.log \ + 2> $TMP_DIR/gemini-review-pr$STATE_PR-err.log & +echo $! > $TMP_DIR/gemini-review-pr$STATE_PR.pid +disown +echo "🚀 gemini launched (pid=$(cat $TMP_DIR/gemini-review-pr$STATE_PR.pid))" >&2 diff --git a/plugins/ndf/skills/cross-review/scripts/monitor.py b/plugins/ndf/skills/cross-review/scripts/monitor.py new file mode 100755 index 00000000..12aaf2f6 --- /dev/null +++ b/plugins/ndf/skills/cross-review/scripts/monitor.py @@ -0,0 +1,476 @@ +#!/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/<pid>/cmdline` で codex/gemini であることを再確認 (PID 再利用対策) + 2. **sentinel** (codex のみ): err.log に `^tokens used$` 出現 + 3. **early-error pattern**: err.log に既知の致命的キーワードが出たら即中断 + 4. **result.json**: プロセス終了後に `/tmp/<agent>-review-pr<PR>-result.json` が + 生成されていなければ失敗扱い + 5. **hard timeout**: 既定 7 分。`--timeout` または `MONITOR_TIMEOUT` で上書き可 + 6. **stall timeout**: err.log + stdout.log の合計サイズが既定 3 分変化しなければ + STALLED として中断 (`--stall-timeout` または `MONITOR_STALL` で上書き可) + 7. **失敗時 kill**: TIMEOUT / STALLED / EARLY_ERROR / PIDFILE_BAD で返るとき、 + 対象プロセスを SIGTERM (3 秒後に SIGKILL) で停止する + +Usage: + monitor.py <PR> <target> target ∈ {codex, gemini, both} + monitor.py <PR> both --timeout 1200 --stall-timeout 600 + +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 + + +# ---------- 設定 ---------- + +DEFAULT_TIMEOUT = int(os.environ.get("MONITOR_TIMEOUT", "420")) # 7 min +DEFAULT_STALL = int(os.environ.get("MONITOR_STALL", "180")) # 3 min no progress +DEFAULT_POLL = int(os.environ.get("MONITOR_POLL", "15")) # 15 sec + +# err.log の行頭に近い形で出る致命的パターン(substring 検索ではない)。 +# `^` (行頭) を必須として、diff のコード本文 / doc の引用 / インラインコメント本文に +# 同じキーワードが出ても誤検知しないようにする。 +EARLY_ERROR_PATTERNS = [ + # 行頭または `: ` の直後など、典型的な error 出力フォーマット + re.compile(r"^(?:Error|FATAL|fatal|panic|PANIC|Traceback)[: ]", re.MULTILINE), + # 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), + re.compile(r"^.*\b(?:quota exceeded|rate limit exceeded)\b", re.MULTILINE | re.IGNORECASE), + re.compile(r"^.*\bAPI key (?:not found|missing|invalid)", re.MULTILINE | re.IGNORECASE), + # codex 固有: sandbox エラー(行頭 + 末尾近辺) + re.compile(r"^.*\bsandbox error\b", re.MULTILINE | re.IGNORECASE), +] + +# 文脈に含まれていたら benign(doc 引用 / コードレビューコメント等)と見なし誤検知扱い +EARLY_ERROR_BENIGN = [ + # diff のコンテキスト行 (` `, `+`, `-` で始まり、その後 markdown 表記が来る) + re.compile(r"^[ +-].*[\|`]", re.MULTILINE), + # markdown のリスト / 引用 + re.compile(r"^\s*[-*>]\s", re.MULTILINE), + # warning は致命ではない + re.compile(r"^warning: ", re.IGNORECASE | re.MULTILINE), +] + +CODEX_SENTINEL = re.compile(r"^tokens used$", re.MULTILINE) + + +def _tmp_dir() -> pathlib.Path: + """cross-review 用 tmp ディレクトリ。 + + state.py の `_tmp_dir()` と同じロジック。優先: + 1. `CROSS_REVIEW_TMP_DIR` env + 2. `~/.gemini/tmp/<cwd-basename>/` (`~/.gemini/tmp/` が存在するとき) + 3. `/tmp/` (フォールバック) + """ + env = os.environ.get("CROSS_REVIEW_TMP_DIR") + if env: + d = pathlib.Path(env) + d.mkdir(parents=True, exist_ok=True) + return d + base_name = pathlib.Path(os.getcwd()).name + gemini_root = pathlib.Path.home() / ".gemini" / "tmp" + if gemini_root.is_dir() and base_name: + d = gemini_root / base_name + d.mkdir(parents=True, exist_ok=True) + return d + return pathlib.Path("/tmp") + + +# ---------- データ型 ---------- + +@dataclass +class AgentPaths: + agent: str + pr: int + pidfile: pathlib.Path + err_log: pathlib.Path + stdout_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"), + 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 + 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` 相当。0 シグナルを送って例外で判定。""" + try: + os.kill(pid, 0) + return True + except (ProcessLookupError, PermissionError): + return False + except OSError: + 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 + 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/<pid>/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_early_errors(path: pathlib.Path) -> Optional[str]: + if not path.exists(): + return None + try: + # 末尾 200KB のみ読む(巨大化対策) + 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 EARLY_ERROR_PATTERNS: + m = pat.search(data) + if not m: + continue + # 直前後 80 文字を見て benign パターンと重なってないか確認 + start = max(0, m.start() - 40) + end = min(len(data), m.end() + 40) + context = data[start:end] + if any(b.search(context) for b in EARLY_ERROR_BENIGN): + continue + # 周辺 1 行を返す + 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) + return data[line_start:line_end].strip() + return None + + +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 monitor_agent( + agent: str, + pr: int, + timeout: int, + stall_timeout: int, + poll: int, + require_result: bool, + log_prefix: str = "", +) -> AgentStatus: + """1 agent を監視する。""" + 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/<pid> が消えるか、別プロセスに + # 再利用されている可能性があり、ここで PIDFILE_BAD を返すと「完了している(result.json は出ている)」 + # ケースを誤って失敗にしてしまう。alive=True と確認した瞬間のみ cmdline 一致を検証する。 + + last_err_size = paths.err_log.stat().st_size if paths.err_log.exists() else 0 + last_progress = time.monotonic() + cmdline_validated = 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 + + if alive and not cmdline_validated: + # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/<pid> を読むと + # ファイル不在で 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 + err = _scan_early_errors(paths.err_log) + if err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + status.detail = f"early error in err.log: {err[:200]}" + _emit_log(log_prefix, agent, status) + return status + + 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 の **両方** をモニタ。 + # gemini は stdout 側だけ進捗が出るケースがあるため、片方でも更新があれば + # progress として扱う) + progress_size = 0 + for p in (paths.err_log, paths.stdout_log): + if p.exists(): + progress_size += p.stat().st_size + status.err_log_size = progress_size + if progress_size != last_err_size: + last_err_size = progress_size + last_progress = time.monotonic() + if (time.monotonic() - last_progress) >= 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_err_size}B)" + ) + _emit_log(log_prefix, agent, status) + return status + + # poll 中の進捗ログ + _emit_progress(log_prefix, agent, status, last_err_size) + time.sleep(poll) + + +def _emit_progress(prefix: str, agent: str, st: AgentStatus, log_size: int) -> None: + print( + f"{prefix}⏳ {agent} elapsed={st.elapsed:.0f}s pid={st.pid} " + f"err_log={log_size}B sentinel={'Y' if st.sentinel_seen else '-'}", + 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"]) + p.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, + help=f"hard timeout in seconds (default: {DEFAULT_TIMEOUT})") + p.add_argument("--stall-timeout", type=int, default=DEFAULT_STALL, + help=f"stall timeout (err.log no progress) in seconds (default: {DEFAULT_STALL})") + p.add_argument("--poll", type=int, default=DEFAULT_POLL, + help=f"poll interval in seconds (default: {DEFAULT_POLL})") + p.add_argument("--no-require-result", action="store_true", + help="プロセス終了後に result.json が無くても OK 扱い") + 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: + results[agent] = monitor_agent( + agent=agent, pr=args.pr, + timeout=args.timeout, stall_timeout=args.stall_timeout, + poll=args.poll, require_result=require_result, + 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, + "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/skills/cross-review/scripts/rotate-pr.sh b/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh new file mode 100755 index 00000000..f576261a --- /dev/null +++ b/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# PR rotation — squash + 新ブランチ + 新 PR. +# +# Usage: rotate-pr.sh <STATE_PR> +# +# 引数 STATE_PR は state.json の key (= 最初に init した PR 番号)。 +# 閉じる「現在の PR」は state.json の `current_pr` を読む。 +# +# 既存ブランチを squash した新ブランチを作り、旧 PR (=current_pr) を close、新 PR を作成する。 +# 新 PR 番号を stdout に "NEW_PR=<番号>" / "NEW_PR_URL=<url>" 形式で出力。 +# +# state.json の current_pr / pr_history 更新は `state.py set-current-pr` で別途行う。 + +set -euo pipefail + +STATE_PR=${1:?STATE_PR 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") +OLD_PR=$(jq -r '.current_pr' "$STATE") +ROUND_IN_PR=$(jq --argjson p "$OLD_PR" '[.rounds[] | select(.pr == $p)] | length' "$STATE") + +cd "$WORKTREE" + +BRANCH=$(git branch --show-current) +BASE=$(gh pr view "$OLD_PR" --json baseRefName -q .baseRefName) +TITLE=$(gh pr view "$OLD_PR" --json title -q .title) +NEW_BRANCH="${BRANCH}-r$(date +%H%M%S)" + +echo "🔄 PR #$OLD_PR rotation: $BRANCH → $NEW_BRANCH (base=$BASE)" >&2 + +# 1. 既存ブランチを squash して新ブランチに +git checkout -b "$NEW_BRANCH" +git reset --soft "origin/$BASE" +git commit -m "$(cat <<EOF +$TITLE + +(cross-review rotation: PR #$OLD_PR を squash 統合) +EOF +)" +git push -u origin "$NEW_BRANCH" + +# 2. 旧 PR を close(コメント残し) +gh pr comment "$OLD_PR" --body "🔄 cross-review ループ進行中のため、本 PR を close し新規 PR に巻き直します。 round_in_pr=$ROUND_IN_PR で長尺化を回避。" +gh pr close "$OLD_PR" + +# 3. 新 PR 作成 +NEW_PR_URL=$(gh pr create --base "$BASE" --title "$TITLE (rotated)" --body "$(cat <<EOF +## Summary +旧 PR #$OLD_PR をベースに、cross-review クロスレビューループの継続。 +旧 PR は round_in_pr=$ROUND_IN_PR で巻き直しのため close 済み。 +旧 PR の resolved スレッドは既に修正済み事項。残った指摘はこの PR で再評価する。 + +<!-- I want to review in Japanese. --> +EOF +)") + +NEW_PR=$(echo "$NEW_PR_URL" | grep -oE '/pull/[0-9]+' | grep -oE '[0-9]+') + +echo "✅ 新 PR #$NEW_PR: $NEW_PR_URL" >&2 +echo "NEW_PR=$NEW_PR" +echo "NEW_PR_URL=$NEW_PR_URL" +echo "NEW_BRANCH=$NEW_BRANCH" diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py new file mode 100755 index 00000000..1784509a --- /dev/null +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -0,0 +1,562 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""cross-review state.json 操作 CLI。 + +`/tmp/cross-review-pr<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 subprocess +import sys +from typing import Any + + +# ---------------- helpers ---------------- + +def _tmp_dir(workspace: str | None = None) -> pathlib.Path: + """cross-review 用 tmp ディレクトリを決定する。 + + 優先順位: + 1. 環境変数 `CROSS_REVIEW_TMP_DIR` (明示) + 2. `~/.gemini/tmp/<workspace-basename>/` (gemini workspace 制約を回避するため、 + `~/.gemini/tmp/` が存在するなら自動使用) + 3. `/tmp/` (フォールバック) + + `workspace` 未指定なら `os.getcwd()` の basename を使う。 + """ + env = os.environ.get("CROSS_REVIEW_TMP_DIR") + if env: + d = pathlib.Path(env) + d.mkdir(parents=True, exist_ok=True) + return d + base_name = pathlib.Path(workspace or os.getcwd()).name + gemini_root = pathlib.Path.home() / ".gemini" / "tmp" + if gemini_root.is_dir() and base_name: + d = gemini_root / base_name + d.mkdir(parents=True, exist_ok=True) + return d + return pathlib.Path("/tmp") + + +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 _tmp_dir() / f"{agent}-review-pr{pr}-round{round_}-payload.json" + + +def _existing_comments_path(pr: int) -> pathlib.Path: + return _tmp_dir() / f"cross-review-pr{pr}-existing-comments.txt" + + +def _now() -> str: + return _dt.datetime.now(_dt.timezone.utc).astimezone().isoformat(timespec="seconds") + + +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()) + + +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)) + 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 + # worktree path を先に解決してから tmp_dir を決定する。 + # gemini の workspace 制約 (~/.gemini/tmp/<workspace_basename>) と + # 一致させるため、worktree basename ベースで tmp_dir を計算する必要がある。 + # 旧実装は _tmp_dir(args.worktree) を args.worktree=None のまま呼び、 + # os.getcwd() の basename (= 親リポジトリ名) を採用していたため、 + # launch-gemini.sh で `cd $WORKTREE` した後の gemini が + # `~/.gemini/tmp/<repo>` への write をブロックして hard timeout していた。 + worktree = args.worktree or f"/work/worktrees/pr{pr}" + tmp_dir = _tmp_dir(worktree) + state_file = tmp_dir / f"cross-review-pr{pr}-state.json" + + # 再開 + if state_file.exists(): + st = json.loads(state_file.read_text()) + if st.get("final") is None: + wt = st.get("worktree_path") or "" + info(f"↻ 前回中断 state から再開(round={len(st.get('rounds', []))})") + print(f"PR={st['current_pr']}") + print(f"WORKTREE={wt}") + print(f"TMP_DIR={tmp_dir}") + 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 分離 + head_branch = _sh(["gh", "pr", "view", str(pr), "--json", "headRefName", "--jq", ".headRefName"]) + base_branch = _sh(["gh", "pr", "view", str(pr), "--json", "baseRefName", "--jq", ".baseRefName"]) + if not pathlib.Path(worktree).exists(): + _sh(["git", "fetch", "origin", head_branch]) + # head branch が既に別の worktree (例: 現在の作業ディレクトリ) で checkout されている + # 場合、`git worktree add <path> <branch>` は + # `fatal: '<branch>' is already used by worktree at '<other>'` + # で落ちる。これを避けるため、`origin/<head_branch>` を **detached** で展開する。 + # cross-review はファイル参照しかしないので detached HEAD で全く問題ない。 + _sh(["git", "worktree", "add", "--detach", worktree, f"origin/{head_branch}"]) + info(f"✅ worktree 作成 (detached @ origin/{head_branch}): {worktree}") + else: + info(f"↻ 既存 worktree 流用: {worktree}") + + # 既存コメントスナップショット(重複指摘防止)。 + # NOTE: `gh api --paginate` は REST のページごとに **JSON 配列が連続して** stdout に出る + # ため、`json.loads(r.stdout)` は複数ページで JSONDecodeError になり、コメントが空に + # 落ちる。`--jq '.[] | ...'` で gh CLI 側に整形させ、行単位で素直に書き出す。 + repo = _sh(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) + jq_filter = ( + r'.[] | "\(.path // "?"):\(.line // .original_line // "?") ' + r'[\(.user.login)] \(.body // "" | split("\n")[0])"' + ) + r = subprocess.run( + ["gh", "api", f"repos/{repo}/pulls/{pr}/comments", "--paginate", "--jq", jq_filter], + 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) + else: + info(f"⚠ 既存コメント取得失敗: {r.stderr.strip()[:200]}") + existing_path.write_text("") + + 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, + "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)) + info(f"✅ state 初期化: {state_file}") + print(f"PR={pr}") + print(f"WORKTREE={worktree}") + print(f"TMP_DIR={tmp_dir}") + print(f"REPO={repo}") + print(f"HEAD_BRANCH={head_branch}") + print(f"BASE_BRANCH={base_branch}") + print(f"IS_OWN_PR={'1' if is_own else '0'}") + print(f"EVENT_DOWNGRADE={'1' if event_downgrade 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 _tmp_dir() / f"{agent}-review-pr{pr}-result.json") + if not rfile.exists() or rfile.stat().st_size == 0: + die(f"{agent}: result 未生成 ({rfile})") + + r = json.loads(rfile.read_text()) + st = _load(pr) + if not st.get("rounds"): + die(f"{agent}: state.rounds が空。`state.py start-round` を先に呼んでください") + st["rounds"][-1][agent] = { + "intent": r.get("event"), + "posted_as": r.get("posted_as", r.get("event")), + "comments": r.get("comments_count"), + "review_url": r.get("review_url"), + "by_severity": r.get("by_severity", {}), + } + _save(pr, st) + info(f"✅ {agent}: intent={r.get('event')} posted_as={r.get('posted_as', r.get('event'))} " + f"comments={r.get('comments_count')}") + + +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()) + except json.JSONDecodeError: + continue + for c in payload.get("comments", []): + 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 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 + ffile = pathlib.Path(args.file or _tmp_dir() / f"fix-pr{pr}-result.json") + if not ffile.exists() or ffile.stat().st_size == 0: + die("fix サブエージェントが戻り値ファイルを生成しなかった", code=3) + + fix = json.loads(ffile.read_text()) + st = _load(pr) + if not st.get("rounds"): + die("state.rounds が空。`state.py start-round` を先に呼んでください", code=3) + round_no = st["rounds"][-1]["round"] + + st["rounds"][-1]["fix"] = { + "commit": fix.get("fix_commit"), + "fixed": fix.get("fixed_count", 0), + "deferred": len(fix.get("deferred", []) or []), + "rejected": len(fix.get("rejected", []) or []), + "resolved_threads": len(fix.get("resolved_threads", []) or []), + "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 (fix.get("deferred") or []): + 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.get('fix_commit')} fixed={fix.get('fixed_count', 0)})") + 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.""" + 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 更新。""" + 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 <PR#>` を起動してください。") + 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=6) + sp.add_argument("--rotate-after", type=int, default=5) + sp.add_argument("--only", choices=["codex", "gemini"], default=None) + sp.add_argument("--worktree", default=None) + 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/skills/cross-review/scripts/wait-review.sh b/plugins/ndf/skills/cross-review/scripts/wait-review.sh new file mode 100755 index 00000000..8741d36d --- /dev/null +++ b/plugins/ndf/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 <PR> [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/data-analyst-export/SKILL.md b/plugins/ndf/skills/data-analyst-export/SKILL.md index 6aebe491..a25e4911 100644 --- a/plugins/ndf/skills/data-analyst-export/SKILL.md +++ b/plugins/ndf/skills/data-analyst-export/SKILL.md @@ -1,15 +1,7 @@ --- name: data-analyst-export -description: | - Export query results to various formats (CSV, JSON, Excel, Markdown tables) with proper formatting and headers. Use when saving analysis results to files. - - This skill provides data export utilities for multiple formats: - - CSV: Comma-separated with headers, customizable delimiters - - JSON: Structured data with pretty-print option - - Excel: Multiple sheets, cell formatting - - Markdown: Tables for documentation - - Triggers: "export data", "save results", "output CSV", "output JSON", "output Excel", "データ出力", "結果保存", "エクスポート" +description: "Export query results to various formats (CSV, JSON, Excel, Markdown tables) with proper formatting and headers. CSV (custom delimiters), JSON (pretty-print), Excel (multi-sheet/formatting), Markdown tables for docs." +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 diff --git a/plugins/ndf/skills/data-analyst-sql-optimization/SKILL.md b/plugins/ndf/skills/data-analyst-sql-optimization/SKILL.md index 30e00f3b..8bd96f05 100644 --- a/plugins/ndf/skills/data-analyst-sql-optimization/SKILL.md +++ b/plugins/ndf/skills/data-analyst-sql-optimization/SKILL.md @@ -1,15 +1,7 @@ --- name: data-analyst-sql-optimization -description: | - Apply SQL optimization patterns including index usage, query rewriting, JOIN optimization, and window functions. Use when improving query performance or analyzing slow queries. - - This skill provides comprehensive SQL optimization patterns: - - N+1 query elimination - - Index optimization strategies - - JOIN vs subquery performance - - Window functions for complex aggregations - - Triggers: "optimize SQL", "slow query", "improve performance", "SQL最適化", "クエリ改善", "パフォーマンス向上" +description: "Apply SQL optimization patterns: index usage, query rewriting, JOIN/subquery optimization, window functions, N+1 elimination." +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 diff --git a/plugins/ndf/skills/deepwiki-transfer/SKILL.md b/plugins/ndf/skills/deepwiki-transfer/SKILL.md index 83711cd8..fa103acc 100644 --- a/plugins/ndf/skills/deepwiki-transfer/SKILL.md +++ b/plugins/ndf/skills/deepwiki-transfer/SKILL.md @@ -1,17 +1,8 @@ --- name: deepwiki-transfer -description: | - DeepWiki(Devin MCP)のドキュメント内容を対象リポジトリのディレクトリにMarkdownファイルとして転載する。セクション構成を維持し、GFM準拠・日本語変換まで自動処理する。 - - This skill provides DeepWiki content transfer capabilities: - - DeepWikiコンテンツの一括取得(公開/プライベートリポジトリ対応) - - セクション番号付きファイル分割 - - GFM準拠の自動修正 - - 日本語翻訳(オプション) - - Triggers: "deepwiki transfer", "deepwiki転載", "wiki転載", "リポジトリドキュメント取得", "DeepWikiからMarkdown", "transfer wiki contents" +description: "DeepWiki (Devin MCP) のドキュメント内容を対象リポジトリの Markdown ファイルとして転載する。セクション構成維持・番号付きファイル分割・GFM 準拠補正・日本語翻訳 (オプション) まで自動処理。" +when_to_use: "DeepWiki から Markdown としてコンテンツを取得・転載したいとき。Triggers: 'deepwiki transfer', 'deepwiki転載', 'wiki転載', 'リポジトリドキュメント取得', 'DeepWikiからMarkdown', 'transfer wiki contents'" disable-model-invocation: true -user-invocable: true allowed-tools: - Bash - Read diff --git a/plugins/ndf/skills/deepwiki-transfer/scripts/fetch_wiki.py b/plugins/ndf/skills/deepwiki-transfer/scripts/fetch_wiki.py index 3d355be3..17c2a83a 100644 --- a/plugins/ndf/skills/deepwiki-transfer/scripts/fetch_wiki.py +++ b/plugins/ndf/skills/deepwiki-transfer/scripts/fetch_wiki.py @@ -142,7 +142,7 @@ def main(): parser.add_argument( "--repo", required=True, - help="対象リポジトリ(owner/repo形式、例: owner/repo)", + help="対象リポジトリ(owner/repo形式、例: volareinc/carmo-system-console)", ) parser.add_argument( "--output", diff --git a/plugins/ndf/skills/deploy/SKILL.md b/plugins/ndf/skills/deploy/SKILL.md new file mode 100644 index 00000000..29d847ac --- /dev/null +++ b/plugins/ndf/skills/deploy/SKILL.md @@ -0,0 +1,114 @@ +--- +name: deploy +description: "現在のfeatureブランチを環境ブランチ(qa/staging等)へデプロイPRを作成する。featureブランチ全体をorigin/main取り込み済みのdeployブランチ経由でPRする。cherry-pick-prと異なり、部分選択でなくブランチ全体を適用する用途。" +argument-hint: "<env-branch> (例: 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 環境で動作確認 + +<!-- I want to review in Japanese. --> +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/skills/docker-container-access/SKILL.md b/plugins/ndf/skills/docker-container-access/SKILL.md index a4f74dd9..3829dc57 100644 --- a/plugins/ndf/skills/docker-container-access/SKILL.md +++ b/plugins/ndf/skills/docker-container-access/SKILL.md @@ -1,15 +1,7 @@ --- name: docker-container-access -description: | - Dockerコンテナへのアクセス方法を判定し、適切な接続コマンドを提供します。DinD/DooD環境の自動検出とトラブルシューティング。 - - このSkillは以下を提供します: - - DinD/DooD環境の判定方法 - - 環境に応じた接続方法の選択 - - bind mountの注意点と代替手段 - - curlやPlaywright MCPでの接続例 - - Triggers: "docker access", "container connect", "localhost not working", "DinD", "DooD", "Docker接続", "コンテナアクセス", "curl container" +description: "Docker コンテナへのアクセス方法を判定し、適切な接続コマンドを提供する。DinD/DooD 環境の自動検出、bind mount の注意点と代替手段、curl / Playwright MCP での接続例を扱う。" +when_to_use: "Docker / コンテナへのアクセス・localhost 接続不可・DinD/DooD 環境判定が必要なとき。Triggers: 'docker access', 'container connect', 'localhost not working', 'DinD', 'DooD', 'Docker接続', 'コンテナアクセス', 'curl container'" allowed-tools: - Read - Bash diff --git a/plugins/ndf/skills/fix/SKILL.md b/plugins/ndf/skills/fix/SKILL.md index 569866af..b212da0a 100644 --- a/plugins/ndf/skills/fix/SKILL.md +++ b/plugins/ndf/skills/fix/SKILL.md @@ -1,8 +1,8 @@ --- name: fix -description: "PRのレビューコメントを確認し、修正対応を実行する" -argument-hint: "[PR番号]" -disable-model-invocation: true +description: "PRのレビューコメントを確認し、優先度に応じてコード修正を実行する。サブエージェント (general-purpose) 起動にも対応。--defer-nit / --severity-min で対応範囲を制御。" +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 @@ -16,18 +16,164 @@ allowed-tools: 直前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 <PR番号> --defer-nit を実行してください。 + +PR: <PR番号> +リポジトリ: <owner/repo> +重要度ポリシー: 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確認 -2. 修正可否判断 -3. 問題点修正 -4. コミット・プッシュ -5. PRにSummaryコメントを追加 -6. 対応したコードコメントに個別に返信 -7. reviewerに再レビューを依頼 -8. 対応完了したコードコメントを「Resolve Conversation」にする +1. review comment取得 + 重要度を**独自に再判定**(AI agent のラベルは参考値) +2. **CIエラー確認**(`gh pr checks <PR>` で **現時点の** 失敗ジョブを検出) + - **完了待ちはしない**。実行中(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 <PR> --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エラーチェック + +### 失敗ジョブの検出 -- 4はgit、1と5以降はgithub mcpまたはghを利用 +```bash +# PRの全チェック状態を確認(FAIL/PASS/PENDING) +gh pr checks <PR番号> + +# JSON形式で詳細取得 +gh pr checks <PR番号> --json name,state,link,completedAt + +# 失敗ジョブのみ抽出 +gh pr checks <PR番号> --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 <PR番号> --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 <PR> --json name,state` での **状態スナップショット取得は実施** + し、戻り値の `ci_status` / `ci_failed_checks` に反映する + +### 失敗ログの取得 + +```bash +# ワークフロー実行ID取得 +RUN_ID=$(gh run list --branch <branch-name> --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 <job-id> --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コマンド例 @@ -84,10 +230,66 @@ gh api graphql -f query=' - 指摘がすべて正しいとは限らない。修正前に仕様を調査し、実施の可否を判断すること - 未対応の場合はその理由をコメントに書き込む +## 戻り値フォーマット(必須) + +サブエージェント呼び出し時の 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 をメインに返すサマリの基礎とする。 + ## 作業完了報告(必須) -PRにSummaryコメントを追加: -- 対応した指摘の一覧(優先度、ファイル、指摘内容、対応状況) -- 各修正の問題点と修正内容 -- テスト結果 -- 修正ファイル一覧 +メイン or PR への報告内容(戻り値ファイルから抽出): +- 対応した指摘の件数(重要度別: critical/major/minor/nit) +- **deferred 件数**(主に nit、最後にユーザ問い合わせ予定) +- **rejected 件数**(bot 指摘が不適切で修正しなかった件、各々理由付き) +- **対応したCIエラーの一覧**(ジョブ名、エラー内容、修正方法) +- **対応した flaky テストの一覧**(PR範囲外も含む) +- 修正コミット SHA / 修正ファイル一覧 +- 戻り値ファイルパス: `/tmp/fix-pr<番号>-result.json` +- **PR URL を最後に必ず記載**(例: `https://github.com/<owner>/<repo>/pull/<番号>`) diff --git a/plugins/ndf/skills/gemini/SKILL.md b/plugins/ndf/skills/gemini/SKILL.md new file mode 100644 index 00000000..40e6dc7c --- /dev/null +++ b/plugins/ndf/skills/gemini/SKILL.md @@ -0,0 +1,444 @@ +--- +name: gemini +description: "gemini CLI (Google Gemini) を直接実行してコード生成・レビュー・調査を外部AIに委譲する手順。`gemini -p` を非対話モードで実行し、stdout で最終結果を回収する。" +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 <PR番号> 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-<task-name>.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-<task-name>.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-<task-name>.md` にも `write_file` で書き出してください。 +``` + +## 実例: レビュー依頼の完全フロー + +```bash +# === 1. プロンプト書き出し === +FINAL=/tmp/gemini-output-api-v2-review.md + +cat > /tmp/review-prompt.md <<EOF +あなたはシニアバックエンドエンジニアとして、以下をレビューしてください。 + +## 対象ファイル(必ず最初に読むこと) +/workspace/docs/design/api-v2.md + +## 観点 +1. コードとの一致(行番号・件数・関数シグネチャ) +2. API後方互換性(v1クライアントが壊れないか) + +## 調査対象コード +- src/api/v2/** +- src/api/v1/** (比較用) + +## 出力先 +- stdout に Markdown で出力 +- 保険として \`${FINAL}\` にも \`write_file\` で書き出すこと + +## 出力形式 +Markdown で 400〜500 行、日本語。 +EOF + +# === 2. バックグラウンド起動 === +# レビュー用途なので plan mode(読み取り専用)+ text 出力 +gemini --approval-mode plan --output-format text \ + -p "$(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 <model>` でより長い 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/git-gh-operations/SKILL.md b/plugins/ndf/skills/git-gh-operations/SKILL.md index dc0e63bd..7cf2dca8 100644 --- a/plugins/ndf/skills/git-gh-operations/SKILL.md +++ b/plugins/ndf/skills/git-gh-operations/SKILL.md @@ -1,15 +1,7 @@ --- name: git-gh-operations -description: | - git/gh コマンド実行時の共通エラーパターンと正しい操作方法を提供します。 - CWD問題、パス指定、GitHub API操作の注意点を網羅。 - - このSkillは以下を提供します: - - git操作時のパス解決ルール - - gh CLI / GitHub API の正しい使い方 - - 過去のエラー事例と対策 - - Triggers: "git add", "git commit", "git push", "gh pr", "gh api", "GitHub操作", "gitエラー", "fatal:", "pathspec" +description: "git / gh コマンド実行時の共通エラーパターンと正しい操作方法。CWD 問題、パス解決ルール、gh CLI / GitHub API の正しい使い方、過去のエラー事例と対策を扱う。" +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 @@ -158,11 +150,18 @@ gh pr checks PR --repo OWNER/REPO 2>&1 || true # OK: 失敗のみフィルタ gh pr checks PR --repo OWNER/REPO 2>&1 | grep -i fail || true - -# OK: --watch で完了まで待つ場合も同様 -gh pr checks PR --repo OWNER/REPO --watch 2>&1 || 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 \ diff --git a/plugins/ndf/skills/google-auth/SKILL.md b/plugins/ndf/skills/google-auth/SKILL.md index 12732cc4..fe432ee6 100644 --- a/plugins/ndf/skills/google-auth/SKILL.md +++ b/plugins/ndf/skills/google-auth/SKILL.md @@ -1,104 +1,173 @@ --- name: google-auth -description: | - Google API(Sheets, Drive, Apps Script等)のOAuth2認証が必要な操作を行う際に自動参照。 - トークン取得手順、スコープ指定、認証済みトークンの利用方法を扱う。 - - Triggers: "Google認証", "OAuth", "google_token", "spreadsheets", "Google API", "client_secret" +description: "Google API (Sheets, Drive, Apps Script, Chat, Calendar 等) の OAuth2 認証ヘルパ。単一トークンファイルで複数 API のスコープを一元管理し、CLI / Python ライブラリ両方として使える。" +when_to_use: "Google API の OAuth2 認証が必要なときに自動参照。Triggers: 'Google認証', 'OAuth', 'google_token', 'spreadsheets', 'Google API', 'client_secret'" allowed-tools: - Read - Bash(python *) - Bash(uv *) - - Bash(pip *) --- # Google OAuth2 認証ガイド -Google API を使用する操作(スプレッドシート読み書き、GAS実行等)で認証が必要な場合に参照する。 - -## 認証スクリプト - -`${CLAUDE_SKILL_DIR}/scripts/google_auth.py` を使用する。 - -### 前提条件 - -1. **`client_secret.json` の準備**(初回のみ): - - [GCPコンソール](https://console.cloud.google.com/apis/credentials) を開く - - 「認証情報を作成」→「OAuthクライアントID」→ アプリケーションの種類「デスクトップアプリ」 - - 作成後、JSONをダウンロードしてプロジェクトルートに `client_secret.json` として配置 +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` に追加し、絶対にコミットしないこと - **client_secret.jsonが見つからない場合の案内**: 上記手順をユーザーに伝え、配置後に再実行を依頼する。 +2. **依存パッケージのインストール** (skill ローカルの `pyproject.toml` を使う): -2. 依存パッケージのインストール: + ```bash + uv pip install -r ${CLAUDE_SKILL_DIR}/pyproject.toml + # もしくは: uv run --project ${CLAUDE_SKILL_DIR} python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py ... + ``` -```bash -# uv環境の場合(推奨) -uv pip install -r ${CLAUDE_SKILL_DIR}/pyproject.toml - -# pip の場合 -pip install google-auth-oauthlib -``` +## CLI 使用法 -### 実行方法 - -ユーザーに以下のコマンドを案内し、`!` プレフィックスで実行してもらう(インタラクティブな認証のため): +認証はインタラクティブなブラウザ操作が必要なので、**ユーザに `!` プレフィックスで実行を案内する**。 +エージェントが Bash で直接呼び出すとサーバ待ちでハングする。 ```bash -# デフォルト(spreadsheets.readonly) +# デフォルト (ローカルサーバ port 9123、スコープ spreadsheets.readonly) ! python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py -# スコープ指定(ショートハンド) +# スコープ指定 (ショートハンド) ! python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py spreadsheets drive -# スコープ指定(フルURL) +# スコープ指定 (フル URL) ! python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py https://www.googleapis.com/auth/script.projects -# client_secret.json のパスを明示的に指定 -! python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py --client-secret /path/to/client_secret.json spreadsheets +# 手動 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 ``` ### 利用可能なスコープショートハンド -| ショートハンド | フルスコープ | -|--------------|------------| -| `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` | -| `script.projects` | `https://www.googleapis.com/auth/script.projects` | -| `script.external_request` | `https://www.googleapis.com/auth/script.external_request` | +| ショートハンド | フル 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/<arg>` として展開される。フル URL も直接渡せる。 ### 認証フロー -1. スクリプトがポート9123でローカルサーバーを起動 -2. ユーザーが表示されたURLをブラウザで開いて認証 -3. トークンが `/tmp/google_token.json` に保存される +#### 既定: ローカルサーバ方式 + +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` に保存 -認証後、`/tmp/google_token.json` にトークンが保存される。Python から利用する場合: +ポートが空いていないコンテナ・サーバでも確実に動く。 + +## Python ライブラリ用法 + +他のスキルやスクリプトから import: ```python -import json -from google.oauth2.credentials import Credentials - -with open('/tmp/google_token.json') as f: - token_data = json.load(f) - -creds = Credentials( - token=token_data['token'], - refresh_token=token_data['refresh_token'], - token_uri=token_data['token_uri'], - client_id=token_data['client_id'], - client_secret=token_data['client_secret'], - scopes=token_data['scopes'], -) +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 での認証 (非推奨) -- 認証はインタラクティブなブラウザ操作が必要なため、**エージェントが直接実行することはできない** -- ユーザーに `! python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py [スコープ]` の実行を案内すること -- トークンは `/tmp/google_token.json` に保存されるため、コンテナ再起動で消える -- `client_secret.json` は `.gitignore` に含まれており、リポジトリにコミットされない +gcloud CLI の OAuth 認証はインタラクティブ入力の制約で多くのエージェント環境では動作しない。 +本スキルの `google_auth.py` を使うこと。 diff --git a/plugins/ndf/skills/google-auth/pyproject.toml b/plugins/ndf/skills/google-auth/pyproject.toml index fd3600bc..2d87846b 100644 --- a/plugins/ndf/skills/google-auth/pyproject.toml +++ b/plugins/ndf/skills/google-auth/pyproject.toml @@ -1,8 +1,9 @@ [project] name = "google-auth-skill" -version = "0.1.0" -description = "Google OAuth2認証スクリプトの依存関係" +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 index c64fe5a7..9bf6e575 100644 --- a/plugins/ndf/skills/google-auth/scripts/google_auth.py +++ b/plugins/ndf/skills/google-auth/scripts/google_auth.py @@ -1,128 +1,298 @@ -""" -Google OAuth2 認証スクリプト +"""Google API 共通 OAuth2 認証モジュール -Google API(Sheets, Drive, Apps Script等)のOAuth2トークンを取得する。 -ポート9123でローカル認証サーバーを起動し、ブラウザ認証後にトークンを保存する。 +CLI とライブラリの両方として使える: -前提: - - client_secret.json がカレントディレクトリに配置されていること - - google-auth-oauthlib がインストールされていること - uv pip install -r pyproject.toml (スキルディレクトリのpyproject.toml) - または - pip install google-auth-oauthlib + # ライブラリ用途 (他スキルから import) + from google_auth import get_credentials + creds = get_credentials(['drive.file']) # 既存スコープに追加して再認証 -使い方: - python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py [スコープ...] + # CLI: 初回認証 (デフォルトはローカルサーバ port 9123) + python google_auth.py spreadsheets drive - # デフォルト(spreadsheets.readonly) - python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py + # CLI: 手動 copy-paste フロー (ポート不要、Bash 環境で確実) + python google_auth.py --manual spreadsheets - # スコープ指定 - python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py spreadsheets script.projects + # CLI: トークン情報表示 / 削除 + python google_auth.py --show + python google_auth.py --clear - # フルURL指定も可能 - python ${CLAUDE_SKILL_DIR}/scripts/google_auth.py https://www.googleapis.com/auth/spreadsheets +特徴: + - トークンは `~/.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 = os.path.dirname(os.path.abspath(__file__)) -SKILL_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, '..')) -DEFAULT_CLIENT_SECRET_PATH = os.path.join(os.getcwd(), 'client_secret.json') -TOKEN_OUTPUT_PATH = '/tmp/google_token.json' -AUTH_PORT = 9123 +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', - 'script.projects': 'https://www.googleapis.com/auth/script.projects', - 'script.external_request': 'https://www.googleapis.com/auth/script.external_request', + '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_SCOPES = ['https://www.googleapis.com/auth/spreadsheets.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` → `<SKILL_DIR>/client_secret.json` + → `<CWD>/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 フロー。 -def resolve_scopes(args): - """引数をGoogle APIスコープURLに変換する""" - if not args: - return DEFAULT_SCOPES + redirect_uri は `http://127.0.0.1:<空きポート>/` で動的割当 (どのポートにもサーバを + 立てないので競合しない)。ブラウザは「このサイトにアクセスできません」になるが、 + アドレスバーの URL をユーザに貼り付けてもらえば認可コードを取り出せる。 - scopes = [] - for arg in args: - if arg.startswith('https://'): - scopes.append(arg) - elif arg in SCOPE_SHORTCUTS: - scopes.append(SCOPE_SHORTCUTS[arg]) + `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: - scopes.append(f'https://www.googleapis.com/auth/{arg}') - return scopes + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = old_insecure -def parse_args(): - parser = argparse.ArgumentParser(description='Google OAuth2 認証トークンを取得する') - parser.add_argument( - '--client-secret', - default=DEFAULT_CLIENT_SECRET_PATH, - help=f'client_secret.json のパス(デフォルト: カレントディレクトリの client_secret.json)', +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, ) - parser.add_argument( - 'scopes', - nargs='*', - help='Google APIスコープ(ショートハンドまたはフルURL)', + 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, ) - return parser.parse_args() - - -def main(): - args = parse_args() - scopes = resolve_scopes(args.scopes) - client_secret_path = args.client_secret - - if not os.path.exists(client_secret_path): - print(f'エラー: {client_secret_path} が見つかりません。') - print() - print('client_secret.json の取得手順:') - print(' 1. GCPコンソールを開く: https://console.cloud.google.com/apis/credentials') - print(' 2.「認証情報を作成」→「OAuthクライアントID」→ アプリケーションの種類「デスクトップアプリ」') - print(' 3. 作成後、JSONをダウンロード') - print(f' 4. ダウンロードしたファイルを {DEFAULT_CLIENT_SECRET_PATH} に配置') - print() - print('別の場所にある場合は --client-secret オプションでパスを指定:') - print(f' python {__file__} --client-secret /path/to/client_secret.json [スコープ...]') - sys.exit(1) - - print(f'スコープ: {scopes}') - print(f'ポート{AUTH_PORT}で認証サーバーを起動します...') - print('表示されるURLをブラウザで開いてください。') - print('認証後、自動的にトークンが保存されます。') - print() - - flow = InstalledAppFlow.from_client_secrets_file(client_secret_path, scopes) - creds = flow.run_local_server(port=AUTH_PORT, open_browser=False) - - token_data = { - 'token': creds.token, - 'refresh_token': creds.refresh_token, - 'token_uri': creds.token_uri, - 'client_id': creds.client_id, - 'client_secret': creds.client_secret, - 'scopes': list(creds.scopes or []), - } - - with open(TOKEN_OUTPUT_PATH, 'w') as f: - json.dump(token_data, f, indent=2) - - print(f'認証成功!トークンを {TOKEN_OUTPUT_PATH} に保存しました。') + print('認証成功') + print('スコープ:') + for s in creds.scopes or []: + print(f' - {s}') + return 0 if __name__ == '__main__': - 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 00000000..43b316f8 --- /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 00000000..82a21069 --- /dev/null +++ b/plugins/ndf/skills/google-chat/SKILL.md @@ -0,0 +1,153 @@ +--- +name: google-chat +description: "Google Chat API でスペースのメッセージ読み取り・スペース一覧を取得する。WebFetch は認証付き Chat ページに非対応のため、Chat API + OAuth2 ユーザー認証で取得する (認証は ndf:google-auth に委譲)。" +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 00000000..f3d5b808 --- /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 00000000..b98f3829 --- /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 00000000..c2cf875c --- /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 00000000..198c2bfa --- /dev/null +++ b/plugins/ndf/skills/google-drive/SKILL.md @@ -0,0 +1,111 @@ +--- +name: google-drive +description: "Google Drive / Google Docs API でファイルのエクスポート・ダウンロード・アップロード (公開共有リンク付与) を行う。認証は ndf:google-auth に委譲。" +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 00000000..48e9e772 --- /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 00000000..6a52cac4 --- /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 00000000..7a16ba38 --- /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/implementation-plan/SKILL.md b/plugins/ndf/skills/implementation-plan/SKILL.md new file mode 100644 index 00000000..3f56284b --- /dev/null +++ b/plugins/ndf/skills/implementation-plan/SKILL.md @@ -0,0 +1,98 @@ +--- +name: implementation-plan +description: "実装プランファイル作成・更新の手順。実装開始時およびPR作成時にissues/配下の実装プランの有無を確認し、なければ会話履歴・git log・git diffから生成する。複数ファイル変更・新規機能追加・DBマイグレーション伴う変更が対象。" +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/skills/investigation-rules/SKILL.md b/plugins/ndf/skills/investigation-rules/SKILL.md new file mode 100644 index 00000000..2bbdae33 --- /dev/null +++ b/plugins/ndf/skills/investigation-rules/SKILL.md @@ -0,0 +1,105 @@ +--- +name: investigation-rules +description: "調査レポート作成のルール。否定的結論のエビデンス要件、残課題の記載フォーマット、ハルシネーション防止のための裏取り原則を扱う。DB調査に限らずコードベース調査・仕様調査一般に適用。" +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/skills/issue-plan-strategy/SKILL.md b/plugins/ndf/skills/issue-plan-strategy/SKILL.md new file mode 100644 index 00000000..d4b6f7f7 --- /dev/null +++ b/plugins/ndf/skills/issue-plan-strategy/SKILL.md @@ -0,0 +1,299 @@ +--- +name: issue-plan-strategy +description: "1 つの issue に対する plan(企画書・設計書) 作成、および plan 実行(実装)時のブランチ・worktree・Draft PR・レビュー運用を一括で扱うワークフロー。issue ファイル/URL を引数に取るスラッシュコマンドとしても、issue から plan 作成依頼を受けた時 / 既存 plan の実装依頼を受けた時の自動発動 skill としても利用可能。" +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 <num> --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 → default を 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/<PLAN-ID>` ブランチや Draft PR が存在する場合は作成をスキップし、Step 4 へ進む。 + +### release ブランチ作成 + +```bash +git fetch origin <default-branch> +git checkout -b release/<PLAN-ID> origin/<default-branch> +git push -u origin release/<PLAN-ID> +``` + +### release → default の Draft PR を先行作成 + +```bash +gh pr create \ + --base <default-branch> \ + --head release/<PLAN-ID> \ + --draft \ + --title "release: <PLAN-ID> <概要>" \ + --body "$(cat <<'EOF' +## Summary +- plan: issues/<PLAN-ID>_xxx.md +- 複数 PR を統合する release ブランチ +- 個別 PR が全て merge され次第 Ready for review にする + +## 個別 PR +- [ ] #<TBD> PR1: ... +- [ ] #<TBD> PR2: ... +- [ ] #<TBD> PR3: ... + +## Test plan (結合観点のみ) +- [ ] 個別 PR では検出できない結合テスト項目 + +<!-- I want to review in Japanese. --> +EOF +)" +``` + +release PR を **先に作る理由**: PR 番号が確定し、個別 PR の説明から参照できるため。 + +## Step 4: 個別 PR ブランチ + Draft PR 先行作成 + +> 既存ブランチは `git branch -a | grep "feature/<PLAN-ID>-"` で確認し、未作成のものだけ作る。Draft PR の存在は `gh pr list --base release/<PLAN-ID> --state all` で確認。 + +各 PR について **同じパターンで先に Draft PR まで作る**: + +```bash +# release ブランチを base に個別ブランチを切る +git fetch origin release/<PLAN-ID> +git checkout -b feature/<PLAN-ID>-<scope> origin/release/<PLAN-ID> + +# 空コミットで push して Draft PR を作る (base=release と HEAD が同一だと +# gh pr create が "No commits between ..." で失敗するため、差分ゼロのまま PR +# 作成のトリガにする目的で `--allow-empty` を使う) +git commit --allow-empty -m "chore: <PLAN-ID>-<scope> Draft PR 作成" +git push -u origin feature/<PLAN-ID>-<scope> + +gh pr create \ + --base release/<PLAN-ID> \ + --head feature/<PLAN-ID>-<scope> \ + --draft \ + --title "feat: <PLAN-ID>-<scope> <概要>" \ + --body "$(cat <<'EOF' +## Summary +- plan: issues/<PLAN-ID>_xxx.md +- release PR: #<release-pr-number> +- 担当範囲: <scope> + +## Test plan +- [ ] ... + +<!-- I want to review in Japanese. --> +EOF +)" +``` + +完了後 release PR の本文を `gh pr edit` で更新し、個別 PR 番号を `[ ]` に埋める。 + +## Step 5: git worktree で並行開発 + +並行可能 (依存なし or mock で先行可) な PR は **git worktree** で同時に開く: + +```bash +# repo ルート (default branch のまま) で +git worktree add ../<repo>-<PLAN-ID>-schema feature/<PLAN-ID>-schema +git worktree add ../<repo>-<PLAN-ID>-ui feature/<PLAN-ID>-ui + +# それぞれの worktree で別ターミナル / 別エージェントを起動 +``` + +ガイドライン: + +- **依存のある PR は順次着手**する (PR1 merge → PR2 開始) +- 並行 PR 間で同じファイルを触る場合は事前にレビュー観点で分担を明確化する +- 終わった worktree は `git worktree remove <path>` で片付ける +- Claude Code から並行開発を指示する場合、Agent tool の `isolation: "worktree"` も検討する + +## Step 6: 個別 PR のレビュー + +**レビューは原則個別 PR 単位**で行う: + +| 用途 | コマンド | +|---|---| +| PR 作成前のセルフレビュー | `/ndf:review-branch` | +| GitHub 上の単体レビュー | `/ndf:review <PR番号>` | +| codex + gemini 両方の収束ループ | `/ndf:cross-review <PR番号>` | +| 指摘の修正 | `/ndf:fix <PR番号>` | + +個別 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 → default の merge + +release PR が APPROVE されたら: + +```bash +# Draft 解除 +gh pr ready <release-pr-number> +# merge: 個別 PR が既に squash 済みで release ブランチに並んでいるため、 +# main 側でも個別 PR 単位の commit を追跡できる `--merge` (merge commit 保持) +# が既定として推奨。プロジェクト規約で線形履歴必須なら `--rebase`、 +# それ以外で commit 数を 1 本にしたい場合のみ `--squash`。 +gh pr merge <release-pr-number> --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/<PLAN-ID> +``` + +```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 が既に閉じている場合、コミット意図がずれる | +| 検証ブランチを 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/logging-guidelines/SKILL.md b/plugins/ndf/skills/logging-guidelines/SKILL.md new file mode 100644 index 00000000..4c3ffc87 --- /dev/null +++ b/plugins/ndf/skills/logging-guidelines/SKILL.md @@ -0,0 +1,112 @@ +--- +name: logging-guidelines +description: "ログを追加・修正する際のガイドライン。ログレベルの選択基準、ループ内ログの制御、エラー蓄積パターン、機密情報の扱いを扱う。言語/FW非依存の原則。" +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/skills/markdown-writing/SKILL.md b/plugins/ndf/skills/markdown-writing/SKILL.md index 236c3e0b..1c6fe439 100644 --- a/plugins/ndf/skills/markdown-writing/SKILL.md +++ b/plugins/ndf/skills/markdown-writing/SKILL.md @@ -1,14 +1,7 @@ --- name: markdown-writing -description: | - Markdown文書作成時の重要なルールを提供します。図表はmermaid/plantUMLを使用(ASCII ART禁止)、300行超の文書は順序prefix付きで分割。 - - 重要ルール: - - 図表: mermaid/plantUML使用(ASCII ARTは禁止、ツリー構造のみ例外) - - 文書長: 概ね300行以内、超える場合は分割 - - 分割時: ディレクトリ作成+順序prefix(01-, 02-, ...) - - Triggers: "Markdown作成", "ドキュメント作成", "文書作成", "図を描く", "mermaid", "create document", "write docs" +description: "Markdown 文書作成時の重要なルール。図表は mermaid/plantUML を使用 (ASCII ART 禁止、ツリー構造のみ例外)、概ね 300 行以内、超える場合は順序 prefix (01-, 02-, ...) 付きで分割する。" +when_to_use: "Markdown 文書 / 図表を作成 / 編集するとき。Triggers: 'Markdown作成', 'ドキュメント作成', '文書作成', '図を描く', 'mermaid', 'create document', 'write docs'" allowed-tools: - Read - Write diff --git a/plugins/ndf/skills/mcp-builder/LICENSE.txt b/plugins/ndf/skills/mcp-builder/LICENSE.txt new file mode 100644 index 00000000..4f881c52 --- /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 00000000..8a1a77a4 --- /dev/null +++ b/plugins/ndf/skills/mcp-builder/SKILL.md @@ -0,0 +1,236 @@ +--- +name: mcp-builder +description: Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK). +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 +<evaluation> + <qa_pair> + <question>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?</question> + <answer>3</answer> + </qa_pair> +<!-- More qa_pairs... --> +</evaluation> +``` + +--- + +# 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 00000000..87e9bb78 --- /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 +<evaluation> + <qa_pair> + <question>Your question here</question> + <answer>Single verifiable answer</answer> + </qa_pair> +</evaluation> +``` + +--- + +## 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 +<evaluation> + <qa_pair> + <question>Find the project created in Q2 2024 with the highest number of completed tasks. What is the project name?</question> + <answer>Website Redesign</answer> + </qa_pair> + <qa_pair> + <question>Search for issues labeled as "bug" that were closed in March 2024. Which user closed the most issues? Provide their username.</question> + <answer>sarah_dev</answer> + </qa_pair> + <qa_pair> + <question>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?</question> + <answer>7</answer> + </qa_pair> + <qa_pair> + <question>Find the repository with the most stars that was created before 2023. What is the repository name?</question> + <answer>data-pipeline</answer> + </qa_pair> +</evaluation> +``` + +## Evaluation Examples + +### Good Questions + +**Example 1: Multi-hop question requiring deep exploration (GitHub MCP)** +```xml +<qa_pair> + <question>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?</question> + <answer>Python</answer> +</qa_pair> +``` + +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 +<qa_pair> + <question>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?</question> + <answer>Product Manager</answer> +</qa_pair> +``` + +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 +<qa_pair> + <question>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.</question> + <answer>alex_eng</answer> +</qa_pair> +``` + +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 +<qa_pair> + <question>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?</question> + <answer>Healthcare</answer> +</qa_pair> +``` + +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 +<qa_pair> + <question>How many open issues are currently assigned to the engineering team?</question> + <answer>47</answer> +</qa_pair> +``` + +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 +<qa_pair> + <question>Find the pull request with title "Add authentication feature" and tell me who created it.</question> + <answer>developer123</answer> +</qa_pair> +``` + +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 +<qa_pair> + <question>List all the repositories that have Python as their primary language.</question> + <answer>repo1, repo2, repo3, data-pipeline, ml-tools</answer> +</qa_pair> +``` + +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 `<qa_pair>`** 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 `<qa_pair>` elements: + +```xml +<evaluation> + <qa_pair> + <question>Find the project created in Q2 2024 with the highest number of completed tasks. What is the project name?</question> + <answer>Website Redesign</answer> + </qa_pair> + <qa_pair> + <question>Search for issues labeled as "bug" that were closed in March 2024. Which user closed the most issues? Provide their username.</question> + <answer>sarah_dev</answer> + </qa_pair> +</evaluation> +``` + +## 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 +<evaluation> + <qa_pair> + <question>Find the user who created the most issues in January 2024. What is their username?</question> + <answer>alice_developer</answer> + </qa_pair> + <qa_pair> + <question>Among all pull requests merged in Q1 2024, which repository had the highest number? Provide the repository name.</question> + <answer>backend-api</answer> + </qa_pair> + <qa_pair> + <question>Find the project that was completed in December 2023 and had the longest duration from start to finish. How many days did it take?</question> + <answer>127</answer> + </qa_pair> +</evaluation> +``` + +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 00000000..b9d343cc --- /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 00000000..f6e5df98 --- /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<typeof UserSearchInputSchema>; + +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 '<query>'" 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<any>( + "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<typeof ListSchema>) { + 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<T>( + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + data?: any, + params?: any +): Promise<T> { + 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<ResourceData> { + const response = await axios.get(`${API_URL}/resource/${resourceId}`); + return response.data; +} + +// Bad: Promise chains +function fetchData(resourceId: string): Promise<ResourceData> { + 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<typeof UserSchema>; + +async function getUser(id: string): Promise<User> { + const data = await apiCall(`/users/${id}`); + return UserSchema.parse(data); // Runtime validation +} + +// Bad: Using any +async function getUser(id: string): Promise<any> { + 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<typeof UserSearchInputSchema>; + +// Shared utility functions +async function makeApiRequest<T>( + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + data?: any, + params?: any +): Promise<T> { + 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<T> 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 00000000..cf7ec996 --- /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: <error message>" or "No users found matching '<query>'" + + 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 00000000..ffcd0da3 --- /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 00000000..41778569 --- /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 <summary> tags +3. Provide feedback on the tools provided, wrapped in <feedback> tags +4. Provide your final response, wrapped in <response> tags + +Summary Requirements: +- In your <summary> 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 <feedback> 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 <response> tags +- If you cannot solve the task return <response>NOT_FOUND</response> +- 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}>(.*?)</{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 00000000..41e4459b --- /dev/null +++ b/plugins/ndf/skills/mcp-builder/scripts/example_evaluation.xml @@ -0,0 +1,22 @@ +<evaluation> + <qa_pair> + <question>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)?</question> + <answer>11614.72</answer> + </qa_pair> + <qa_pair> + <question>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.</question> + <answer>87.25</answer> + </qa_pair> + <qa_pair> + <question>A sphere has a volume of 500 cubic meters. Calculate its surface area in square meters. Round to 2 decimal places.</question> + <answer>304.65</answer> + </qa_pair> + <qa_pair> + <question>Calculate the population standard deviation of this dataset: [12, 15, 18, 22, 25, 30, 35]. Round to 2 decimal places.</question> + <answer>7.61</answer> + </qa_pair> + <qa_pair> + <question>Calculate the pH of a solution with a hydrogen ion concentration of 3.5 × 10^-5 M. Round to 2 decimal places.</question> + <answer>4.46</answer> + </qa_pair> +</evaluation> 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 00000000..e73e5d1e --- /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/official-skills-autoloader/SKILL.md b/plugins/ndf/skills/official-skills-autoloader/SKILL.md new file mode 100644 index 00000000..723c2521 --- /dev/null +++ b/plugins/ndf/skills/official-skills-autoloader/SKILL.md @@ -0,0 +1,121 @@ +--- +name: official-skills-autoloader +description: "Anthropic 公式 Skill (docx/pptx/xlsx/pdf 等) が必要な作業で、未インストール時に自動でダウンロードして使用する。" +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 `<name>` を準備しています...」と一言伝える。 + +### ステップ4: SKILL.mdを読み込んで実行 + +``` +Read(file_path="$HOME/.claude/skills/<SKILL_NAME>/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/<name>/` (ユーザー領域) +- プロジェクト単位で配置したい場合は `plugins/ndf/scripts/install-official-skills.sh --scope project <name>` を直接実行 + +### 再読込 + +同一セッション内では 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 <name...>` + +## エラーハンドリング + +| 症状 | 対応 | +|---|---| +| git clone失敗 | ネットワーク・認証を確認。プロキシ環境では HTTP_PROXY 設定を確認 | +| 対象Skillが公式にない | --list で最新の公式一覧を確認、マッピングを更新 | +| 権限エラー | `~/.claude/skills/` の書込権限を確認 | +| 既に別物がある | ユーザーに確認してから上書き | + +## 対象外 + +- 自作Skillの生成(これは `skill-creator` に委譲) +- プロプライエタリSkillのCIへの組込(ライセンス違反) +- NDFプラグイン自体のスキル管理 diff --git a/plugins/ndf/skills/playwright-scenario-test/.gitignore b/plugins/ndf/skills/playwright-scenario-test/.gitignore new file mode 100644 index 00000000..f9cf1ad0 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/.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-scenario-test/SKILL.md b/plugins/ndf/skills/playwright-scenario-test/SKILL.md new file mode 100644 index 00000000..fe888b04 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/SKILL.md @@ -0,0 +1,315 @@ +--- +name: playwright-scenario-test +description: "pytest-playwright 上の Web シナリオ E2E テスト実施フレームワーク。HTSM / ISTQB / FEW HICCUPPS に基づき page role 別の checklist + 必須技法マッピングを内蔵し、accessibility (axe-core) / Core Web Vitals 自動計測 + Playwright trace / HAR / 動画 / Markdown レポート + Google Drive 共有を pytest fixture / hook として提供する。v0.5.0 で利用者プロジェクトに all-in-one ディレクトリを埋め込む Skill 非依存構成へ移行。" +when_to_use: "E2E テスト計画立案 / 不具合エビデンス収集 / 動画レポート / Google Drive 共有が必要なとき。LP / 一覧 / 詳細 / 編集 / 申込フォーム / 検索 / ダッシュボード / 認証 / カート / チェックアウト / モーダル / ウィザード / 設定 / エラーページ など page role 別の理論ベースチェックを行う。Triggers: 'E2E テスト', 'シナリオテスト', '動画エビデンス', 'Playwright', 'pytest-playwright', 'リリース前確認', '回帰テスト', 'a11y テスト', 'accessibility テスト', 'Core Web Vitals', 'Web Vitals', 'page role', 'pwk_role', 'pwk_evidence'" +allowed-tools: + - Read + - Bash(uv *) + - Bash(pytest *) + - Bash(playwright *) + - Bash(python *) +--- + +# Playwright シナリオテスト Skill (v0.5.0) + +Web アプリの E2E シナリオを **理論ベース** で計画し、**pytest-playwright** 上で実行、**動画 + Markdown レポート + accessibility / web vitals** を自動収集する一式の Skill。 + +**v0.5.0 の方針**: 本 Skill は「テストを書き始めるためのスキャフォルダ」であり、`scripts/init_project.sh` で利用者プロジェクトに **all-in-one ディレクトリ** を埋め込んだ後は、Skill ディレクトリの存在に依存せず単独で動作する (CI / 別マシン / Skill 非導入のメンバー環境でも完結)。 + +## 用語集 + +ドメイン略語と正式名・意味の対応表。 + +| 略語 / 用語 | 正式名 / 意味 | +|---|---| +| accessibility (旧 a11y) | Web アクセシビリティ。WCAG 準拠を axe-core で機械検査 | +| web vitals (旧 CWV) | Google が定義する「ユーザ体感パフォーマンス指標」群 | +| LCP | Largest Contentful Paint — 最大コンテンツ描画時間 (体感ロード速度) | +| CLS | Cumulative Layout Shift — 累積レイアウトずれ量 (視覚的安定性) | +| TTFB | Time To First Byte — 初バイト到達時間 (サーバ応答速さ) | +| longest_task | Long Tasks API で観測した最長タスクのミリ秒値 (応答性代理指標) | +| HAR | HTTP Archive — ネットワーク通信ログのファイル形式 | +| trace | Playwright Trace — DOM スナップショット + 操作ログ + 動画の zip | +| overlay (旧 HUD) | テスト中に画面に重ねる赤丸カーソル + 字幕表示 | +| body_check | サーバが HTML 本文に出力した PHP/SSR エラー文字列の検出 | +| page role | LP / 一覧 / 詳細 / フォーム 等のページ種別。a11y / web vitals 自動実行の判定材料 | +| pwk | playwright_kit の略。fixture (`pwk_*`) / CLI option (`--pwk-*`) / env (`PWK_*`) の prefix | + +## 提供物 + +``` +playwright-scenario-test/ ← Skill 自体 (この Skill ディレクトリ) +├── SKILL.md ← このファイル +├── pyproject.toml ← Skill 開発用 (pytest entry-point 含む) +├── playwright_kit/ ← Python パッケージ本体 (旧 scenario_test) +│ ├── pytest_plugin.py ← pytest11 entry-point (addoption / markers / hooks) +│ ├── pytest_report.py ← report.md 生成 +│ ├── fixtures/ ← pytest fixtures +│ │ ├── auth.py — pwk_config / pwk_role_<id> +│ │ ├── evidence.py — pwk_evidence (HAR / trace / console / pageerror) +│ │ ├── accessibility.py — page_role marker autouse で axe-core +│ │ ├── web_vitals.py — page_role marker autouse で Core Web Vitals +│ │ └── body_check.py — page.on("response") で本文エラー文字列を検出 +│ ├── accessibility.py / web_vitals.py — axe-core / Web Vitals ランナー (純関数) +│ ├── overlay.py ← 赤丸カーソル + 字幕 (旧 HUD) JS +│ ├── video.py ← webm → mp4 変換 +│ └── config.py ← scenario.config.yaml ローダ +├── docs/ ← テスト方法論 (HTSM / ISTQB / FEW HICCUPPS) +├── scripts/ +│ ├── init_project.sh ← all-in-one 初期化 (rsync ベース) +│ ├── init_project.bat ← 同 Windows 版 (xcopy ベース) +│ ├── classify_page_role.py / run_a11y_scan.py / check_cwv.py / record_scenario.py +│ └── upload_evidence.py / gdrive_upload_dir.py / build_gdoc_with_drive_links.py +└── templates/ ← 利用者プロジェクト用雛形 + ├── pyproject.toml.runtime — runtime 用 pyproject (dev 用 deps を排除) + ├── runtime-gitignore — .venv / __pycache__ / reports/ + ├── runtime-README.md — Skill 無し環境向けの最低限の使い方 + ├── run.sh / run.bat — ワンコマンドランチャ + ├── scenario.config.yaml — base_url / roles / accessibility / web_vitals 設定 + ├── conftest.py.template — 利用者の conftest.py 雛形 + └── test_*.py.template — auth / list / form / dashboard 雛形 +``` + +init 後の利用者プロジェクト側 (Skill 非依存): + +``` +your-app/ +└── scenario-test/ ← all-in-one ランタイム (--runtime-dir で名前変更可) + ├── playwright_kit/ ← Python パッケージ本体 + ├── scripts/ ← 補助 CLI + ├── tests/ ← 利用者の pytest テスト + │ ├── conftest.py + │ └── test_*.py + ├── reports/ ← 実行結果 (.gitignore 推奨) + ├── scenario.config.yaml ← 利用者の設定 + ├── run.sh / run.bat ← ワンコマンドランチャ + ├── pyproject.toml ← runtime 用 (testpaths=["tests"]) + ├── uv.lock ← 再現性のため commit 推奨 + └── README.md ← runtime-README.md コピー +``` + +## クイックスタート + +```bash +# 1) このディレクトリ (Skill) で利用者プロジェクトを初期化 +cd .claude/plugins/ndf/skills/playwright-scenario-test # Skill のパスは環境による +./scripts/init_project.sh /path/to/your-app +# → /path/to/your-app/scenario-test/ 一式が作成され、uv sync + chromium install +# まで完了する + +# オプション: 配置先ディレクトリ名をカスタマイズ +./scripts/init_project.sh /path/to/your-app --runtime-dir e2e +# → /path/to/your-app/e2e/ + +# Windows +scripts\init_project.bat C:\path\to\your-app + +# 2) base_url / roles を編集 +$EDITOR /path/to/your-app/scenario-test/scenario.config.yaml + +# 3) tests/ にテストを追加 (init 時に test_auth/list/form/dashboard 雛形は配置済) +$EDITOR /path/to/your-app/scenario-test/tests/test_admin.py + +# 4) 実行 +cd /path/to/your-app +./scenario-test/run.sh # 全テスト +./scenario-test/run.sh -k test_admin # nodeid フィルタ +./scenario-test/run.sh --pwk-overlay # 動画に赤丸カーソル + 字幕 +./scenario-test/run.sh --pwk-drive-folder=<ID> # Drive 自動アップロード +``` + +→ 以降このディレクトリ (Skill) は不要。`your-app/scenario-test/` 単体で完結する。 + +### 複数ランタイム共存 + +```bash +./scripts/init_project.sh your-app --runtime-dir e2e-prod +./scripts/init_project.sh your-app --runtime-dir e2e-staging +# → your-app/e2e-prod/run.sh と your-app/e2e-staging/run.sh が独立に動く +``` + +## 利用者は通常の pytest テストを書く + +```python +# tests/test_admin_dashboard.py +import pytest +from playwright.sync_api import Page, expect + +@pytest.mark.page_role("dashboard") +@pytest.mark.role("admin") +def test_admin_kpi_view(page: Page, pwk_role_admin): + page.goto("/admin/dashboard") + expect(page.get_by_role("heading", name="売上サマリ")).to_be_visible() + page.get_by_role("link", name="ユーザ管理").click() + expect(page).to_have_url(lambda u: "/admin/users" in u) +``` + +提供 fixture / marker: + +| 提供 | 種別 | 役割 | +|---|---|---| +| `pwk_config` | session fixture | `scenario.config.yaml` をロード (Config dataclass) | +| `pwk_role_<id>` | function fixture (動的) | 該当 role で login 済の storage_state を context に注入 | +| `pwk_evidence` | function fixture | HAR / trace / console.error / pageerror / body_check の集中管理 | +| `pwk_accessibility_scan()` | helper | 任意のタイミングで axe-core を 1 回実行 | +| `pwk_web_vitals_measure()` | helper | 任意のタイミングで Web Vitals を 1 回計測 | +| `pwk_body_check_scan()` | helper | 任意のタイミングで現在の page 本文を 1 回 body_check | +| `@pytest.mark.page_role("form")` | marker | accessibility / web_vitals autouse の判定 (auto_roles 設定に従う) | +| `@pytest.mark.role("admin")` | marker | report.md 集計用 (login 自体は `pwk_role_<id>` fixture) | +| `@pytest.mark.phase(1)` | marker | report.md フェーズ集計 | +| `@pytest.mark.priority("high")` | marker | report.md ソート | +| `@pytest.mark.no_body_check` | marker | body_check autouse をこの test では skip | + +### body_check (PHP / SSR エラー検出, v0.4.0+) + +PHP / SSR が HTML 本文に直接出力した `Fatal error` / `Warning:` / `STRICT:` 等の +エラー文字列は console.error / pageerror では拾えない。`body_check` は +`page.on("response")` で全 HTML レスポンスを監視し、文字列パターンとの substring +一致で violation を記録する。**default で enabled + PHP 系パターン内蔵** なので、 +config を書かなくても PHP プロジェクトでまず動く。 + +パターンを上書きしたい場合のみ `scenario.config.yaml` で明示する: + +```yaml +# scenario.config.yaml (省略可) +body_check: + enabled: true + fatal_patterns: ["Fatal error", "Uncaught", "Parse error"] + warning_patterns: ["STRICT:", "Warning:", "Notice:", "Deprecated:"] + warning_head_chars: 300 # warning_patterns は本文先頭 N 文字のみ走査 (旧名 warning_head_bytes も alias) + not_found_patterns: ["File not found"] + fail_on_match: true # false で情報収集モード (PASS のまま report に記録) +``` + +- 違反は `case_dir/body_check.jsonl` に 1 violation = 1 行で出力 (`jq`/`grep` + で集計しやすいよう flat structure にしている) +- `report.md` のサマリ表に `body_check` カラムが、件数 > 0 の場合は詳細セクション + (URL / pattern / snippet) が出力される +- 機能ごと無効化したい場合は `body_check.enabled: false` を明示 +- 個別カテゴリのみ無効化したい場合は `fatal_patterns: []` のように明示空指定 +- 個別 test で skip したい場合は `@pytest.mark.no_body_check` を付与 +- 非 PHP プロジェクトでは default の `Notice:` / `Warning:` 等が誤検出になる + 場合あり。その場合は `warning_patterns: []` で warning カテゴリだけ無効化するか + `enabled: false` で機能ごと off にする + +## CLI options + +ランチャ経由 (`./run.sh` / `run.bat`) でも、`uv run pytest` を直接呼んでも、同じ option がそのまま効く。 + +| option | 役割 | +|---|---| +| `--pwk-config <path>` | `scenario.config.yaml` のパス。env `PWK_CONFIG` / CWD の同名ファイルでも可 | +| `--pwk-out-dir <path>` | 成果物出力先 (default: `reports/<run-id>/`) | +| `--pwk-no-evidence` | HAR / trace / video の収集を OFF | +| `--pwk-har-mode {minimal,full,none}` | HAR 録画モード (default: minimal、Issue #62) | +| `--pwk-overlay` | overlay (赤丸カーソル + 字幕、旧名 HUD) を全 page に inject | +| `--pwk-drive-folder <id>` | session 終了時に report.md と evidence を Drive アップロード | + +pytest 標準と組み合わせて使える: + +```bash +# page_role marker が付いたテストだけ実行 +./scenario-test/run.sh -m "page_role" + +# 4 worker で並列実行 +./scenario-test/run.sh -n 4 + +# 動画レポートを Drive へ自動アップロード +./scenario-test/run.sh --pwk-drive-folder=<FOLDER_ID> + +# pytest-html を組み合わせて HTML report も +./scenario-test/run.sh --html=reports/index.html --self-contained-html +``` + +## 標準ワークフロー (理論ベース計画) + +``` +[A] 対象 URL を渡される + │ +[B] page role を判定 scripts/classify_page_role.py --url <URL> + ▼ +[C] 該当 checklist を開く docs/checklists/checklist-{role}.md + │ 全項目を「適用」or「不適用 (理由付き)」で判定 + ▼ +[D] 必須技法を確定 docs/03-test-techniques.md § 11 + ▼ +[E] pytest テストを書く templates/test_<role>.py.template を起点に + │ `playwright codegen` で操作 → そのまま test 関数に貼る or 整形 + ▼ +[F] 実行 ./scenario-test/run.sh + │ trace.zip / video / HAR / console / accessibility / web_vitals を自動収集 + ▼ +[G] レポート確認 scenario-test/reports/<run-id>/report.md + │ --pwk-drive-folder 指定で Drive にアップロード + viewer URL 化 + ▼ +[H] 不具合発見 → bug report docs/05-bug-report.md + FEW HICCUPPS の oracle 軸を必ず付与 +``` + +## 単発 CLI ツール (補助) + +| Script | 用途 | +|---|---| +| `scripts/classify_page_role.py --url <URL>` | a11y tree + URL pattern + role 集計から page role 推定 | +| `scripts/record_scenario.py <URL>` | Playwright codegen を起動し操作を Python コードで取得 | +| `scripts/run_a11y_scan.py --url <URL>` | axe-core 単発スキャン | +| `scripts/check_cwv.py --url <URL>` | Web Vitals (LCP/CLS/TTFB) 単発計測 | +| `scripts/upload_evidence.py <file> --kind trace --public` | Drive アップロード + Playwright Trace Viewer URL 生成 | + +## docs/ 配下 (理論ベース知識) + +| ファイル | 内容 | +|---|---| +| `docs/01-methodology.md` | 総論: HTSM / FEW HICCUPPS / ISO 29119-3 の位置付け | +| `docs/02-page-roles.md` | page role 分類 (lp/list/item/edit/form/search/...) | +| `docs/03-test-techniques.md` | テスト技法 (EP / BVA / Decision Table / Pairwise) と role 必須マッピング | +| `docs/04-playwright-mapping.md` | Playwright API → role / 観点 マッピング | +| `docs/05-bug-report.md` | bug report 仕様 (ISO 29119-3 + FEW HICCUPPS) | +| `docs/06-pytest-playwright.md` | pytest-playwright fixture / CLI option / playwright_kit 拡張との対応関係 | +| `docs/checklists/checklist-<role>.md` | role 別チェックリスト (lp/list/item/edit/form/search/dashboard/auth/cart-checkout/modal-wizard/common) | + +## テスト雛形 (templates/) + +利用者は role に応じて `test_<role>.py.template` をコピーして編集する (init 時に +4 ファイルが配置済)。各テンプレートには: + +- 該当 `page_role` marker +- 該当 `pwk_role_<id>` fixture +- `expect()` ベースの web-first assertion +- 正常系 + 1 件以上の異常系 + +が含まれる。 + +## 開発者向け: Skill 単体で動かす旧運用 + +Skill 自身の改修・テスト時のみ、Skill ディレクトリで直接 pytest を回す: + +```bash +cd .claude/plugins/ndf/skills/playwright-scenario-test +uv sync +uv run pytest -q # 159 件 pure 関数テスト +``` + +利用者環境向け (`init_project.sh` 経由) と Skill 開発用は **別の uv プロジェクト** +として分離される (利用者側は `templates/pyproject.toml.runtime` を用い、開発側は +リポジトリルートの `pyproject.toml` を用いる)。 + +## 制約 / 注意 + +- **依存**: `pytest>=8.0`, `pytest-playwright>=0.5`, `pytest-xdist>=3.0`, `playwright>=1.50` +- **認証情報は YAML に直書きしない**: `scenario.config.yaml` の `fields.Password` 等は `${ENV_VAR}` で参照し、実値は環境変数 (`.env` / `direnv` / shell export) で管理してください。リポジトリに認証情報をコミットしないこと +- **トレース / HAR / 動画は機微情報を含む**: + - HAR には URL のクエリ文字列・Cookie・Authorization ヘッダ等が記録されます + - trace.zip には localStorage / 操作履歴 / DOM スナップショットが含まれます + - `--pwk-drive-folder=<id>` は **private folder** を指定し、共有相手を限定してください + - `upload_evidence.py --public` を付けない限り Drive にも非公開でアップ (既定) +- **CI**: GitHub Actions では `cd scenario-test && uv run pytest -n auto` でそのまま回せる +- **Skill 更新時の追従**: 開発中につきコピー済ランタイムは再 `init_project.sh` で + 上書き運用 (`scenario.config.yaml` / `tests/conftest.py` / `tests/test_*.py` は + 既存があれば skip するため利用者編集物は保護される) + +## 関連ドキュメント + +- `docs/README.md` — 知識マップ +- `templates/scenario.config.yaml` — 設定例 +- `templates/runtime-README.md` — init 後の `your-app/scenario-test/README.md` 元 diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/01-methodology.md b/plugins/ndf/skills/playwright-scenario-test/docs/01-methodology.md new file mode 100644 index 00000000..2879b9d4 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/01-methodology.md @@ -0,0 +1,148 @@ +# 01. テスト方法論総論 + +本 Skill は「Playwright + curl で実行する E2E テスト」を、経験則ではなく**業界標準の理論**に基づいて設計する。 +本書はその理論的な土台を、実用に落とし込む形で要約したもの。 + +## 1. 目的の二項定義 + +テストの目的は次の 2 つに分解できる (ISTQB / Beizer)。 + +1. **不具合の発見**: 仕様 / 期待挙動から外れる症状を能動的に探す +2. **修正担当者への情報提供**: 発見した症状を**再現可能で偏りのない記述**で伝える + +この 2 つは独立したスキルセットを必要とする。本 Skill は両方を「設計時にどの観点をテストするか」と「実行時に何を証拠として残すか」の 2 段で機械化する。 + +## 2. テスト戦略のフレームワーク (HTSM) + +James Bach の **Heuristic Test Strategy Model** (v6.3) は、テスト戦略を 4 つの構成要素に分解する。 + +``` + Quality Criteria (CRUSCSPCID: どの品質特性をテストするか) + │ + Mission ─── Strategy ─── Test Techniques (どう調査するか) + │ + Project Environment (リソース・制約・チーム・予算・スケジュール) + │ + Product Elements (SFDIPOT: 何をテストするか) +``` + +### 2.1 Product Elements (SFDIPOT) + +「製品の何を見るか」のチェックリスト。 + +| 因子 | 内容 | Web E2E での例 | +|---|---|---| +| **S**tructure | 構造的部品 | URL ツリー / route 設計 / DOM 構造 / コンポーネント階層 | +| **F**unction | 機能 | CRUD / 検索 / 認証 / 決済 / 通知 | +| **D**ata | データ | 入力 / 出力 / 永続化 / 流入元 / 文字種・境界 | +| **I**nterfaces | 接合面 | API / WebSocket / 3rd party / iframe / postMessage | +| **P**latform | プラットフォーム | OS / ブラウザ / device / viewport / 言語 | +| **O**perations | 運用 | 利用シナリオ / ペルソナ / ロール | +| **T**ime | 時間 | 日付 / TZ / 同時実行 / sequence / 期限 | + +### 2.2 Quality Criteria (CRUSCSPCID) + +「どんな品質か」のチェックリスト。本 Skill は **Capability** (機能満足) と **Reliability** (再現性) を主軸に、Web 文脈で重要な **Usability** (a11y 含)、**Security**、**Performance** を併走させる。 + +| 略 | 軸 | 主担当チェック | +|---|---|---| +| C | Capability | 機能要件を満たすか | +| R | Reliability | 同じ操作で同じ結果か。エラー時に回復するか | +| U | Usability | 操作可能性 (a11y / キーボード操作 / 国際化) | +| S | Scalability | 大量データ / 高負荷下の挙動 | +| C | Charisma | 感情的訴求 / ブランド整合 (人間判定主体) | +| S | Security | OWASP Top 10 / CSRF / IDOR / 認証 | +| P | Performance | LCP / INP / CLS / API 応答時間 | +| C | Compatibility | クロスブラウザ / OS / デバイス | +| I | Installability | (SaaS では設定/退会フローに相当) | +| D | Development | テスト容易性 / ログ充実 | + +## 3. テスト技法 (ISTQB CTFL 4.2) + +詳細は `03-test-techniques.md` 参照。本書では「どの page role でどの技法を必須にするか」だけ示す。 + +| page role | 必須技法 | 推奨追加 | +|---|---|---| +| LP | Claims Testing, Domain Testing (viewport) | accessibility / web_vitals | +| list | Equivalence Partitioning, BVA, Pairwise (フィルタ次元 ≥3) | State Transition | +| item | Domain Testing (id partition), Risk Testing (IDOR) | Claims | +| edit | BVA, Equivalence Partitioning, Decision Table | State Transition (dirty/saving/error) | +| form | **Decision Table 必須**, Classification Tree, State Transition | Pairwise | +| search | Domain Testing, Claims Testing | Pairwise (ファセット) | +| dashboard | Domain Testing (期間), Claims Testing | State Transition | +| auth | Decision Table (認証分岐), Risk Testing | | +| cart/checkout | Decision Table, BVA (金額境界), State Transition | | +| modal/wizard | State Transition (open/close/focus), ARIA APG conformance | | + +## 4. Oracle: FEW HICCUPPS (Bach / Bolton) + +「これは不具合か?」を 11 軸で判定する。**全 bug report に該当軸を必ず記録**することで、AI / 人間の判定揺らぎを抑える。 + +| 略 | 軸 | 例 | +|---|---|---| +| **F** | Familiarity (既知パターン) | 過去 bug DB に同型がある | +| **E** | Explainability (説明可能性) | 価格と総額の差を説明できない | +| **W** | World (世界の事実) | 月が 13 月、県が 47 以外 | +| **H** | History (過去版との一貫性) | 前 release ではできた操作 | +| **I** | Image (ブランド/外観) | デザインガイド逸脱 | +| **C** | Comparable products | 競合と比べ明らかに弱い | +| **C** | Claims (仕様/広告) | spec が「3秒以内」と主張 | +| **U** | User expectations | 一般的ユーザが「こう動く」と思う | +| **P** | Product (内部一貫性) | 詳細とリストで値が違う | +| **P** | Purpose (目的整合) | EC なのに購入できない | +| **S** | Statutes/Standards (法令/標準) | WCAG / GDPR / PCI 違反 | + +## 5. Hendrickson Test Heuristics Cheat Sheet + +20 個の guideword で「テストアイデアを生成する」発想支援。実行のたびに「Boundaries / Goldilocks / CRUD / Position / Selection / **Count (0/1/Many)** / Multi-user / Flood / Sequences / Sorting / **Interruptions** / Constraints / Input Method / Configurations / Starvation / Dependencies / Touch Points / Variable Analysis / **State** / Map Making」をチェック。 + +太字の **Count**, **Interruptions**, **State** は Web E2E で見落としやすく、checklists/ で繰り返し参照する。 + +## 6. テスト計画立案フロー (本 Skill の標準手順) + +``` +[1] 対象 URL のスクショまたは構造解析 + │ Playwright: page.accessibility.snapshot() or page.evaluate(getRoleSummary) + ▼ +[2] page role を判定 → docs/02-page-roles.md の識別ヒューリスティック + │ scripts/classify_page_role.py が補助 (DOM の role 集計) + ▼ +[3] 該当 checklist を開く → docs/checklists/checklist-{role}.md + │ 全項目を「適用」または「不適用 (理由付き)」と判定 + ▼ +[4] 各項目に適用するテスト技法を選ぶ → docs/03-test-techniques.md + │ 例: 編集フィールドなら BVA で min-1/min/min+1/max-1/max/max+1 + ▼ +[5] pytest テストを書く → templates/test_{role}.py.template + │ playwright codegen で操作録画 → そのまま test 関数に貼ってもよい + │ @pytest.mark.page_role(...) を付けると accessibility / web_vitals が autouse で走る + ▼ +[6] 実行 → uv run pytest --pwk-config=./scenario.config.yaml -n 4 + │ trace.zip / video / screenshot / HAR / console / accessibility / web_vitals を自動収集 + │ reports/<run-id>/report.md が pytest_terminal_summary で生成 + ▼ +[7] FAIL → docs/05-bug-report.md の構造で報告 (FEW HICCUPPS 軸付与) +``` + +## 7. 「経験」と「理論」の境界 + +本 Skill では次の方針で経験則を切り出している。 + +| 種類 | 配置 | 理由 | +|---|---|---| +| 業界標準 (ISO / WCAG / OWASP / ISTQB) | docs/ 配下 (このディレクトリ) | 出典がある。揺らぎが小さい | +| ヒューリスティクス (HTSM / FEW HICCUPPS / Hendrickson) | docs/ 配下 | 「思考の道具」として再利用 | +| 個別プロジェクトの慣習 (PHP / Rails / 等) | scenario.config.yaml の `tolerated_console_errors` / `tolerated_page_errors` 正規表現 | 個別カスタマイズ | +| 動画/HUD の細かい数値 (字幕高さ・カーソル色) | playwright_kit/overlay.py のコード内定数 | 表示 UX の調整。理論の対象外 | + +「経験」を docs に書くのではなく、**理論を docs に書き、慣習は config に逃がす** のが本 Skill の規律。 + +## 参考文献 + +- James Bach, "Heuristic Test Strategy Model" v6.3, https://www.developsense.com/resource/htsm.pdf +- Michael Bolton, "FEW HICCUPPS", https://developsense.com/blog/2012/07/few-hiccupps +- Elisabeth Hendrickson et al., "Test Heuristics Cheat Sheet", https://www.ministryoftesting.com/articles/test-heuristics-cheat-sheet +- ISTQB Foundation Level Syllabus 4.2 (Black-box Test Techniques), https://astqb.org/4-2-black-box-test-techniques/ +- ISO/IEC/IEEE 29119-3:2021 — Test documentation +- W3C, "WCAG 2.2", https://w3c.github.io/wcag/requirements/22/ +- OWASP, "Top 10:2025", https://owasp.org/Top10/2025/0x00_2025-Introduction/ diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/02-page-roles.md b/plugins/ndf/skills/playwright-scenario-test/docs/02-page-roles.md new file mode 100644 index 00000000..6f6a8039 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/02-page-roles.md @@ -0,0 +1,207 @@ +# 02. Page Role 分類 + +ページを役割で分類する。**URL パターンや SEO 構造ではなく、ユーザの目的とテスト観点で分類** する。 +1 ページが複数 role を兼ねる場合は両方の checklist を適用する (例: 一覧 + 検索)。 + +各 role には次の 4 セクションを定義する: +- **識別ヒューリスティック**: そのページが当該 role かを判定する特徴 +- **代表 URL パターン**: よく見るパス +- **必須 checklist**: `docs/checklists/checklist-{role}.md` への参照 +- **代表的 oracle**: FEW HICCUPPS のうち主に使う軸 + +## 全 role 一覧 + +| role | 短い説明 | 識別の主シグナル | +|---|---|---| +| `lp` | Landing Page (外部到達) | nav 中心 + CTA 多 + 機能 < 5 | +| `list` | 一覧 | 同一構造の繰り返し要素 + ページャ | +| `item` | 詳細 | URL に id / 単一エンティティ | +| `edit` | 編集 | プリフィル + save/cancel | +| `form` | 申込フォーム (複数ステップ) | progress indicator + 確認画面 | +| `search` | 検索 | search box + 結果 list + ファセット | +| `dashboard` | ダッシュボード | KPI カード + 複数チャート + 期間フィルタ | +| `auth` | 認証 | login form / logout / 2FA | +| `cart` | カート | 商品行 + 数量変更 + 合計 | +| `checkout` | チェックアウト | 配送/支払い段階 + 確認 | +| `modal` | モーダル | `role="dialog"` + 背景 overlay | +| `wizard` | ウィザード | step インジ + 戻る/次へ | +| `error` | エラーページ | 4xx/5xx + 復帰導線 | +| `settings` | 設定 / プロフィール | 個人設定の保存フォーム群 | + +## 各 role 詳細 + +### `lp` — Landing Page (外部到達ページ) +**識別**: ドメインルート (`/`) または `/lp/*` `/campaign/*`。 +ナビ + ヒーロー + 複数の説明セクション + CTA。SEO meta tag が充実。 +ページ深度 1。機能リンク (form 等) は 1〜3 個に集中。 + +**代表 URL**: `/`, `/lp/2026-spring`, `/about`, `/pricing`, `/features`. + +**必須 checklist**: [`checklists/checklist-lp.md`](checklists/checklist-lp.md) + +**代表的 oracle**: Claims (主張), Image (ブランド), Statutes (a11y / GDPR バナー). + +--- + +### `list` — 一覧ページ +**識別**: 同一構造の要素 (table / card / feed) が繰り返し描画される。 +ページャ or 無限スクロール。ソート/フィルタ UI。各行に詳細リンク。 + +**代表 URL**: `/items`, `/users`, `/orders`, `/posts`, `/products`. + +**必須 checklist**: [`checklists/checklist-list.md`](checklists/checklist-list.md) + +**代表的 oracle**: Product (内部一貫性 — 件数と表示の一致), Statutes (テーブル a11y), Claims (フィルタ仕様). + +--- + +### `item` — 詳細ページ +**識別**: URL に `/{resource}/{id}`。breadcrumbs。編集 / 削除 / 戻るリンク。 + +**代表 URL**: `/items/123`, `/users/u_abc`, `/orders/o_xyz`. + +**必須 checklist**: [`checklists/checklist-item.md`](checklists/checklist-item.md) + +**代表的 oracle**: Product (一覧との値一致), Statutes (IDOR / 認可), History (過去版で動いた操作). + +--- + +### `edit` — 編集ページ +**識別**: 既存値プリフィル + Save / Cancel ボタン。dirty 検知 (`beforeunload`)。CSRF token。 + +**代表 URL**: `/items/123/edit`, `/items?Cmd=Edit&ItemID=123`. + +**必須 checklist**: [`checklists/checklist-edit.md`](checklists/checklist-edit.md) + +**代表的 oracle**: Claims (validation 仕様), Product (保存後の値整合), Statutes (CSRF / a11y errors). + +--- + +### `form` — 申込フォーム (複数ステップ) +**識別**: 進捗インジ (Step 1/N) + 戻る/次へ + 確認画面 + 送信 + 完了画面。 +入力分岐がある (国別 / 法人個人 / オプション)。**コードを読みながら Decision Table を作る対象**。 + +**代表 URL**: `/contact`, `/signup`, `/apply`, `/subscribe`. + +**必須 checklist**: [`checklists/checklist-form.md`](checklists/checklist-form.md) + +**代表的 oracle**: Claims (分岐ロジック仕様), Product (確認画面と送信値の一致), User (ステップ間ナビゲーション期待). + +--- + +### `search` — 検索ページ +**識別**: search box + 結果 list + ファセット + 件数表示 + ハイライト + サジェスト. + +**代表 URL**: `/search`, `/?q=...`, `/find`. + +**必須 checklist**: [`checklists/checklist-search.md`](checklists/checklist-search.md) + +**代表的 oracle**: Claims (relevance 順位), Product (件数と表示の整合), Statutes (XSS / SQL inj sanitize). + +--- + +### `dashboard` — ダッシュボード +**識別**: KPI カード + 複数チャート + 期間/dimension フィルタ + drill-down. + +**代表 URL**: `/dashboard`, `/analytics`, `/reports`, `/admin`. + +**必須 checklist**: [`checklists/checklist-dashboard.md`](checklists/checklist-dashboard.md) + +**代表的 oracle**: Product (合計と内訳の一致), Claims (リアルタイム表記), Statutes (色覚多様性 / a11y). + +--- + +### `auth` — 認証 +**識別**: email/username + password。SSO ボタン / Remember me / Forgot password / 2FA. + +**代表 URL**: `/login`, `/signin`, `/register`, `/forgot-password`, `/auth/callback`. + +**必須 checklist**: [`checklists/checklist-auth.md`](checklists/checklist-auth.md) + +**代表的 oracle**: Statutes (OWASP ASVS / NIST 800-63B), Claims (パスワードポリシー), History (セッション再発行). + +--- + +### `cart` / `checkout` — カート / 決済 +**識別**: 商品行 + 数量 + 合計 + 配送 + 支払い + 確認 + 完了. + +**代表 URL**: `/cart`, `/checkout`, `/checkout/payment`, `/order/confirm`. + +**必須 checklist**: [`checklists/checklist-cart-checkout.md`](checklists/checklist-cart-checkout.md) + +**代表的 oracle**: Claims (税/送料計算), Product (価格再計算と表示の整合), Statutes (PCI DSS), History (在庫変動). + +--- + +### `modal` / `wizard` — モーダル / ウィザード +**識別**: `role="dialog"` + `aria-modal="true"` + overlay + close. wizard は内部に step. + +**代表**: 削除確認 dialog, onboarding wizard, 設定 modal. + +**必須 checklist**: [`checklists/checklist-modal-wizard.md`](checklists/checklist-modal-wizard.md) + +**代表的 oracle**: Statutes (W3C ARIA APG dialog pattern), User (Esc キーで閉じる期待), Product (wizard 状態保持). + +--- + +### `error` — エラーページ +**識別**: HTTP 4xx/5xx 応答。"Page not found" / "Server error" / "Maintenance". + +**代表 URL**: 404 / 500 / 503 / 429 / 401 / 403. + +**必須 checklist**: 一覧未整備 — checklists/checklist-common.md の「エラーハンドリング」節参照 + +**代表的 oracle**: Claims (status code 仕様), Product (実 status と画面の一致), Statutes (PII 露出禁止). + +--- + +### `settings` — 設定 / プロフィール +**識別**: 個人 / 組織設定の保存フォーム群。アバター。退会導線。 + +**代表 URL**: `/settings/profile`, `/settings/notifications`, `/account`. + +**必須 checklist**: 「edit」を流用 + checklists/checklist-common.md のセキュリティ節 (再認証要求). + +**代表的 oracle**: Claims (即時反映表記), Statutes (OWASP ASVS V8 Data Protection), History (退会後のデータ扱い). + +## 識別の自動化 + +`scripts/classify_page_role.py` は次のヒューリスティックで role を推定する: + +``` +入力: target_url, [既ログイン storage_state] +処理: + 1. page.goto(url) + 2. accessibility tree を抽出 (page.accessibility.snapshot()) + 3. role 集計: + - article >= 2 + listitem >= 2 → list 候補 + - role=heading lvl=1 + role=link >= 5 + role=button (CTA) → lp 候補 + - role=textbox + role=button name="Sign in"|"Login" → auth 候補 + - role=dialog → modal + - role=textbox >= 3 + 進捗 (`step` text or aria-label) → form + - role=table or role=grid → list (table 系) + - getByLabel("Email") + getByLabel("Password") → auth + 4. URL pattern (id 含むなど) で補強 +出力: 推定 role + 信頼度 + 代替候補 +``` + +これにより、AI が「経験で role を決める」のではなく、a11y tree という客観的事実から決まる。 + +## 兼ね role の扱い + +1 ページが複数 role を兼ねる場合 (例: 検索結果 = list + search): + +```python +@pytest.mark.page_role("search", "list") +def test_search_result(page, pwk_role_user): + ... +``` + +このとき各 role の checklist 全項目を走査する。重複項目は片方で OK。 + +## 参考文献 + +- James Bach, "Heuristic Test Strategy Model" v6.3 (Operations / Users) +- Nielsen Norman Group, "Page Types and Templates" +- W3C, "ARIA in HTML", https://www.w3.org/TR/html-aria/ +- W3C WAI-ARIA APG, https://www.w3.org/WAI/ARIA/apg/patterns/ diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/03-test-techniques.md b/plugins/ndf/skills/playwright-scenario-test/docs/03-test-techniques.md new file mode 100644 index 00000000..4b8cd21b --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/03-test-techniques.md @@ -0,0 +1,284 @@ +# 03. テスト技法ライブラリ + +ISTQB CTFL 4.2 のブラックボックス技法を中心に、Web E2E で実用される技法を整理する。 +各技法に **(1) 定義 (2) 適用例 (3) 適用すべき page role (4) 限界** を記す。 + +テストケース YAML には **使用した技法名を必ず記録** すること: + +```yaml +- name: パスワード境界テスト + technique: BVA + oracle: Claims # FEW HICCUPPS のどの軸 + inputs: [7, 8, 64, 65] + expect_behavior: ... +``` + +## 1. Equivalence Partitioning (EP) — 同値分割 + +### 定義 (ISTQB CTFL 4.2.1) +入力ドメインを「同じ処理が期待される」分割に分け、各分割から代表値 1 つでテストする。 +不具合は「分割を取り違えた処理」に起因することが多いという前提。 + +### 適用例 +- 年齢: `<0` / `0–17` / `18–64` / `65–120` / `>120` → 5 分割 → 各 1 テスト +- 入力欄の文字種: `空` / `半角英数` / `全角` / `絵文字` / `制御文字` / `BiDi` + +### 適用 role +全 role。特に `edit`, `form`, `search`. + +### 限界 +- 分割の境界は別途 BVA で補完が必要 +- 順序の無いカテゴリ (色 / 言語) は EP のみ + +--- + +## 2. Boundary Value Analysis (BVA) — 境界値分析 + +### 定義 (ISTQB CTFL 4.2.1) +順序のある分割の境界値そのもの、境界 ± 1 をテストする。 +「不具合は境界に集まる」という経験的事実を踏まえる。 + +### 適用例 +- パスワード長 (仕様 8〜64): `7 / 8 / 9 / 63 / 64 / 65` +- ページ番号 (1 始まり): `0 / 1 / 2 / last-1 / last / last+1 / -1 / 'abc'` +- 金額 (0 円許容?): `-1 / 0 / 1 / max-1 / max / max+1` + +### 適用 role +`edit`, `form`, `list` (ページネーション), `cart` (金額境界), `search` (件数 0/1/many). + +### 限界 +- 順序の無いカテゴリには無効 +- 境界仕様が曖昧な場合は **どこを境界としたか** を bug report に明記 + +--- + +## 3. Decision Table — 判定表 + +### 定義 (ISTQB CTFL 4.2.2) +入力条件と期待結果の組合せを表で網羅する。 + +``` +| 国 | 会員ランク | クーポン | 期待送料 | +|----|----------|---------|---------| +| JP | Free | なし | 500 円 | +| JP | Free | あり | 0 円 | +| JP | Pro | なし | 0 円 | +| US | Free | なし | 1500 円 | +| US | Pro | なし | 0 円 | +``` + +ルール ≤ 6 個の条件で網羅。> 6 個なら Classification Tree + Pairwise で削減。 + +### 適用例 +- 認証: (auth_present, csrf_token, captcha) × 期待 status code +- 配送料計算: (国, 会員, クーポン, 重量) × 配送料 + +### 適用 role +**`form`, `auth`, `cart`/`checkout` で必須**。 +Edit / Search でも入力分岐があれば適用。 + +### 限界 +- 条件 6 個で `2^6 = 64` 行になり管理不能 → 上位概念で集約 (Classification Tree) +- 条件の独立性が前提 (相互作用は別テスト) + +--- + +## 4. State Transition Testing — 状態遷移 + +### 定義 (ISTQB CTFL 4.2.2) +状態と遷移を表/図で定義し、有効遷移と無効遷移を網羅。 +0-switch (1 遷移) / 1-switch (2 連続遷移) でカバレッジを階層化。 + +### 適用例 +注文の状態遷移: + +``` +[draft] ─submit→ [submitted] ─pay→ [paid] ─ship→ [shipped] ─deliver→ [delivered] + │ │ + └──cancel────────────────────[cancelled] +``` + +無効遷移: `delivered → cancel`, `submitted → ship`, etc. + +### 適用 role +`cart`/`checkout` (cart→checkout→paid→fulfilled), `auth` (logged_out→logging_in→logged_in→locked), `form` (step1→step2→...→complete), `edit` (clean→dirty→saving→saved/error). + +### 限界 +- 状態爆発時 (>20 状態) に階層化が必要 +- 並行状態 (multi-tab) は別モデル + +--- + +## 5. Use Case Testing — ユースケース + +### 定義 (ISTQB CTFL 4.2.2) +アクター × ゴールから主流れと例外流れを抽出してシナリオ化。 + +### 適用例 +「ユーザが商品を返品する」: +- 主流れ: 注文一覧 → 該当注文 → 返品申請 → 理由選択 → 返品ラベル DL → 完了 +- 例外: 期限超過 / 配送中 / 既返品済み / 一部返品 + +### 適用 role +全 role の **シナリオ束ね** に有効。本 Skill では「1 testcase = 1 use case scenario」。 + +### 限界 +- UI 詳細はカバーしない (BVA / EP で補強) + +--- + +## 6. Pairwise / All-Pairs Testing + +### 定義 (ISTQB CTFL 4.2.3) +多次元組合せを「全 2 因子組合せ」に絞る。 +**不具合の 70%以上が 2 因子相互作用**という経験則 (Kuhn et al. 2004) に基づく。 + +### 適用例 +OS (3) × Browser (4) × 言語 (5) × 端末タイプ (3) = 全 180 通り → All-Pairs で ~20 通り. +ツール: PICT (Microsoft), allpairs.py (Python). + +### 適用 role +`form` (国 × 配送 × 支払い × 法人/個人), `dashboard` (期間 × dimension × フィルタ), `search` (ファセット組合せ), `list` (ソート × フィルタ × ページ). + +### 限界 +- 3 因子以上の交互作用は見逃す +- ツール依存 (手作業では困難) + +--- + +## 7. Classification Tree Method (CTM) + +### 定義 (Grimm/Grochtmann 1993) +入力因子と値クラスを階層的なツリーに整理し、葉ノードを Pairwise で組合せ生成。 + +### 適用例 +``` +申込フォーム +├── 顧客種別: [個人, 法人] +├── 国: [JP, US, EU, 他] +├── 支払い: [カード, 銀振, PayPal] +└── 配送: [標準, 速達, 店舗受取] +``` +→ 葉 4 因子の All-Pairs で ~20 ケース生成。 + +### 適用 role +`form` (複数ステップ), `cart`/`checkout` (オプション組合せ). + +--- + +## 8. Domain Testing (HTSM) + +### 定義 (Kaner / Bach) +入力/出力データを系統的に分割し、典型値・境界値・無効値を選ぶ。 +EP/BVA を Web の文字列・日付・URL・ファイル等に拡張した実用版。 + +### 標準分割セット +| データ型 | 分割 | +|---|---| +| 整数 | min-1, min, min+1, 0, 1, -1, mid, max-1, max, max+1, NaN, ∞ | +| 文字列 | 空, 1 文字, 短, 平均, 長, max, max+1, 半角, 全角, 絵文字, BiDi, 制御文字, ヌル文字 | +| 日付 | 過去 / 現在 / 未来 / 閏年 2/29 / DST 切替 / 年末年始 / TZ 境界 / RFC 3339 違反 | +| URL | http/https/file/ftp / 異なるドメイン / クエリ / フラグメント / IDN / IPv6 / open redirect | +| ファイル | 0 byte / 拡張子のみ / 拡張子偽装 / 大ファイル / Unicode 名 / 同名重複 | + +### 適用 role +`edit`, `form`, `search`, `list`, `cart` の数値/金額. + +--- + +## 9. その他 HTSM / 経験的技法 + +### Stress Testing +過負荷・低リソース・大量データで応答を観察。 +Playwright での近似: `route` で全リクエストに 300ms delay を注入、`context.set_offline(True)` で回線切断、データ生成スクリプトで 10000 件投入。 + +### Flow Testing +リセットせず連続操作。副作用を引き出す。 +例: カートに追加 50 連発 → 二重登録 / 在庫不整合. + +### Scenario Testing (HTSM) +「重要人物が重要な事をする物語」を実行。 +本 Skill の pytest テスト関数 1 件 = 1 シナリオ. + +### Claims Testing (HTSM) +仕様 / 広告 / SLA の主張を逐一検証。 +例:「3秒以内に表示」「99.9% uptime」「Drag & Drop に対応」. + +### Risk Testing (HTSM) +想定欠陥を仮説立てし、それを暴く試験を設計。 +例: 「他人の注文を IDOR で見れるはずだ」→ 別ユーザの id で URL を踏む. + +### User Testing (HTSM) +ペルソナごとの利用シナリオを実行。スクリーンリーダー利用者 / 高齢者 / 非ネイティブ言語話者. + +### Automatic Checking +機械的に oracle で照合できる部分を網羅 (axe-core / visual diff / API レスポンス検査). + +--- + +## 10. Hendrickson Test Heuristics (20 guideword) + +| guideword | 説明 | +|---|---| +| Boundaries | 境界値 (BVA) | +| Goldilocks | 短すぎ・長すぎ・適切 | +| CRUD | Create / Read / Update / Delete を全網羅 | +| Position | 並び順の最初/最後/中間 | +| Selection | 選択 0/1/全/不正 | +| **Count** | 0 件 / 1 件 / 多数 | +| Multi-user | 同時編集 / 競合 | +| Flood | 連打 / 大量データ | +| Sequences | 順序を入替えて副作用を引き出す | +| Sorting | 昇順 / 降順 / null 含む / i18n 並び | +| **Interruptions** | ネットワーク切断 / タブ閉じる / セッション切れ | +| Constraints | 必須 / 一意 / 関係 / FK | +| Input Method | キーボード / マウス / タッチ / 音声 / コピペ | +| Configurations | OS / ブラウザ / viewport | +| Starvation | 低メモリ / 低帯域 / バッテリー低下 | +| Dependencies | 上位/下位データ / 第三者 API | +| Touch Points | 通知 / メール / SMS への副作用 | +| Variable Analysis | 変数の生死範囲 / scope | +| **State** | 状態遷移とその境界 | +| Map Making | テスト対象の地図を描く (探索) | + +太字は Web E2E で見落としやすい必須項目。 + +## 11. 「揺らぎ排除」のための技法選択ルール + +AI / 人間が「思いつき」でテストを書かないように、**page role × データ型 → 必須技法** を以下に固定する。 + +``` +入力: page_role, data_type +出力: 必須技法のリスト + +(role, data) → techniques +───────────────────────────────────────── +(*, 数値) → BVA + EP +(*, 文字列) → BVA (長さ) + Domain (文字種) +(*, 日付) → Domain (日付セット) +(*, URL/path) → Domain (URL 種別) + Risk (open redirect) +(*, ファイル) → Domain (ファイル種別) +(form, *) → Decision Table 必須 +(checkout, 金額) → BVA + Decision Table (税/送料/クーポン) +(list, *) → EP (Count: 0/1/Many) +(search, クエリ) → Domain (clean/inj) + Claims (relevance) +(auth, 認証情報) → Decision Table + Risk (列挙/ロック) +(*, 状態を持つ操作) → State Transition +(*, 多次元組合せ) → Pairwise (All-Pairs) +(*, 仕様で主張あり) → Claims Testing +(*, IDOR/CSRF/XSS リスク) → Risk Testing +``` + +このマッピングは利用者が pytest テストを書く際の指針として使う。 +``@pytest.mark.parametrize`` で各境界値 / 各 row を test 関数として展開し、 +4 軸以上は事前に Pairwise で削減してから ``parametrize`` する運用を推奨する。 + +## 参考文献 + +- ISTQB CTFL Syllabus 4.2, "Black-box Test Techniques" +- Glenford Myers, "The Art of Software Testing" 3rd ed. +- Cem Kaner, "Domain Testing Workbook" +- Grimm/Grochtmann (1993), "Classification Trees for Partition Testing" +- Kuhn et al. (2004), "Software Fault Interactions and Implications for Software Testing", IEEE TSE +- Hendrickson/Lyndsay/Emery, "Test Heuristics Cheat Sheet", https://www.ministryoftesting.com/articles/test-heuristics-cheat-sheet +- James Bach, HTSM v6.3 diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/04-playwright-mapping.md b/plugins/ndf/skills/playwright-scenario-test/docs/04-playwright-mapping.md new file mode 100644 index 00000000..02b2a2bc --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/04-playwright-mapping.md @@ -0,0 +1,249 @@ +# 04. Playwright API → page role / 観点 マッピング + +「経験で API を選ぶ」のではなく、**page role × 観点 → 使う Playwright API** を一意に固定するための表。 +本 Skill のロケーター戦略・assertion 戦略・debug ツール選択は本書に従う。 + +## 1. ロケーター優先順位 (公式推奨) + +ユーザの操作意図を反映する a11y セマンティクスを最優先。 +**この順序を守ることがスキル全体の根幹。** CSS/XPath は禁忌。 + +| 優先度 | API (Python) | 用途 | コメント | +|---|---|---|---| +| 1 | `page.get_by_role(role, name=)` | 主要操作要素 (button/link/heading/textbox/listitem/dialog/tab/row/cell ...) | a11y role + accessible name | +| 2 | `page.get_by_label(text)` | フォーム要素 (`<label for>` / `aria-labelledby`) | フォーム第一選択 | +| 3 | `page.get_by_placeholder(text)` | label のない検索 box 等 | label 推奨だが placeholder のみのケース | +| 4 | `page.get_by_text(text)` | 表示文字列 (主にアサーション) | 操作には不安定な場合あり | +| 5 | `page.get_by_alt_text(text)` | 画像 | | +| 5 | `page.get_by_title(text)` | `title` 属性 | | +| 6 | `page.get_by_test_id(id)` | `data-testid` | 本番 DOM 変動を吸収する最終手段 | +| 7 | `page.locator(css_or_xpath)` | 上記で取れない場合のみ | **使用時はコメントで理由を明記** | + +絞り込み・連結: + +```python +# filter +locator.filter(has_text="...", has_not_text="...", has=..., has_not=...) +# 位置 +locator.first / locator.last / locator.nth(i) +# AND / OR +locator.and_(...) # 両方マッチ +locator.or_(...) # どちらかマッチ +# チェイン +page.get_by_role("listitem").filter(has_text="Product 2") \ + .get_by_role("button", name="Add to cart").click() +# iframe +page.frame_locator("#my-iframe").get_by_role("button", name="Sign in").click() +# trace 用ラベル (1.50+) +locator.describe("Subscribe button") +``` + +## 2. Page role × ロケーター マトリクス + +| page role | 主要要素 | 第一優先 | 第二優先 | フォールバック | +|---|---|---|---|---| +| `lp` | hero / nav / CTA | `get_by_role("link"/"button", name=...)` | `get_by_text` | testid | +| `list` | table / card / row | `get_by_role("row"/"listitem").filter(has_text=...)` | `get_by_role("rowheader"/"cell")` | nth | +| `item` | 見出し / アクションボタン | `get_by_role("heading", level=1)` + `get_by_role("button", name=...)` | label | testid | +| `edit` | 個別入力 + 保存ボタン | `get_by_label(...)` → `get_by_role("button", name="保存")` | placeholder | testid | +| `form` | 多項目入力 + step | `get_by_label` 一択 + `get_by_role("textbox", name=...)` | placeholder | testid | +| `search` | 検索 box / 結果 | `get_by_role("searchbox")` または `get_by_placeholder` | label | css | +| `dashboard` | region scope + widget | `get_by_role("region", name=...)` でスコープ → 内部 role | testid | text | +| `auth` | login / sign-in | `get_by_label("Email" / "Password")` + `get_by_role("button", name="Sign in")` | placeholder | testid | +| `cart` | 行 + 数量 + 削除 | `get_by_role("listitem").filter(has_text=商品名).get_by_role("button", name="削除")` | role=row | testid | +| `checkout` | 段階 region | `get_by_role("region", name="配送先")` でスコープ → label | placeholder | testid | +| `wizard` | step + Next | `get_by_role("button", name="次へ"/"Next")` + `get_by_role("status")` | text | testid | +| `modal` | dialog scope | `get_by_role("dialog", name=...)` → 内部 role | text | testid | + +## 3. Assertion 戦略 (web-first assertion) + +`time.sleep` / `wait_for_selector` / `locator.is_visible()` は禁止。 +すべて `expect(...)` で auto-retry させる。 + +| 検査内容 | API | 例 | +|---|---|---| +| 要素表示 | `expect(locator).to_be_visible()` | save ボタン表示 | +| 非表示 | `expect(locator).to_be_hidden()` | エラー表示が消える | +| テキスト | `expect(locator).to_have_text(...)` | エラーメッセージ完全一致 | +| 部分一致 | `expect(locator).to_contain_text(...)` | 件数表示 | +| 個数 | `expect(locator).to_have_count(N)` | 一覧件数 | +| 値 | `expect(locator).to_have_value(...)` | input の値 | +| URL | `expect(page).to_have_url(...)` | 遷移先 | +| タイトル | `expect(page).to_have_title(...)` | SEO | +| 属性 | `expect(locator).to_have_attribute(...)` | aria-disabled | +| クラス | `expect(locator).to_have_class(...)` | active 状態 | +| ARIA snapshot | `expect(locator).to_match_aria_snapshot(...)` | 構造のリグレッション | +| Screenshot | `expect(page).to_have_screenshot(...)` | visual regression | + +`timeout`, `useInnerText` 等のオプションは config 1 箇所で管理。 + +## 4. Network / API 操作 + +「画面で値を確認する」を「API レスポンスを直接アサート」に置き換え、UI flakiness を排除する。 + +| 操作 | API | 適用例 | +|---|---|---| +| リクエスト傍受 / モック | `page.route(url, handler)` | 条件で `route.fulfill / abort / continue` | +| HAR 録画 | `context.route_from_har(path, update=True, update_mode="minimal")` | 録画時に固定 fixture 化 | +| HAR 再生 | `context.route_from_har(path, update=False, not_found="abort")` | 安定化 | +| レスポンス待機 | `with page.expect_response(predicate) as info: ...` | API 完了 → 画面アサート | +| API テスト | `playwright.request.new_context(base_url=..., extra_http_headers=...)` | UI を介さず login / seed | +| 画像/3rd party 遮断 | `page.route("**/*.{png,jpg}", lambda r: r.abort())` | 速度向上 | + +### page role × Network 戦略 + +| role | 推奨 | 理由 | +|---|---|---| +| `lp` | 画像 / tracker abort | 速度向上 / 外部依存除去 | +| `list` / `dashboard` | `expect_response` で API 完了待機 | DOM 待機より早く確実 | +| `auth` | `request.new_context().post("/login")` で `storage_state` 化 | 全 testcase で再利用 | +| `search` | `expect_response` でクエリ検証 | "0 件" もアサート可 | +| `edit` / `form` | submit を `route.fulfill` で固定 422 化 | エラーパスを安定化 | + +## 5. デバッグ / 不具合調査支援 + +### codegen — 録画→コード生成 + +```bash +# 基本 +playwright codegen https://example.com + +# Python sync 出力 +playwright codegen --target python ... + +# device emulation +playwright codegen --device "iPhone 13" example.com + +# 認証 storage 保存 +playwright codegen --save-storage=auth.json example.com + +# 認証済み状態から再開 +playwright codegen --load-storage=auth.json example.com +``` + +本 Skill では `scripts/record_scenario.py` がラッパーを提供し、codegen の Python 出力をそのまま pytest テスト関数として利用する。 +**「経験で書く」を「録画→生成→pytest テスト」に置換** する。 + +### Trace Viewer + +```bash +# 失敗時のみ trace 残す (推奨) +pytest --tracing retain-on-failure + +# trace を開く +playwright show-trace test-results/<test>/trace.zip + +# リモート URL +https://trace.playwright.dev/?trace=<URL> +``` + +bug report に **trace.zip の playwright.dev リンク** を必ず付与する (`scripts/upload_evidence.py --kind trace --public`)。 + +### page.pause() + +開発時のみ。CI には流さない。`headless=False` 必須。 + +### screenshot mask + +```python +page.screenshot( + path="...", + full_page=True, + mask=[page.locator(".clock"), page.get_by_test_id("avatar")], + mask_color="#FF00FF", + animations="disabled", + caret="hide", +) +``` + +dashboard / list の動的領域 (時計 / 広告) を除外して visual regression を安定化. + +## 6. アクセシビリティ / 性能 / 互換 + +### a11y — `axe-playwright-python` + +```python +from axe_playwright_python.sync_playwright import Axe + +def test_a11y(page): + page.goto("/dashboard") + axe = Axe() + results = axe.run(page) + assert results.violations_count == 0, results.generate_report() +``` + +公式 Locator の `to_match_aria_snapshot('''- heading "todos" - textbox ...''')` で a11y 構造のリグレッション検出も可能。pytest テストでは ``expect(page.locator('main')).to_match_aria_snapshot(...)`` のように直接使う。 + +### Core Web Vitals + +```python +def measure_lcp(page): + return page.evaluate("""() => new Promise(r => { + new PerformanceObserver(list => { + const e = list.getEntries().at(-1); + r(e.startTime); + }).observe({type:'largest-contentful-paint', buffered:true}); + })""") +``` + +`scripts/check_cwv.py` が LCP/INP/CLS を一括計測. + +### モバイル / メディア + +```python +iphone = playwright.devices["iPhone 13"] +context = browser.new_context(**iphone) # viewport+UA+touch+DPR +page.emulate_media(color_scheme="dark", reduced_motion="reduce") +context.set_offline(True) +context.set_geolocation({"latitude": 35.68, "longitude": 139.69}) +``` + +## 7. その他重要 API + +| 機能 | API | 用途 | +|---|---|---| +| URL 待機 | `page.wait_for_url("/dashboard*", wait_until="networkidle")` | `expect_navigation` の代替 | +| Popup | `with page.expect_popup() as info: ...; popup = info.value` | OAuth / 印刷 | +| iframe | `page.frame_locator("#frame").get_by_*` | 決済 widget / YouTube | +| dialog | `page.on("dialog", lambda d: d.dismiss())` | confirm/alert 自動応答 | +| download | `with page.expect_download() as d: ...; d.value.save_as(path)` | CSV / PDF DL 検証 | +| console | `page.on("console", lambda msg: ...)` | JS エラー検出 | +| pageerror | `page.on("pageerror", lambda exc: ...)` | uncaught exception 自動 FAIL | +| storage_state | `context.storage_state(path)` / `new_context(storage_state=path)` | login 1 回, 全 test 再利用 | + +### 必須リスナー (本 Skill 標準) + +``pwk_evidence`` fixture が次のリスナーを自動付与する (autouse 相当): + +```python +console_msgs = [] +errors = [] +page.on("console", lambda msg: console_msgs.append(msg)) +page.on("pageerror", lambda exc: errors.append(str(exc))) +``` + +`pageerror` が 1 件でも検出されたら **そのテストは無条件 FAIL**。bug report に exception を添付. + +## 8. Skill 設計判断: pytest-playwright vs `@playwright/test` + +| 機能 | Python (`pytest-playwright`) | Node (`@playwright/test`) | 本 Skill 採用 | +|---|---|---|---| +| fixture | pytest fixture | `test.use({...})` | Python (既存資産) | +| web-first assertion | `expect(locator).to_be_visible()` | 同等 | 共通 | +| retry | `pytest --reruns N` | `retries: 2` | Python | +| 並列 | `pytest -n auto` | builtin worker | Python | +| trace | `--tracing retain-on-failure` | 同等 | Python | +| merge-reports | `pytest_terminal_summary` で集計 | builtin | Python (`playwright_kit/pytest_report.py`) | + +**結論**: Python `pytest-playwright` を採用。理由は (1) overlay (旧名 HUD) / 字幕焼き込み / accessibility / web_vitals / Drive 連携を pytest plugin として一体化済み、(2) `def test_xxx(page, pwk_role_admin): ...` を直接書く設計のため pytest fixture / marker と相性が良い、(3) merge-reports は `pytest_terminal_summary` hook で同等機能を提供. + +## 参考文献 + +- Playwright Python Docs, https://playwright.dev/python/docs/intro +- Playwright Locators, https://playwright.dev/python/docs/locators +- Playwright Mock APIs, https://playwright.dev/python/docs/mock +- Playwright Trace Viewer, https://playwright.dev/docs/trace-viewer +- Playwright Codegen, https://playwright.dev/docs/codegen +- Playwright API Testing, https://playwright.dev/python/docs/api-testing +- axe-playwright-python, https://github.com/pamelafox/axe-playwright-python +- web-vitals.js, https://github.com/GoogleChrome/web-vitals diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/05-bug-report.md b/plugins/ndf/skills/playwright-scenario-test/docs/05-bug-report.md new file mode 100644 index 00000000..8567ea48 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/05-bug-report.md @@ -0,0 +1,207 @@ +# 05. Bug Report 仕様 + +不具合報告は ISO/IEC/IEEE 29119-3:2021 (Incident Report) に整合し、 +**修正担当者が再現と原因特定に着手できる最小情報** を機械的に揃える。 + +## 1. 必須フィールド + +| フィールド | 型 | 必須 | 説明 | +|---|---|---|---| +| `id` | string | ✅ | 一意識別子 (例: `BUG-2026-04-25-001`) | +| `title` | string (≤80) | ✅ | 「どこで」「何が」「どうなる」を 1 行 | +| `detected_at` | ISO 8601 + TZ | ✅ | `2026-04-25T14:32:01+09:00` | +| `originator` | string | ✅ | 報告者 / 自動ジョブ名 | +| `environment` | object | ✅ | `url, build_sha, browser, browser_version, os, viewport, language, role` | +| `preconditions` | string[] | ✅ | ログイン状態 / 種データ / feature flag | +| `steps` | string[] | ✅ | 番号付き、1 ステップ 1 動作、具体値 | +| `expected` | string | ✅ | 1 文 + 出典 (要件 ID / 仕様 / oracle) | +| `actual` | string | ✅ | 観測値のみ (推測禁止) | +| `reproducibility` | enum | ✅ | `always` / `intermittent (N/M)` / `once` | +| `severity` | enum | ✅ | `S1` / `S2` / `S3` / `S4` (技術影響) | +| `priority` | enum | ✅ | `P1` / `P2` / `P3` / `P4` (ビジネス緊急度) | +| `impact` | string | ✅ | 影響範囲 (% ユーザ / データ汚染 / 回復可否) | +| `oracle` | enum | ✅ | FEW HICCUPPS のどの軸 (本 Skill 拡張) | +| `page_role` | enum | ✅ | `lp` / `list` / `item` / ... (本 Skill 拡張) | +| `evidence` | object | ✅ | screenshot / video / trace.zip / HAR / console / network | +| `repro_command` | string | 推奨 | `uv run pytest tests/test_form.py::test_create_user --headed` | +| `workaround` | string | 任意 | あれば | +| `related` | string[] | 任意 | 関連バグ / PR / 仕様リンク | +| `status` | enum | ✅ | `New` / `Triaged` / `In progress` / `Fixed` / `Verified` / `Closed` / `Won't fix` | +| `assignee` | string | 任意 | | + +## 2. severity 定義 + +技術的影響度。ビジネス緊急度 (priority) とは独立。 + +| level | 定義 | 例 | +|---|---|---| +| **S1: Blocker** | 主要機能が完全に動かない / データ消失 / セキュリティ侵害 | login できない / 注文が消える / 他人の注文が見える | +| **S2: Critical** | 主要機能で重大な誤動作 / 多数のユーザに影響 | 価格計算ミス / 検索 0 件返却 / 編集が保存されない | +| **S3: Major** | 機能の一部が不安定 / 一部ユーザに影響 / 回避策あり | 特定ブラウザで描画崩れ / リトライで回復 | +| **S4: Minor** | 表示崩れ / typo / コスメティック | 文字化け / 微小なレイアウトずれ | + +## 3. priority 定義 + +ビジネス緊急度。 + +| level | 定義 | +|---|---| +| **P1** | 即時修正 (リリースブロック) | +| **P2** | 次リリース | +| **P3** | バックログ対応 | +| **P4** | 余裕があれば | + +## 4. oracle (FEW HICCUPPS) 紐付け + +「なぜこれが不具合と判定できるか」を 11 軸で明示。 + +| 略 | 軸 | bug 例 | +|---|---|---| +| F | Familiarity | 過去 bug DB の同型 (#XXXX) と一致 | +| E | Explainability | 価格と総額の差を説明できない | +| W | World | 月が 13 月と表示 | +| H | History | 前 release ではできた (commit XXX) | +| I | Image | デザインガイド逸脱 (Figma リンク) | +| C | Comparable products | 競合品 X では正常 | +| C | Claims | spec が「3秒以内」と主張 (URL/PR) | +| U | User expectations | 一般ユーザは「Esc で閉じる」と期待 | +| P | Product | 詳細とリストで値が違う | +| P | Purpose | EC なのに購入できない | +| S | Statutes | WCAG 2.2 違反 (criterion 番号) | + +「**思った通りでない**」のような曖昧な根拠は禁止。必ず軸 + 出典を書く。 + +## 5. evidence 自動収集 (Playwright) + +| アーティファクト | 取得方法 | bug 報告での価値 | +|---|---|---| +| **trace.zip** | `--tracing retain-on-failure` | DOM snapshot / network / console / source / film strip 統合 — 最強 | +| screenshot | `--screenshot only-on-failure` | 失敗時の見た目 | +| video | `--video retain-on-failure` | 連続的な遷移把握 | +| HAR | `recordHar: { path: '...' }` | サーバ往復ペイロード詳細 | +| console log | `page.on("console", ...)` | JS エラー検出 | +| pageerror log | `page.on("pageerror", ...)` | uncaught exception | +| network log | trace 内包 | API ステータス / タイミング | +| storage_state | `context.storage_state()` | 認証関連 bug | + +### 推奨 config (pytest-playwright) + +```ini +# pyproject.toml +[tool.pytest.ini_options] +addopts = "--tracing retain-on-failure --video retain-on-failure --screenshot only-on-failure" +``` + +### trace.zip の閲覧 URL 化 + +`scripts/upload_evidence.py --kind trace --public` が trace.zip を Google Drive に +アップロードし、`https://trace.playwright.dev/?trace=<URL>` 形式の閲覧 URL を +生成する (HAR / video の Drive アップロードも同スクリプトで可能、`--kind har/video`)。 +bug report に必ずこの URL を貼る (zip 単体だと開発者の手元で展開が必要)。 + +## 6. bug report テンプレート (Markdown) + +以下を bug 起票時の雛形として使用する (`reports/<run-id>/<TC-ID>/log.txt` と +trace/HAR を `upload_evidence.py` でアップロードしたあと、生成された Drive リンクを +本テンプレートに差し込む運用)。 + +```markdown +# BUG-2026-04-25-001 — 詳細ページの編集ボタンが他者所有データで非表示にならない + +- **detected_at**: 2026-04-25T14:32:01+09:00 +- **originator**: pytest tests/test_item_permission.py::test_other_owners_edit_blocked +- **page_role**: item +- **oracle**: Statutes (OWASP WSTG IDOR / OWASP ASVS V8.2.5) +- **severity**: S1 / **priority**: P1 +- **reproducibility**: always (3/3) +- **status**: New + +## 環境 +- url: https://staging.example.com/items/789 +- build_sha: a1b2c3d +- browser: Chromium 122 (Playwright 1.50) +- os: Linux 6.6 +- viewport: 1280x720 +- language: ja-JP +- role: user (alice@example.com) + +## 前提条件 +- alice (role=user) でログイン済 +- /items/789 の所有者は bob (別ユーザ) +- alice は admin 権限を持たない + +## 再現手順 +1. alice でログイン (POST /user/login) +2. ブラウザで `/items/789` を直接開く (alice 所有でない id) +3. 詳細表示を確認 + +## 期待結果 +- 「編集」ボタンが非表示。または 403 Forbidden で詳細自体が見えない。 +- 出典: 仕様書 #SPEC-AUTHZ-002 / OWASP WSTG IDOR + +## 実際結果 +- 詳細ページが 200 OK で開き、「編集」ボタンが**表示される**。 +- ボタンを押すと /items/789/edit に遷移し、編集フォームが開く (alice の入力が bob のデータを上書きできる)。 + +## 影響 +- 全 user ロールが他人のデータを編集可能。data-tampering リスク。重大度 S1。 +- データ汚染が発生した場合、監査ログから復旧する必要あり。 + +## エビデンス +- Trace: https://trace.playwright.dev/?trace=https://drive.google.com/.../trace.zip +- Video: https://drive.google.com/.../TC-30-edit-permission.mp4 +- Screenshot: https://drive.google.com/.../03-detail-with-edit-btn.png +- HAR: https://drive.google.com/.../trace.har +- Console: (エラーなし) + +## 再現コマンド +```bash +uv run pytest tests/test_item_permission.py::test_other_owners_edit_blocked \ + --pwk-config=./scenario.config.yaml --headed +``` + +## 関連 +- 仕様書: SPEC-AUTHZ-002 +- 関連 PR: #1234 (権限チェック導入) +``` + +## 7. AI が bug を起票する際の規律 + +AI が「これは不具合だ」と判断するときは次の 6 点を機械的に確認する: + +1. **再現性**: 3 回実行して何回再現するか (`reproducibility` フィールド必須) +2. **oracle**: FEW HICCUPPS のどの軸か。曖昧な「変な感じ」は不可 +3. **証拠の網羅**: trace / screenshot / video / console / network が揃っているか +4. **環境の特定**: viewport / browser / role / build sha がわかるか +5. **期待と実際の分離**: 「期待」は仕様 / oracle 引用、「実際」は観測値のみ。混ぜない +6. **severity の根拠**: なぜ S1 か / なぜ S3 か。判断軸を書く + +この 6 点が満たせない場合は **bug 起票せず、観察ログに留める**。 + +## 8. ISO/IEC/IEEE 29119-3:2021 (Incident Report) 参照表 + +| 標準フィールド | 本 Skill の対応 | +|---|---| +| Identifier | `id` | +| Summary | `title` | +| Description | `expected` + `actual` | +| Environment | `environment` | +| Reproduction | `preconditions` + `steps` + `repro_command` | +| Expected result | `expected` | +| Actual result | `actual` | +| Test item | `page_role` + `url` | +| Severity / Priority | `severity` + `priority` | +| Status | `status` | +| Originator | `originator` | +| Date | `detected_at` | +| Resolution information | `status` + `assignee` | + +`oracle` フィールドは 29119-3 にはないが、本 Skill が「揺らぎ排除」のため必須化する拡張。 + +## 参考文献 + +- ISO/IEC/IEEE 29119-3:2021, "Software and systems engineering — Software testing — Part 3: Test documentation" +- Atlassian, "Bug Report Template", https://www.atlassian.com/software/jira/templates/bug-report +- BrowserStack, "How to Write a Bug Report" +- Bach/Bolton, "FEW HICCUPPS", https://developsense.com/blog/2012/07/few-hiccupps +- Playwright Trace Viewer, https://playwright.dev/docs/trace-viewer diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/06-pytest-playwright.md b/plugins/ndf/skills/playwright-scenario-test/docs/06-pytest-playwright.md new file mode 100644 index 00000000..a76f0eeb --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/06-pytest-playwright.md @@ -0,0 +1,246 @@ +# 06. pytest-playwright リファレンス + +`pytest-playwright` は Playwright 公式の pytest plugin。本 Skill はこの plugin の上に NDF 固有の fixture / marker / hook を追加する形で構築されている。 +本書では「pytest-playwright が提供する物」と「NDF が追加する物」を整理し、利用者が両者を混乱なく組み合わせられるようにする。 + +## 1. インストールと前提 + +```bash +uv add pytest-playwright pytest-xdist # main 依存に追加 (本 Skill では同梱済) +playwright install chromium # ブラウザバイナリ +``` + +`pytest-playwright>=0.5` が必要。CLAUDE.md / pyproject.toml の依存と一致。 + +## 2. 標準 fixture (pytest-playwright 提供) + +| fixture | scope | 用途 | +|---|---|---| +| `playwright` | session | `Playwright` インスタンス | +| `browser_type` | session | 現在の `BrowserType` (chromium/firefox/webkit) | +| `browser` | session | session 中で 1 回だけ launch される `Browser` | +| `browser_name` / `browser_channel` | session | 文字列 (条件分岐用) | +| `is_chromium` / `is_firefox` / `is_webkit` | session | 真偽値 | +| `context` | function | 各 test 専用の `BrowserContext` (毎回新規) | +| `page` | function | `context.new_page()` 済の `Page` | +| `new_context` | function | test 内で別 context を追加で作る関数 | +| `browser_type_launch_args` | session | `BrowserType.launch()` の引数 (override 可能) | +| `browser_context_args` | function | `Browser.new_context()` の引数 (override 可能) | +| `connect_options` | session | 既存ブラウザに WS で接続する場合の dict | + +**重要**: `context` / `page` は **function scope** なので test ごとに完全分離される。テスト間で状態漏れが起きない代わりに、認証は毎回 storage_state を inject する必要がある (NDF では `pwk_role_<id>` fixture が担当)。 + +## 3. CLI options (pytest-playwright) + +| option | デフォルト | 用途 | +|---|---|---| +| `--browser <name>` | chromium | 複数指定可: `--browser firefox --browser webkit` | +| `--browser-channel <name>` | (なし) | `chrome` / `msedge` 等 | +| `--headed` | off | ヘッドフルで実行 (debug) | +| `--slowmo <ms>` | 0 | 各操作を ms 単位で遅延 (debug) | +| `--device <name>` | (なし) | `iPhone 13` 等のデバイスエミュ | +| `--output <dir>` | `test-results` | 全 artifact の出力先 | +| `--tracing <mode>` | off | `on` / `off` / `retain-on-failure` | +| `--video <mode>` | off | 同上 | +| `--screenshot <mode>` | off | `on` / `off` / `only-on-failure` | +| `--full-page-screenshot` | off | screenshot 有効時に全 page を撮る | + +`pytest.ini` / `pyproject.toml` で固定化できる: + +```toml +# pyproject.toml +[tool.pytest.ini_options] +addopts = "--headed --browser firefox --tracing retain-on-failure" +``` + +## 4. NDF 提供の追加 fixture / marker / option + +| 種別 | 名前 | 由来 | scope | 用途 | +|---|---|---|---|---| +| fixture | `pwk_config` | NDF | session | `scenario.config.yaml` をロード | +| fixture | `pwk_role_<id>` (動的生成) | NDF | function | 該当 role で login 済 storage_state を context に inject | +| fixture | `pwk_evidence` | NDF | function | HAR / trace / console / pageerror の集中管理 | +| fixture | `pwk_accessibility_scan` | NDF | function | 任意のタイミングで axe-core を 1 回実行 | +| fixture | `pwk_web_vitals_measure` | NDF | function | 任意のタイミングで Core Web Vitals を 1 回計測 | +| fixture | `browser_context_args` (override) | NDF | function | HAR `record_har_path` を inject (上書きしないこと) | +| marker | `@pytest.mark.page_role(...)` | NDF | — | accessibility / web_vitals autouse の判定 (auto_roles 設定に従う) | +| marker | `@pytest.mark.role(role_id)` | NDF | — | report.md 集計用 (login は `pwk_role_<id>` 側で行う) | +| marker | `@pytest.mark.phase(num)` / `priority(level)` | NDF | — | report.md ソート / フェーズ集計 | +| CLI | `--pwk-config <path>` | NDF | — | `scenario.config.yaml` パス | +| CLI | `--pwk-out-dir <path>` | NDF | — | 成果物出力先 (default: `reports/<run-id>/`) | +| CLI | `--pwk-no-evidence` | NDF | — | HAR / trace / video の収集を OFF | +| CLI | `--pwk-overlay` | NDF | — | overlay (旧名 HUD) を inject (録画用) | +| CLI | `--pwk-drive-folder <id>` | NDF | — | session 終了時に Drive へアップ | + +## 5. fixture override パターン + +### 5.1 全テスト共通の context オプション + +利用者プロジェクトの `tests/conftest.py` で: + +```python +import pytest + +@pytest.fixture(scope="session") +def browser_context_args(browser_context_args): + return { + **browser_context_args, + "viewport": {"width": 1440, "height": 900}, + "locale": "ja-JP", + "timezone_id": "Asia/Tokyo", + } +``` + +NDF の `browser_context_args` (HAR `record_har_path` を注入する関数 scope) を併用する場合、**NDF の override が優先される**。利用者は `viewport` / `locale` 等の追加情報のみを混ぜる形にする。 + +### 5.2 1 件のテストだけ context を変える + +```python +@pytest.mark.browser_context_args(timezone_id="Europe/Berlin", locale="en-GB") +def test_german_user(page): + assert page.evaluate("window.navigator.languages") == ["de-DE"] +``` + +`browser_context_args` marker は pytest-playwright 標準。NDF marker と独立して動く。 + +### 5.3 複数 role を session 内で再利用 + +NDF では `pwk_role_<id>` fixture が **session 内で login を 1 回だけ実行** し storage_state を cache する。同じ role を使う test は何件あっても再ログインしない。 +利用者プロジェクト側で同様の最適化を自前で書く必要はない。 + +## 6. 並列実行 (`pytest-xdist`) + +```bash +pytest -n auto # CPU 数に応じて自動 +pytest -n 4 # worker 4 並列 +pytest -n 4 --dist=loadgroup # @pytest.mark.xdist_group で同じ context を寄せる +``` + +注意点: + +- `pwk_role_<id>` の storage_state cache は **worker 内** で共有される。worker をまたぐとログインが N 回走る (ヘビーな環境では `auth.json` を pre-build しておく方法を検討) +- `pwk_evidence` の出力先 (`reports/<run-id>/<test-id>/`) は test 名から sub-dir を切るため worker 競合は起きない +- `pytest_terminal_summary` で集約される `report.md` は xdist でも 1 ファイルで出る + +## 7. 複数ブラウザ / parametrize + +```bash +pytest --browser chromium --browser firefox --browser webkit +``` + +各 test は browser 数だけ実行される (test ID の末尾に `[chromium]` 等が付く)。 +特定 test のみ skip / only したい場合: + +```python +def test_only_in_chromium(page, browser_name): + if browser_name != "chromium": + pytest.skip("chromium only") + ... +``` + +## 8. Visual Regression + +```python +expect(page).to_have_screenshot("dashboard.png", max_diff_pixel_ratio=0.01) +``` + +- 初回実行で baseline が生成される (`<test>__<browser>.png`) +- `--update-snapshots` で baseline を更新 +- 差分は `test-results/` に PNG で保存される + +NDF では現状 visual regression は autouse 化していない。必要な test に手動で `expect(...).to_have_screenshot()` を書く。 + +## 9. 実行方法 (v0.5.0 自己完結ランタイム) + +v0.5.0 から、利用者プロジェクトに `init_project.sh` で埋め込んだ `scenario-test/` +ディレクトリ単体で実行する形が推奨。Skill ディレクトリの存在に依存しない。 + +```bash +# 1) 初期化 (Skill ディレクトリ内で 1 度だけ) +cd .claude/plugins/ndf/skills/playwright-scenario-test +./scripts/init_project.sh /path/to/your-app +# → /path/to/your-app/scenario-test/ 一式が作成される +# (--runtime-dir e2e で配置先名カスタマイズ可) + +# 2) 通常の実行 +cd /path/to/your-app +./scenario-test/run.sh # 全テスト +./scenario-test/run.sh -k test_admin # nodeid フィルタ +./scenario-test/run.sh -m "page_role" # marker フィルタ +./scenario-test/run.sh --pwk-overlay # 動画に赤丸カーソル + 字幕 +./scenario-test/run.sh --pwk-drive-folder=<ID> # Drive 自動アップロード +./scenario-test/run.sh -n 4 # 並列実行 +``` + +ランチャ (`run.sh` / `run.bat`) は自身の位置から RUNTIME_DIR を解決し、CWD を +ランタイム内 (`scenario-test/`) に固定して `uv run pytest` を起動する。利用者 +プロジェクトのどこから呼んでも挙動は同じ。 + +`scenario-test/` の中身: + +``` +scenario-test/ +├── playwright_kit/ ← Python パッケージ本体 +├── scripts/ ← 補助 CLI +├── tests/ ← 利用者の pytest テスト +├── reports/ ← 実行結果 (.gitignore 推奨) +├── scenario.config.yaml ← 利用者の設定 +├── pyproject.toml ← runtime 用 (testpaths=["tests"]) +├── uv.lock ← 再現性のため commit 推奨 +├── run.sh / run.bat ← ランチャ +└── README.md ← 最低限の使い方 +``` + +直接 pytest を呼ぶ場合 (CI / IDE 統合): + +```bash +cd /path/to/your-app/scenario-test +uv sync # 初回のみ +uv run playwright install chromium # 初回のみ +uv run pytest --pwk-config=./scenario.config.yaml +``` + +### 旧運用との互換性 + +旧 v0.4.0 までは Skill ディレクトリで `uv sync` する運用だったが、v0.5.0 では +非推奨。理由は (1) Skill が消えるとテストが動かない (CI / 別マシン破綻)、 +(2) Skill 側の uv プロジェクトが利用者プロジェクトの依存と分離されておらず +再現性が低い。本 Skill は開発中につき後方互換は重視せず、旧運用は廃止する。 + +## 10. デバッグ + +| 目的 | 方法 | +|---|---| +| ヘッドフルで動かす | `pytest --headed` | +| ステップバイステップ | `pytest --headed --slowmo 500` | +| trace を必ず採る | `pytest --tracing on` (本 Skill は pwk_evidence でも採取) | +| trace viewer で再生 | `playwright show-trace test-results/.../trace.zip` | +| インスペクタで一時停止 | `page.pause()` を test 内に挿入 + `--headed` | + +`page.pause()` は **テストを書く時の手探り** に有効。完成したテストには残さない。 + +## 11. NDF 移行時の注意 + +| pytest-playwright 標準 | NDF 上での扱い | +|---|---| +| `--video on` | NDF では `pwk_evidence` が webm を採り `video.py` で mp4 化する。`--video` は併用しないこと (重複) | +| `--tracing on` | 同上。`pwk_evidence` 側で取るので NDF 経由が推奨 | +| `--screenshot only-on-failure` | NDF 標準では設定していない。利用者プロジェクトで必要なら有効化可 | +| `browser_context_args` override | NDF 側 override をベースに、利用者は `viewport` 等の追加のみ | + +## 12. autouse fixture の落とし穴 + +NDF の accessibility / web_vitals autouse fixture は **`page` を直接 fixture 引数に取らない**。理由: + +- pytest-playwright が `page` を function scope で要求するため、autouse fixture が `page` を要求すると **全 test が browser parametrize される** +- 結果として「page_role marker 無し」の純 unit test まで browser を立ち上げてしまう + +NDF では `request.fixturenames` ガード + `request.getfixturevalue("page")` の遅延取得でこれを回避している。利用者プロジェクトで autouse fixture を書く場合も同じパターンを使うこと。 + +## 参考文献 + +- pytest-playwright Test Runners, https://playwright.dev/python/docs/test-runners +- pytest-playwright API, https://playwright.dev/python/docs/api/class-fixtures +- pytest-xdist, https://pytest-xdist.readthedocs.io/ +- Playwright Trace Viewer, https://playwright.dev/python/docs/trace-viewer +- Visual Comparisons, https://playwright.dev/python/docs/test-snapshots diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/README.md b/plugins/ndf/skills/playwright-scenario-test/docs/README.md new file mode 100644 index 00000000..1ab18dea --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/README.md @@ -0,0 +1,79 @@ +# Web シナリオテスト方法論ドキュメント + +このディレクトリは Playwright シナリオテスト Skill が依拠する**テスト方法論**をまとめている。 +SKILL.md は実行手順とナビゲーションに集中し、「なぜそう書くか」「どこまで網羅するか」の理論はここに集約する。 + +AI/人間どちらが計画書を書く場合も、まず該当 page role のチェックリストを参照し、 +記載の oracle (FEW HICCUPPS の何軸) を脚注として残すこと。経験で書かない。 + +## 構成 + +| パス | 内容 | 想定読者 | +|---|---|---| +| `01-methodology.md` | テスト方法論総論 (HTSM / ISTQB / FEW HICCUPPS の本 Skill 適用) | テスト計画立案者 | +| `02-page-roles.md` | page role 分類体系と識別ヒューリスティック | 計画立案者 / 自動分類スクリプト | +| `03-test-techniques.md` | テスト技法ライブラリ (EP / BVA / Decision Table / State Transition / Pairwise) | テストケース設計者 | +| `04-playwright-mapping.md` | Playwright API → page role / 観点 マッピング | 自動化実装者 | +| `05-bug-report.md` | bug report 仕様 (ISO/IEC/IEEE 29119-3 ベース) + エビデンス自動収集 | テスト実行者 / 報告者 | +| `06-pytest-playwright.md` | pytest-playwright fixture / CLI option / NDF 拡張との対応関係 | 自動化実装者 / 利用者 | +| `checklists/checklist-common.md` | 全 role 共通: accessibility (axe-core) / Core Web Vitals / セキュリティ / i18n | 全テスト | +| `checklists/checklist-lp.md` | Landing Page (外部到達ページ) | LP 担当 | +| `checklists/checklist-list.md` | 一覧ページ | 一覧担当 | +| `checklists/checklist-item.md` | 詳細ページ | 詳細担当 | +| `checklists/checklist-edit.md` | 編集ページ | 編集担当 | +| `checklists/checklist-form.md` | 申込フォーム (複数ステップ) | フォーム担当 | +| `checklists/checklist-search.md` | 検索ページ | 検索担当 | +| `checklists/checklist-dashboard.md` | ダッシュボード | dashboard 担当 | +| `checklists/checklist-auth.md` | 認証 (login / logout / 2FA) | auth 担当 | +| `checklists/checklist-cart-checkout.md` | カート / チェックアウト / 決済 | EC 担当 | +| `checklists/checklist-modal-wizard.md` | モーダル / ウィザード | UI コンポーネント担当 | + +## 利用フロー + +``` +1. 対象 URL を見て page_role を判定 + → docs/02-page-roles.md の識別ヒューリスティックを使用 + → スクリプト: scripts/classify_page_role.py + +2. 該当 role の checklist を開く + → docs/checklists/checklist-{role}.md を全項目走査 + → 各項目には適用すべきテスト技法 (EP/BVA/...) と oracle が併記されている + +3. pytest テストを書く + → templates/test_<role>.py.template を起点に test 関数を追加 + → `playwright codegen` で操作録画 → そのまま test に貼ってもよい + → `@pytest.mark.page_role(...)` を付ければ accessibility / web_vitals が autouse で走る + +4. 実行 → エビデンス収集 + → ./scenario-test/run.sh で実行 (init_project.sh 後の標準パス) + ※ 直接 `cd scenario-test && uv run pytest --pwk-config=./scenario.config.yaml` でも可 + → trace.zip / video / screenshot / HAR / console log / accessibility / web_vitals を自動収集 + → scenario-test/reports/<run-id>/report.md が ``pytest_terminal_summary`` で生成される + +5. bug 発見 → docs/05-bug-report.md に従って報告 + → 全 bug に oracle (FEW HICCUPPS) と severity を付与 +``` + +## 出典 + +このドキュメント群は次の一次資料に基づく。詳細は各文書末尾の「参考文献」節を参照: + +- **HTSM** (Heuristic Test Strategy Model) v6.3 — James Bach, Satisfice +- **ISTQB CTFL 4.2** — Black-box Test Techniques +- **ISO/IEC/IEEE 29119-3:2021** — Test documentation (Incident Report) +- **WCAG 2.2** — W3C Web Content Accessibility Guidelines +- **OWASP Top 10:2025** + ASVS + WSTG +- **web.dev Core Web Vitals** — LCP / INP / CLS +- **Test Heuristics Cheat Sheet** — Hendrickson / Lyndsay / Emery +- **FEW HICCUPPS** — Bach / Bolton oracle heuristic + +## 用語 + +| 用語 | 定義 | +|---|---| +| **page role** | ページの**機能的役割** (LP / list / item / edit / form / ...)。URL や SEO 構造とは別に、ユーザの目的・必要なテスト観点で分類する単位 | +| **oracle** | 「これは不具合だ」と判定する根拠 (仕様 / 過去版 / 標準 / ユーザ期待 / 内部一貫性 等) | +| **FEW HICCUPPS** | oracle の 11 軸 (Familiarity, Explainability, World, History, Image, Comparable products, Claims, User expectations, Product, Purpose, Statutes/Standards) | +| **HTSM** | テスト戦略を Mission / Environment / Product Elements (SFDIPOT) / Quality Criteria の 4 軸で構造化するモデル | +| **SFDIPOT** | Product Elements の 7 因子 (Structure / Function / Data / Interfaces / Platform / Operations / Time) | +| **EP / BVA** | Equivalence Partitioning / Boundary Value Analysis | diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-auth.md b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-auth.md new file mode 100644 index 00000000..8fac1618 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-auth.md @@ -0,0 +1,148 @@ +# Checklist: `auth` — 認証 (login / logout / 2FA / password reset) + +## 適用条件 +email/username + password。SSO ボタン / Remember me / Forgot password / 2FA. + +代表 URL: `/login`, `/signin`, `/register`, `/forgot-password`, `/auth/callback`. + +## 必須テスト観点 + +### AU1: 正常ログイン `[State / Functional]` +- 有効資格情報 → ログイン成功 +- セッション cookie 発行 (`Secure HttpOnly SameSite`) +- ダッシュボード or 指定 redirect 先へ遷移 +- URL に session id を**含めない** (cookie に格納) + +### AU2: 不正資格情報 — ユーザ列挙防止 `[Risk / Statutes]` +- 不存在メール `wrong@example.com` + 適当 pw → 「メールまたはパスワードが正しくありません」 +- 存在メール `valid@example.com` + 不正 pw → **同じ文言** (列挙防止) +- 「このメールは登録されていません」「パスワードが違います」のような切り分け表示は **NG** +- NIST 800-63B 推奨: 識別不能なメッセージ + +### AU3: 同一エラー応答時間 (timing attack 対策) `[Risk / Statutes]` +- 不存在メールと存在メールでレスポンス時間に有意差がない +- パスワード hash 計算を**両方で実行** (短絡しない) +- E2E では `expect_response` の経過時間を 100 件平均で比較 + +### AU4: アカウントロックアウト `[Decision Table / Statutes]` +- 連続失敗 N 回 → ロック (例: 5 回) +- ロック中は正しい資格情報でも拒否 +- ロック解除: メール経由 / 一定時間経過 / 管理者 +- rate limit ヘッダ (`Retry-After`) + +### AU5: パスワード強度ポリシー `[Claims / Statutes]` +NIST SP 800-63B 推奨: +- 最低 8 文字 (推奨 15 文字以上) +- 漏洩済 PW DB (HaveIBeenPwned) チェック (任意) +- 文字種強制 (大文字 / 数字 / 記号) は**非推奨** (覚えにくくなる) +- 過度な複雑性ルールは UX 低下の根拠あり + +仕様で定義された境界を BVA でテスト. + +### AU6: パスワードリセット URL `[Risk / Statutes]` +- リセットリンクは単一使用 (use once) +- 有効期限 (例: 1 時間) +- HTTPS only +- リンク内 token は十分なエントロピー (256 bit) +- 期限切れリンクで再発行可能 + +### AU7: 2FA フロー `[State / Decision Table]` +- TOTP / SMS / メール / Email magic link +- 正しいコード → ログイン成功 +- 不正コード N 回 → ロック +- 回復コード (recovery code) は単回利用 +- 2FA 一時的バイパス (Remember this device 30 日) の管理 + +### AU8: ログイン後のセッション ID 再発行 `[Risk / Statutes]` +- ログイン**前** session id (匿名) と**後** session id (認証済) が**異なる** +- session fixation 攻撃対策 (OWASP) +- E2E では `context.cookies()` で前後比較 + +### AU9: ログアウト `[State / Functional]` +- ログアウトでサーバ側セッション無効化 +- 戻るボタンで前ページに戻れない (cache-control: no-store) +- 別タブのセッションも無効化 (server-side session) +- 全セッション無効化オプション (パスワード変更時) + +### AU10: Remember me と再認証 `[Risk / Claims]` +- Remember me で長期 cookie (例: 30 日) +- 自動ログイン後、**重要操作**は再認証要求 (パスワード変更 / 削除) +- step-up authentication: 高権限操作のみ追加認証 + +### AU11: Cookie 属性 `[Statutes / Automatic]` +- `Secure` (HTTPS のみ送信) +- `HttpOnly` (JS から読めない — XSS で session 盗難防止) +- `SameSite=Lax` または `Strict` (CSRF 軽減) +- `Path=/` (適切な scope) +- `Domain` 属性: ワイルドカードドメインに注意 + +### AU12: CSRF token (login form にも) `[Statutes / Automatic]` +- ログインフォームにも CSRF token (login CSRF 攻撃) +- token なしで POST `/login` → 403 + +### AU13: SSO redirect URL whitelist `[Risk / Statutes]` +- OAuth callback URL は事前登録された whitelist のみ受理 +- `?redirect_uri=https://evil.example.com` で外部ドメインへ飛ばない +- open redirect 防止 + +### AU14: 並行セッション管理 `[State / Functional]` +- デバイス一覧の表示 (現在ログイン中のセッション) +- リモートログアウト (他デバイスを強制ログアウト) +- 異常検知 (新地域からのログイン → メール通知) + +### AU15: HSTS / referrer policy `[Statutes / Automatic]` +- `Strict-Transport-Security` ヘッダ +- `Referrer-Policy: strict-origin-when-cross-origin` +- 認証関連ページでの `Referer` 漏洩防止 + +## 適用すべきテスト技法 + +| 技法 | 適用箇所 | +|------|---------| +| Decision Table | AU4 (失敗回数 × ロック), AU7 (2FA 分岐) | +| Risk Testing | AU2, AU3, AU6, AU8, AU13 | +| BVA | AU5 (パスワード長境界) | +| State Transition | AU7, AU9, AU14 | +| Claims | AU5, AU10 | + +## Playwright 実装パターン + +```python +# AU1: storage_state を保存して全テストで再利用 +api = playwright.request.new_context(base_url=base_url) +api.post("/api/login", data={"email": "alice@test", "password": "..."}) +api.storage_state(path="alice.json") + +# 後続テストでは +context = browser.new_context(storage_state="alice.json") +page = context.new_page() +page.goto("/dashboard") # ログイン済 + +# AU2: ユーザ列挙防止 +for email in ["nonexistent@test", "valid@test"]: + page.goto("/login") + page.get_by_label("メール").fill(email) + page.get_by_label("パスワード").fill("wrongpass") + page.get_by_role("button", name="ログイン").click() + expect(page.get_by_role("alert")).to_have_text("メールまたはパスワードが正しくありません") # 同じ文言 + +# AU8: session id 再発行 +ctx_before = context.cookies() +session_before = next(c for c in ctx_before if c["name"] == "session_id")["value"] +# login +session_after = next(c for c in context.cookies() if c["name"] == "session_id")["value"] +assert session_before != session_after, "session fixation 対策不備" +``` + +## 共通チェックリスト併用 + +`checklist-common.md` の C3 (sec — 全項目), C8 (console error 自動 FAIL). + +## 参考文献 + +- OWASP ASVS v5.0 V2 (Authentication), V3 (Session Management) +- OWASP Authentication Cheat Sheet +- OWASP Session Management Cheat Sheet +- OWASP CSRF Prevention Cheat Sheet/ +- NIST SP 800-63B "Digital Identity Guidelines" +- RFC 6265 "HTTP State Management Mechanism" (cookies) diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-cart-checkout.md b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-cart-checkout.md new file mode 100644 index 00000000..09487931 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-cart-checkout.md @@ -0,0 +1,158 @@ +# Checklist: `cart` / `checkout` — カート / チェックアウト / 決済 + +## 適用条件 +商品行 + 数量 + 合計 + 配送 + 支払い + 確認 + 完了. + +代表 URL: `/cart`, `/checkout`, `/checkout/payment`, `/order/confirm`, `/order/{id}`. + +## 必須テスト観点 + +### CK1: カート Count `[EP / Hendrickson Count]` +- 0 件 (空カート) → 「カートは空です」+ 商品検索 CTA +- 1 件 (単数表示) +- max 個数 (在庫上限 / システム上限) +- 在庫超過 (`requested > stock`) → 警告 + max 値に丸め + +### CK2: 価格再計算 `[Decision Table / Product]` +入力分岐の決定表: + +| 数量変更 | クーポン | 送料無料閾値 | 期待挙動 | +|---------|---------|-------------|---------| +| ✓ | - | - | 即時再計算 (loading 表示) | +| - | 適用 | - | 割引表示 + 合計再計算 | +| - | - | 超過 | 送料 0 表示 | +| - | 適用 | 適用後超過 | 送料 0 + クーポン両方反映 | + +### CK3: 通貨 / 税率 / 表示 `[Domain / Statutes]` +- 通貨選択 (USD/JPY/EUR) で全価格再計算 +- 税込 / 税抜表示 (locale 別 JP は税込、US は税抜が多い) +- 軽減税率 / 標準税率の混在 +- 端数処理 (切捨/四捨五入) ポリシー一致 + +### CK4: クーポン `[Decision Table / Claims]` +- 有効期限内 +- 期限切れ → エラー +- 併用不可フラグ → 既適用との競合 +- 最低金額未達 → 「あと N 円で適用可能」 +- ユーザ単位上限 → N 回目以降エラー +- 大文字小文字 / 全半角の許容 + +### CK5: 総額 0 円 / 総割引が総額超過 `[BVA / Edge case]` +**重要 edge case**. +- 100% off クーポンで合計 0 円 → 決済 step skip / 即完了 (仕様による) +- 割引が商品代を超過 → 0 円で確定 (マイナスにしない) +- ポイント全額利用 + クーポン併用 + +### CK6: 在庫変動 (Multi-user) `[Multi-user / Reliability]` +- カートに入れた商品が他ユーザに買い切られる +- checkout 直前のチェック: 「在庫切れ」 + 削除 / 数量調整選択 +- 競合状態でも 二重販売しない (DB トランザクション or pessimistic lock) + +### CK7: リロードでカート保持 `[State / User]` +- ログイン前に商品追加 → ログイン後にカート merge +- ログアウト → 別ユーザログイン: ゲストカートの扱い (引き継ぐ / 破棄) +- 別タブで同じユーザがカート操作 → 同期 + +### CK8: 住所バリデーション `[Domain / Statutes]` +- 郵便番号 → 自動補完 (国別: JP `123-4567`, US `12345-6789`, UK `SW1A 1AA`) +- 海外住所: state / province / region の必須化 +- PO Box 拒否 (配送業者制限) +- 同一住所でも 文字種違い (全角/半角) で別住所扱いされない + +### CK9: クレジットカード validation `[BVA / Statutes / PCI]` +- Luhn check (ブラウザ側 `<input pattern>` + サーバ側) +- 期限切れ (前月) +- CVV 3 桁 (Visa/MC) / 4 桁 (Amex) +- 3D Secure challenge (フェイク環境で OK) +- 失敗 → エラー表示 + 別カード再試行 +- **PCI DSS**: カード番号を JS log / sentry breadcrumb / E2E trace に出さない (検査必須) + +### CK10: 部分支払い `[Decision Table / Functional]` +- gift card + credit card (ハイブリッド) +- ポイント部分使用 +- 残額 0 円なら gift card のみで完了 + +### CK11: 完了画面リロードで二重課金しない `[State / Reliability]` +- POST/Redirect/GET パターン +- idempotency key (Stripe 等の API 仕様準拠) +- リロードしても order id が変わらない + +### CK12: 注文確認メール `[Functional / i18n]` +- 送信成功 +- 件名 / 本文の i18n +- 添付 PDF (領収書) の内容検証 +- メール内リンクの正当性 (token 付き / 期限) + +### CK13: ネットワーク中断 `[Interruptions / Reliability]` +- 決済 API 呼出中に offline +- 「決済処理中エラー」 + 再開可能 +- DB に「決済中」状態のレコードが残る → 監視で回収 + +### CK14: PCI DSS 関連 `[Statutes / Risk]` +- カード番号入力フォームは Iframe (PCI scope 削減 — Stripe Elements 等) +- E2E trace に PAN (カード番号) が含まれていないことを確認 +- console / network logs で PAN 検出を grep + +### CK15: 注文確認 URL の IDOR `[Risk / Statutes]` +- alice の注文確認 URL `/order/123` を bob が踏む → 403 / 404 +- token 付き URL `/order/123?token=...` で他人がアクセス不可 +- 注文番号がシーケンシャルでない (推測困難) + +### CK16: キャンセル / 返品フロー `[State / Functional]` +- キャンセル可能期限 (発送前) +- キャンセル後の在庫戻し +- 返品申請 → 配送ラベル生成 → 返金 + +### CK17: 配送選択 `[Domain / Decision Table]` +- 標準 / 速達 / 店舗受取 / 翌日配送 (時間指定) +- エリア外の配送方法は選択肢から消える +- 送料が選択に応じて再計算 + +## 適用すべきテスト技法 + +| 技法 | 適用箇所 | +|------|---------| +| Decision Table | CK2, CK4, CK10, CK17 | +| BVA | CK1, CK5 (金額境界), CK9 (CVV 桁数) | +| State Transition | CK11 (cart→checkout→paid→shipped→delivered/cancelled), CK16 | +| Multi-user | CK6 | +| Risk | CK13, CK14, CK15 | +| Interruptions | CK13 | +| Domain | CK3, CK8, CK17 | + +## Playwright 実装パターン + +```python +# CK14: PCI DSS — trace.zip にカード番号がないか確認 +# テスト終了時に +import zipfile +import re +with zipfile.ZipFile("trace.zip") as zf: + for name in zf.namelist(): + content = zf.read(name).decode("utf-8", errors="ignore") + # 16 桁数字パターン (テストカード番号など) + if re.search(r"\b\d{4}[ -]?\d{4}[ -]?\d{4}[ -]?\d{4}\b", content): + pytest.fail(f"PAN detected in trace: {name}") + +# CK11: 完了画面リロード +page.goto(complete_url) +page.reload() +expect(page).to_have_url(complete_url) # 完了画面のまま +expect(page.get_by_text("ご注文ありがとうございます")).to_be_visible() +order_count_after = api.get("/api/orders").json()["count"] +assert order_count_before + 1 == order_count_after # 二重登録なし +``` + +## 共通チェックリスト併用 + +`checklist-common.md` の C1 (a11y form), C3 (sec — IDOR / CSRF / open redirect), C6 (offline), C7 (4xx 5xx), C8 (console — 特に PAN 検出). +`form` checklist の FM1〜FM10 も準用 (checkout は複数ステップ form). +`auth` checklist (購入時のゲスト → 登録 / ログインフロー). + +## 参考文献 + +- PCI DSS v4.0 +- Stripe API "Idempotent requests" +- testvox "E-commerce checkout testing" +- OWASP ASVS V8 (Data Protection), V9 (Communication) +- ISO/IEC/IEEE 29119-3 § Test Items (Order entity) diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-common.md b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-common.md new file mode 100644 index 00000000..c6315dac --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-common.md @@ -0,0 +1,129 @@ +# 全 role 共通チェックリスト + +すべての page role に対して走査する横断項目。 +各 role 固有チェックリストと**併用**する (両方適用)。 + +各項目には `[技法 / oracle 軸]` が併記されている。テスト計画 YAML には適用した技法と oracle を必ず記録する。 + +## 1. アクセシビリティ (WCAG 2.2 AA) + +| # | 観点 | 技法 | oracle | 検査方法 | +|---|------|------|--------|---------| +| C1.1 | axe-core 違反 0 件 | Automatic Checking | Statutes (WCAG) | `scripts/run_a11y_scan.py {url}` (axe-playwright-python). タグ `wcag2a`, `wcag2aa`, `wcag21aa`, `wcag22aa` | +| C1.2 | キーボードのみで全主要操作可能 | User Testing | Statutes (WCAG 2.1.1) | Tab で操作要素を順に踏破。手順 YAML で記録 | +| C1.3 | フォーカス可視性 | User Testing | Statutes (WCAG 2.4.7, 2.4.11 New 2.2) | フォーカス時の outline / 背景色変化を確認 | +| C1.4 | 見出し階層 (h1 単一 / h2 → h3 順) | Automatic | Statutes (WCAG 1.3.1) | `page.locator("h1").count() == 1` + axe `heading-order` | +| C1.5 | 画像 alt | Automatic | Statutes (WCAG 1.1.1) | axe `image-alt` | +| C1.6 | フォームラベル紐付け | Automatic | Statutes (WCAG 1.3.1, 3.3.2) | axe `label`, `aria-input-field-name` | +| C1.7 | 色のみで情報伝達しない | User Testing | Statutes (WCAG 1.4.1) | リンク色のみで識別される箇所がないか目視 | +| C1.8 | コントラスト比 ≥ 4.5:1 (テキスト) | Automatic | Statutes (WCAG 1.4.3) | axe `color-contrast` | +| C1.9 | ターゲットサイズ ≥ 24×24 px (新 2.2) | Domain Testing | Statutes (WCAG 2.5.8) | bounding box 計測スクリプト | +| C1.10 | aria_snapshot のリグレッション | Automatic | Product (内部一貫性) | `expect(...).to_match_aria_snapshot(...)` | + +## 2. パフォーマンス (Core Web Vitals) + +LCP / INP / CLS は **field metric** だが、本 Skill では Lab 計測で代替。 + +| # | 観点 | 技法 | oracle | 検査方法 / 閾値 | +|---|------|------|--------|----------------| +| C2.1 | LCP ≤ 2.5s | Claims | Claims (web.dev) | `scripts/check_cwv.py --metric lcp` | +| C2.2 | CLS ≤ 0.1 | Automatic | Claims | `scripts/check_cwv.py --metric cls` | +| C2.3 | INP ≤ 200ms | User Testing | Claims | INP は field 主体。Lab で `pointer-down → next paint` を計測 | +| C2.4 | TTFB (server response) ≤ 800ms | Automatic | Performance | `page.expect_response` の経過時間 | + +## 3. セキュリティ (OWASP Top 10:2025) + +E2E から検証可能な範囲。攻撃ではなく**挙動確認**まで。 + +| # | 観点 | 技法 | oracle | 検査方法 | +|---|------|------|--------|---------| +| C3.1 | A01: Broken Access Control / IDOR | Risk Testing | Statutes (OWASP A01) | 別ロールの id を URL に直接指定 → 403 Forbidden | +| C3.2 | A05: Injection (XSS) sanitize | Risk Testing | Statutes (WSTG-INPV-01) | 入力欄に `<script>alert(1)</script>` → 実行されず本文に文字列として表示 | +| C3.3 | A05: Injection (SQL) | Risk Testing | Statutes (WSTG-INPV-05) | 入力欄に `' OR '1'='1` → エラーや想定外結果が返らない | +| C3.4 | CSRF token 必須 | Risk Testing | Statutes (OWASP CSRF) | hidden token を削除して submit → 403 | +| C3.5 | Cookie 属性 `Secure` `HttpOnly` `SameSite` | Automatic | Statutes (OWASP Session) | `context.cookies()` を取得し属性検査 | +| C3.6 | エラーページに stack trace を露出しない | Claims | Statutes (OWASP A09) | わざと 500 を起こし `Traceback` `at /path/to/...` 等が出ないか | +| C3.7 | open redirect 防止 | Risk Testing | Statutes (WSTG-CLNT-04) | redirect URL に `//evil.example.com` を渡してドメイン外へ飛ばないか | +| C3.8 | Content-Security-Policy 設定 | Automatic | Statutes (OWASP A06) | `expect_response` でレスポンスヘッダ検査 | + +## 4. i18n / l10n + +| # | 観点 | 技法 | oracle | 検査方法 | +|---|------|------|--------|---------| +| C4.1 | pseudo-localization 文字列 +40% でレイアウト崩壊しない | Domain Testing | Image | テキストを 1.4 倍に膨張させた fixture でテスト | +| C4.2 | RTL (`dir="rtl"`) でレイアウト鏡像化 | Compatibility | Statutes (i18n) | `page.evaluate("document.dir = 'rtl'")` 後の visual regression | +| C4.3 | Unicode 文字 (絵文字 / CJK / 結合 / ゼロ幅) を保持 | Domain Testing | Product | 入力 → 保存 → 再表示で同一文字列 | +| C4.4 | 日付 / 通貨フォーマット (TZ / DST / 千区切り) | Domain Testing | Statutes (Locale) | `Intl.DateTimeFormat` の locale を切替 | +| C4.5 | ハードコード文字列の検出 (英語混入) | Claims | Claims | pseudo-loc fixture で英語残存検出 | + +## 5. クロスブラウザ / デバイス + +| # | 観点 | 技法 | oracle | 検査方法 | +|---|------|------|--------|---------| +| C5.1 | Chromium / Firefox / WebKit で動作 | Compatibility | Compatibility | pytest project で `--browser firefox --browser webkit` | +| C5.2 | Mobile viewport (Pixel 5 / iPhone 13) | Compatibility | Compatibility | `playwright.devices["iPhone 13"]` で context 作成 | +| C5.3 | Tablet (768〜1024px) のレイアウト | Compatibility | Image | viewport を 768/1024 で撮影し layout 確認 | +| C5.4 | dark / light / reduced-motion 対応 | Domain Testing | Statutes (WCAG 2.3.3) | `page.emulate_media(color_scheme="dark", reduced_motion="reduce")` | + +## 6. ネットワーク・エッジケース (Hendrickson: Interruptions) + +| # | 観点 | 技法 | oracle | 検査方法 | +|---|------|------|--------|---------| +| C6.1 | offline で適切な表示 | Stress Testing | User | `context.set_offline(True)` | +| C6.2 | slow 3G (50KB/s, 400ms RTT) で操作可能 | Stress Testing | User | CDP `Network.emulateNetworkConditions` | +| C6.3 | 操作中の API abort → エラー表示 | Risk Testing | User | `page.route("**/api/**", lambda r: r.abort("failed"))` | +| C6.4 | リクエストの再送 (retry) | Reliability | History | abort 後にリトライボタンがあれば押す → 成功 | + +## 7. エラーハンドリング (`error` role 兼用) + +| # | 観点 | 技法 | oracle | 検査方法 | +|---|------|------|--------|---------| +| C7.1 | 404 で status 404 | Claims | Statutes (HTTP semantics) | 存在しない URL を踏む → status | +| C7.2 | 500 でユーザに stack trace を見せない | Risk Testing | Statutes (OWASP A09) | サーバ強制エラー (`?cause_500=1` 等) 後の表示 | +| C7.3 | 401 / 403 の差別化 | Claims | Claims | 未認証 → 401, 認可なし → 403 | +| C7.4 | 復帰導線 (home / search / login) | User Testing | User | エラーページからの脱出ボタン | +| C7.5 | サポート用相関 ID 表示 | Claims | Claims | エラー画面に traceId / requestId | +| C7.6 | エラーログに PII を出さない | Risk Testing | Statutes (GDPR) | サーバログ確認 (E2E では難。bug DB に注記) | + +## 8. console / pageerror / network 自動 FAIL + +各 testcase 実行中、以下が **1 件でも検出されたら無条件 FAIL**: + +| # | 検出 | 自動収集 | +|---|-----|---------| +| C8.1 | `pageerror` (uncaught exception) | runner が exception text を bug report に添付 | +| C8.2 | `console.error` レベル | runner が message を bug report に添付 | +| C8.3 | 5xx HTTP responses | trace network log に記録 | +| C8.4 | resource load failure (404 css/js/img) | console から検出 | + +例外: 既知の許容パターンは `config.yaml` の `tolerated_console_errors: [...]` に登録 (理由コメント必須). + +## 9. 観察してログのみ取る項目 (FAIL にしない) + +| # | 観点 | 理由 | +|---|------|------| +| C9.1 | 3rd party (analytics / chat widget) からのコンソール警告 | 自社制御外 | +| C9.2 | DevTools experimental warning | ブラウザ内部 | + +## 10. 追加すべきプロジェクト固有チェック + +pytest テスト関数の最後に `expect(...).not_to_contain_text(...)` を並べる (web-first): + +```python +# フレームワーク fatal +expect(page.locator("body")).not_to_contain_text("Fatal error") +# ローカライズされた 404 表示 +expect(page.locator("body")).not_to_contain_text("Page not found") +``` + +ページ全体に対する正規表現マッチではなく、locator + 部分一致で判定する。 +`Warning:` / `Notice:` のような先頭領域限定の判定が必要なら +`locator: { css: "main, .page-top" }` で範囲を絞る。 + +## 参考文献 + +- W3C, "WCAG 2.2", https://w3c.github.io/wcag/requirements/22/ +- web.dev Web Vitals, https://web.dev/articles/vitals +- OWASP Top 10:2025, https://owasp.org/Top10/2025/ +- OWASP WSTG, https://owasp.org/www-project-web-security-testing-guide/ +- Hendrickson Cheat Sheet (Interruptions), https://www.ministryoftesting.com/articles/test-heuristics-cheat-sheet diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-dashboard.md b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-dashboard.md new file mode 100644 index 00000000..f385ebb3 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-dashboard.md @@ -0,0 +1,124 @@ +# Checklist: `dashboard` — ダッシュボード + +## 適用条件 +KPI カード + 複数チャート + 期間 / dimension フィルタ + drill-down リンク + エクスポート. + +代表 URL: `/dashboard`, `/analytics`, `/reports`, `/admin`. + +## 必須テスト観点 + +### DB1: API レスポンスとチャート表示の数値一致 `[Product / Claims]` +- 各 widget の API 完了後にチャート要素を読み取る +- API の `total` と画面表示の合計が一致 +- 各 widget の合計を足し合わせると親 KPI と一致 (内部一貫性) +- `expect_response("**/api/widgets/...")` で取得 → DOM 値と比較 + +### DB2: 期間フィルタの全 widget 同期 `[State / Product]` +- 期間 (今日 / 今週 / 今月 / カスタム範囲) を切替 +- すべての widget が新期間で再計算 +- ロード中の skeleton 表示 + 完了通知 + +### DB3: Count 三値 (期間別) `[EP / Hendrickson Count]` +- 0 件期間 (新サービス / 古い期間) +- 1 件期間 (境界) +- 大量データ期間 (>10 万行) +各で widget が崩壊しない. + +### DB4: タイムゾーン `[Domain / Statutes]` +- UTC / ユーザローカル / サーバローカル のうち、どれが基準か仕様で定義 +- 期間境界 (例: 月初 0:00 JST) で正しく区切る +- DST 切替日 (米国 3 月 2 週日) の挙動 +- 日付表示は `Intl.DateTimeFormat` で locale 別 + +### DB5: drill-down `[Product / User]` +- KPI カードクリック → 詳細画面 (or 詳細表) +- 詳細の数値が KPI と一致 +- 戻るリンクで dashboard 状態 (期間 / フィルタ) 復元 + +### DB6: キャッシュ TTL と「リアルタイム」表記 `[Claims / Reliability]` +- 「リアルタイム」と表記しているなら 5 秒以内更新 +- 「直近 1 時間」なら 1 時間以内更新 +- データ更新時刻の表示 (`as of 2026-04-25 14:32:01 JST`) +- 自動リフレッシュ (5 分間隔等) があれば確認 + +### DB7: a11y (チャート) `[Statutes / User]` +- グラフの代替表示 (テーブル切替) があるか +- 色のみで情報を伝えない (パターン / ラベル併用) +- スクリーンリーダーが KPI 値を読み上げる (`aria-label` + `role="img"` + `aria-describedby`) +- キーボード操作で全 widget へ到達 + +### DB8: 色覚多様性 `[Statutes / User]` +- Protanopia / Deuteranopia / Tritanopia シミュレーション (`page.emulate_media` 拡張 or プラグイン) +- 凡例が色のみに依存しない (パターン or テキスト) +- コントラスト比 ≥ 4.5:1 + +### DB9: テーブル代替表示 `[Statutes / User]` +- チャート → テーブル切替ボタン +- テーブルにアクセスできる (a11y) → スクリーンリーダー対応 + +### DB10: エクスポート `[Functional / Product]` +- CSV / Excel / PDF / PNG 各形式 +- 内容が画面と一致 (フィルタ条件反映) +- 大量データの DL タイムアウト +- 文字化け (UTF-8 BOM) + +### DB11: 並行ロード時の race `[Multi-user / Reliability]` +- フィルタ A 即フィルタ B (ロード完了前) +- 結果が「最後に指定した B」になる (race 解決) +- 古いリクエストのレスポンスで上書きされない (`AbortController` or リクエスト ID) + +### DB12: 数値の単位 / 桁区切り / 通貨 i18n `[Domain / Statutes]` +- `1,234.56` (en-US) vs `1.234,56` (de-DE) vs `1 234,56` (fr-FR) +- 通貨記号: `$1,234` / `1.234 €` / `¥1,234` +- 大きい数値: `1.2K`, `1.5M`, `3.4B` の単位丸め + +### DB13: フィルタ組合せ (Pairwise) `[Pairwise]` +期間 × dimension × フィルタ × チャート種が ≥ 3 次元なら All-Pairs. + +### DB14: 認可 (見せていい widget) `[Risk / Statutes]` +- 一般ユーザに admin 専用 widget が表示されない +- API 直接 `GET /api/admin-metrics` → 403 +- データの粒度制御 (組織単位 / 部署単位 / 個人単位) + +### DB15: empty state per widget `[EP / User]` +- 各 widget が独立して空状態 UI +- 「データがありません」+ CTA (期間変更 / データ追加) + +## 適用すべきテスト技法 + +| 技法 | 適用箇所 | +|------|---------| +| Domain Testing | DB4 (TZ), DB12 (i18n) | +| State Transition | DB2 (フィルタ遷移) | +| Pairwise | DB13 | +| Claims | DB1, DB6, DB10 | +| Multi-user | DB11 | +| Risk | DB14 | +| EP / Count | DB3, DB15 | + +## Playwright 実装パターン + +```python +# DB1: API と DOM の数値一致 +with page.expect_response("**/api/dashboard/revenue") as info: + page.goto("/dashboard") +api_total = info.value.json()["total"] +dom_total = float(page.get_by_role("region", name="売上").get_by_test_id("total").inner_text().replace(",", "")) +assert api_total == dom_total, f"API={api_total} DOM={dom_total}" + +# DB11: race 解決 +page.get_by_role("button", name="今週").click() +page.get_by_role("button", name="今月").click() # 即時切替 +# 期待: 「今月」のデータで描画される +expect(page.get_by_test_id("active-period")).to_have_text("今月") +``` + +## 共通チェックリスト併用 + +`checklist-common.md` の C1 (a11y, **C1.7 色のみ**), C2 (perf, dashboard 重い), C8 (console error). + +## 参考文献 + +- UK Gov Analysis Function "Dashboard testing", https://analysisfunction.civilservice.gov.uk/policy-store/data-visualisation-testing-dashboards-for-design-and-accessibility/ +- A11Y Collective "Accessible charts" +- W3C ARIA APG "Live Region" (auto refresh) diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-edit.md b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-edit.md new file mode 100644 index 00000000..d4b7666a --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-edit.md @@ -0,0 +1,156 @@ +# Checklist: `edit` — 編集ページ + +## 適用条件 +既存値プリフィル + Save / Cancel ボタン + URL に id 含む. +dirty 検知 (`beforeunload` 確認) + CSRF token + バリデーション. + +代表 URL: `/items/123/edit`, `/items?Cmd=Edit&ItemID=123`. + +## 必須テスト観点 + +### ED1: プリフィル値の正確性 `[Product / History]` +- 直近 GET で取得した値と完全一致 +- 日付 TZ / 通貨単位 / null の扱い +- リッチテキスト: HTML エスケープが適切 (`<` が `<` で表示されない、ただし保存されている) + +### ED2: 各フィールドの境界値 (BVA) `[BVA / Claims]` +仕様で定義された各フィールドの長さ / 値範囲を全境界網羅。 +- 文字数: `min-1` / `min` / `min+1` / `max-1` / `max` / `max+1` +- 数値: `min-1` / `min` / `0` / `max-1` / `max` / `max+1` / `-1` +- 日付: 過去 / 現在 / 未来 / 閏年 2/29 / DST / 範囲外 + +例: パスワード長 8〜64 + +```yaml +- name: パスワード境界 + technique: BVA + oracle: Claims (パスワードポリシー仕様 v1.2) + inputs: + - "1234567" # 7 文字 (min-1) → 失敗期待 + - "12345678" # 8 文字 (min) → 成功期待 + - "abcdefg9" # 8 文字 (min, パターン違い) → 成功 + - "a"*63 # 63 文字 (max-1) + - "a"*64 # 64 文字 (max) → 成功 + - "a"*65 # 65 文字 (max+1) → 失敗 +``` + +### ED3: Goldilocks (Hendrickson) `[Domain / User]` +短すぎ・長すぎ・適切値. +- 名前 1 文字 / 100 文字 / 適切 +- 説明 0 行 / 1 万行 / 適切 + +### ED4: 必須フィールド未入力 `[Decision Table / Claims]` +- すべての必須フィールドを空で submit → 422 + フィールド単位エラーメッセージ +- 1 個ずつ空にして submit → 当該フィールドのみエラー +- エラーメッセージのテキスト + a11y (`aria-invalid="true"` + `aria-describedby`) + +### ED5: クライアント / サーバ validation 一致 `[Product / Claims]` +- JS validation を bypass (DevTools で `disabled` 削除 / `formnovalidate` 追加) +- サーバが同じバリデーションを行い 422 +- エラーメッセージのテキストが一致 + +### ED6: 特殊文字の保持 `[Domain / Product]` +- 絵文字 (4-byte UTF-8: `🎉`) +- 結合文字 (`が` = 単独 vs `が` = `か` + 濁点) +- 全半角混在 (`ABCabc123123`) +- ヌル文字 `\x00` (拒否されるべき) +- BiDi 制御 (`‮` Right-to-Left Override) +- ゼロ幅 (`​` Zero-Width Space) +- HTML 特殊文字 (`<>&"'`) +- 改行 (`\r\n` / `\n` / `\r`) + +すべて入力 → 保存 → 再表示で同一の文字列 (lossless) または明示的拒否. + +### ED7: Optimistic locking / 競合 `[Multi-user / Reliability]` +- ユーザ A が編集中、B が同じレコードを更新 +- A が保存しようとする → 409 Conflict + 「他のユーザが更新しました」 +- 強制上書き or 取り直し選択肢 + +### ED8: CSRF token `[Risk / Statutes]` +- フォーム送信時に CSRF token (`<input type="hidden" name="_token">`) が含まれる +- token を削除 / 改ざんして submit → 403 +- token は session ごと / 1 時間ごとに更新 + +### ED9: 未保存変更でページ離脱 `[State / User]` +- 1 文字編集 → 別ページへ navigate (リンククリック / `window.location`) +- `beforeunload` 確認ダイアログ表示 +- 「はい」で離脱、「いいえ」で残留 +- 保存後の離脱では確認なし + +### ED10: キャンセル / 保存ボタン挙動 `[State / User]` +- キャンセル → 確認なしで一覧 (or 詳細) へ +- 保存 → 詳細へ + 成功通知 / 同画面リロード + 成功通知 +- 保存中は「保存中...」表示 + ボタン disable (二重 submit 防止) + +### ED11: ネットワーク中断 (Interruptions) `[Risk / Reliability]` +- 保存ボタン押下 → 即 offline (`context.set_offline(True)`) +- エラー表示 + リトライボタン +- offline 解除後、再送信で成功 + +### ED12: ファイルアップロード `[Domain / Statutes]` +- 拡張子: 許可された拡張子のみ +- MIME type: 拡張子と内容の整合 (拡張子偽装検査) +- サイズ上限: max-1 / max / max+1 +- 同名: 上書き / リネーム / 拒否 +- 0 byte / 巨大ファイル +- ウイルススキャン (EICAR test file が拒否されるか — 仕様による) + +### ED13: 値が変わっていない状態の保存 (no-op) `[State / Reliability]` +- プリフィル値のまま「保存」押下 +- 期待: 200 OK or 304 Not Modified +- audit log には記録 / されない (仕様による) + +### ED14: 削除ボタン (edit から削除) `[State / Functional]` +- 削除確認 dialog +- 削除 → 一覧へ +- アンドゥの猶予 (5 秒) があれば動作確認 + +## 適用すべきテスト技法 + +| 技法 | 適用箇所 | +|------|---------| +| BVA | ED2 (境界値) | +| EP | ED6 (文字種分割) | +| Decision Table | ED4 (必須組合せ), ED5 (validation 状態) | +| State Transition | ED9 (clean→dirty→saving→saved/error) | +| Domain Testing | ED12 (ファイル種別) | +| Multi-user | ED7 | +| Risk Testing | ED8 (CSRF), ED11 (interruption) | + +## Playwright 実装パターン + +```python +# プリフィル値の検証 +expect(page.get_by_label("商品名")).to_have_value("既存の商品") + +# 境界値テスト (parametrize) +@pytest.mark.parametrize("password,expected", [ + ("1234567", "短すぎ"), # min-1 + ("12345678", None), # min OK + ("a"*64, None), # max OK + ("a"*65, "長すぎ"), # max+1 +]) +def test_password_boundary(page, password, expected): + page.get_by_label("パスワード").fill(password) + page.get_by_role("button", name="保存").click() + if expected: + expect(page.get_by_role("alert")).to_contain_text(expected) + else: + expect(page).to_have_url(re.compile(r"/items/\d+$")) + +# CSRF token 削除攻撃 +page.evaluate("document.querySelector('input[name=_token]').remove()") +page.get_by_role("button", name="保存").click() +# 期待: 403 表示 +``` + +## 共通チェックリスト併用 + +`checklist-common.md` の C1 (a11y label / aria-invalid), C3 (CSRF), C6 (offline), C8 (console error). + +## 参考文献 + +- ISTQB CTFL 4.2.1 (BVA), 4.2.2 (Decision Table) +- OWASP CSRF Prevention Cheat Sheet +- MDN Client-side form validation +- Hendrickson "Boundaries" / "Goldilocks" / "Constraints" diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-form.md b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-form.md new file mode 100644 index 00000000..56357111 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-form.md @@ -0,0 +1,160 @@ +# Checklist: `form` — 申込フォーム (複数ステップ) + +## 適用条件 +進捗インジ (Step 1/N) + 戻る/次へ + 確認画面 + 送信 + 完了画面. +入力分岐がある (国別 / 法人個人 / オプション). + +代表 URL: `/contact`, `/signup`, `/apply`, `/subscribe`. + +> ⚠️ **重要**: form の入力分岐はコードを読んで Decision Table を作る対象。 +> 経験で書かない。フォーム実装コード (HTML / JS / サーバ側 validator) を読んで分岐表を +> 自前で作り、その後 ``@pytest.mark.parametrize`` で各 row を test 関数として展開する。 +> 4 軸以上は Python 標準の ``itertools`` + 簡易 Pairwise で削減した上で +> ``parametrize`` する運用を推奨。 + +## 必須テスト観点 + +### FM1: 各ステップの直接 URL アクセス `[State / User]` +- `/form/step1` 直接 → step1 表示 (新規) +- `/form/step2` 直接 (step1 未完了) → step1 にリダイレクト +- `/form/confirm` 直接 (step3 未完了) → step1 にリダイレクト +- `/form/complete` 直接 (送信前) → step1 にリダイレクト + +### FM2: ブラウザ「戻る」ボタン `[State / User]` +- step3 で「戻る」 → step2 表示、入力値保持 +- step2 で「戻る」 → step1 表示、入力値保持 +- step1 で「戻る」 → 元のページ (フォーム外) +- 完了画面で「戻る」 → step3 ではなく完了画面のまま (キャッシュ汚染防止) + +### FM3: 入力分岐 (Decision Table) `[Decision Table 必須]` +**コードを読んで全分岐を網羅**。例: + +``` +| 国 | 顧客種別 | 配送 | 期待挙動 | +|----|---------|------|---------| +| JP | 個人 | 標準 | 郵便番号必須, 配送料 500円 | +| JP | 個人 | 速達 | 郵便番号必須, 配送料 1500円 | +| JP | 法人 | 標準 | 会社名必須, 請求書住所必須, 送料 0円 | +| US | 個人 | 標準 | ZIP必須, 州必須, 送料 海外要見積 | +| US | 法人 | 標準 | 会社名必須, EIN 必須, 送料 海外要見積 | +| EU | 個人 | 標準 | VAT 任意, GDPR 同意必須 | +``` + +各行を別 testcase として作成。ルール ≥ 7 なら Classification Tree + Pairwise で削減. + +### FM4: 確認画面 ↔ 編集ステップ往復 `[Product / User]` +- step3 で「step1 を編集」リンク → step1 表示、入力値保持 +- 編集後「次へ」を順次踏む or 「確認画面に戻る」ジャンプリンク +- 編集後の確認画面に編集内容が反映 + +### FM5: 二重送信防止 `[Risk / Reliability]` +- 「送信」ボタンを高速 2 回クリック +- ボタンが disable される (`expect(button).to_be_disabled()`) +- サーバ側 nonce / idempotency key で 2 件目を拒否 +- 結果として登録は 1 件のみ + +### FM6: 完了画面でリロード → 二重登録しない `[State / Reliability]` +- 完了画面で F5 / Ctrl+R +- 期待: 完了画面のまま (POST/Redirect/GET パターン) +- 二重登録されない + +### FM7: セッションタイムアウト中の継続 `[Interruptions / Reliability]` +- step2 で 30 分以上放置 (セッション timeout) +- 次の操作で「セッション切れ」案内 + 中間データ保持 (中間保存があれば) +- 復活フロー: ログイン or 再開リンク + +### FM8: 必須項目を JS で除去 `[Risk / Statutes]` +- DevTools で `required` 削除 / `disabled` 解除 +- submit → サーバ側 422 (二重防御) + +### FM9: step skip 攻撃 `[Risk / State]` +- step1 完了後、URL を `/form/complete` に直接書き換え +- 期待: step1 (or 必要な未完ステップ) に戻される +- 内部状態 (session) で進捗管理されているか + +### FM10: PII (個人情報) を URL / log に出力しない `[Statutes / GDPR]` +- ステップ間遷移は POST or session storage (URL クエリで送らない) +- ブラウザ履歴 / referrer に氏名 / メール / 電話番号が残らない +- サーバアクセスログにも PII が出ない (E2E では難 → bug DB 注記) + +### FM11: Pairwise (オプション組合せ) `[Pairwise / Domain]` +オプション 4 軸以上は All-Pairs: + +``` +factor1: { 国: [JP, US, EU] } +factor2: { 配送: [標準, 速達, 店舗] } +factor3: { 支払い: [カード, 銀振, PayPal] } +factor4: { 顧客: [個人, 法人] } +``` +全 54 通り → All-Pairs で ~12 通り。``@pytest.mark.parametrize`` で 12 行展開して書く. + +### FM12: ファイル添付 `[Domain / Statutes]` +- 単一ファイル / 複数ファイル / 添付ゼロ +- 合計サイズ上限境界 (max-1 / max / max+1) +- 拡張子フィルタ (許可 / 拒否) +- 同名ファイルの上書き or 拒否 + +### FM13: 完了通知メール `[Functional / Statutes]` +- メール送信成功 +- 件名 / 本文の i18n +- HTML escape (XSS 防止) +- 添付 PDF があれば内容検証 + +### FM14: 「登録済みメール」エラー `[Risk / Statutes]` +- 既存メールアドレスで signup → エラー表示 +- ⚠️ ユーザ列挙攻撃に使えない曖昧な文言: 「登録できません」(NIST 800-63B 推奨) +- 確認メール送信方式 (登録の有無を答えない方針) も検討 + +### FM15: a11y (form 構造) `[Statutes / Automatic]` +- `<fieldset><legend>` でグルーピング +- `aria-invalid="true"` + `aria-describedby` でエラー紐付け +- フォーカス順序が論理的 (Tab で step1 → 次へボタン → step2 ...) +- スクリーンリーダーで step 進捗を読み上げる (`aria-live` or `role="status"`) + +## 適用すべきテスト技法 + +| 技法 | 適用箇所 | +|------|---------| +| Decision Table | **FM3 必須** | +| Classification Tree + Pairwise | FM11 | +| State Transition | FM1, FM2 (step1→2→3→complete) | +| Risk Testing | FM5, FM8, FM9, FM10 | +| BVA | FM12 (ファイルサイズ) | +| Domain Testing | FM12 (ファイル種別) | +| Use Case | フォーム全体 (アクター × ゴール) | +| Claims | FM4, FM13, FM14 | + +## Playwright 実装パターン + +```python +# step skip 攻撃 +page.goto("/form/step1") +page.get_by_label("メール").fill("test@example.com") +page.get_by_role("button", name="次へ").click() +# step2 で URL 直接書き換え +page.goto("/form/complete") +expect(page).to_have_url(re.compile(r"/form/step\d")) # step に戻される + +# 二重送信防止 +button = page.get_by_role("button", name="送信") +# 2 連打 +button.click() +expect(button).to_be_disabled() # 即 disable + +# 完了画面リロード +page.goto("/form/complete") +page.reload() +expect(page).to_have_url(re.compile(r"/form/complete")) # まだ完了画面 +``` + +## 共通チェックリスト併用 + +`checklist-common.md` の C1 (a11y form), C3 (CSRF / XSS), C6 (interruption), C7 (404 step). + +## 参考文献 + +- ISTQB CTFL 4.2.2 (Decision Table) +- Grimm/Grochtmann (1993), "Classification Tree Method" +- OWASP ASVS V2.1 (Authentication, including signup) +- NIST SP 800-63B (パスワード / ユーザ列挙) +- W3C ARIA APG "Form Pattern" diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-item.md b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-item.md new file mode 100644 index 00000000..8a7e4caa --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-item.md @@ -0,0 +1,120 @@ +# Checklist: `item` — 詳細ページ + +## 適用条件 +URL に `/{resource}/{id}` パターン。単一エンティティ表示。 +編集 / 削除 / 一覧へ戻る / 関連リンク / breadcrumbs. + +代表 URL: `/items/123`, `/users/u_abc`, `/orders/o_xyz`. + +## 必須テスト観点 + +### IT1: 存在しない id `[Domain / Claims]` +- `/items/999999` (大きい数値) +- `/items/0` / `/items/-1` (境界値) +- `/items/abc` (型不一致) +- `/items/<script>alert(1)</script>` (injection) +- 期待: 404 ステータス + 専用 404 ページ (status 200 で擬似 404 にしない) +- `expect(page.locator("h1")).to_contain_text("見つかりません")` + +### IT2: 削除済み id `[State / Claims]` +- 一度削除した id を再アクセス → 410 Gone or 404 (仕様準拠) +- 「削除されました」メッセージ + 復元 / 一覧導線 + +### IT3: 他ユーザ所有の id (IDOR) `[Risk / Statutes]` +**Severity S1 候補**. OWASP Top 10 A01. +- alice でログイン → `/items/789` (= bob 所有) を直接 URL +- 期待: 403 Forbidden / または 404 (情報漏洩防止のため 404 で隠す方針もあり、仕様確認) +- 編集 / 削除ボタンは表示されない or 押下で 403 + +### IT4: 形式不正 id `[Domain / Statutes]` +- 大きすぎる id (Int max+1 / UUID 不正) +- SQL injection 試験: `1; DROP TABLE items;--` +- path traversal: `../../etc/passwd` +- いずれも 400 / 404 で早期返却 + +### IT5: 戻るリンクの状態復元 `[State / User]` +- 一覧 → 詳細 → 「戻る」リンク +- 一覧の フィルタ / ソート / ページ番号が復元される +- ブラウザ「戻る」ボタンでも同じ挙動 + +### IT6: 編集 / 削除権限の制御 `[Risk / Statutes]` +- read-only ユーザ: 編集 / 削除ボタンが**非表示** (`expect(...).to_be_hidden()`) +- 自分のデータ: 編集 / 削除ボタン表示 +- 直接 `/items/123/edit` URL を踏ませた場合: 403 Forbidden +- API 直接 `PATCH /items/123` も同様に 403 + +### IT7: 関連リソース (画像 / PDF / 動画) `[Functional / Reliability]` +- リソース URL の死活 (200 / 404) +- 大ファイルの遅延ロード (lazy) +- 画像 fallback (broken image icon ではなく適切な代替) +- PDF / 動画は `<iframe>` or `<object>` で表示 → MIME type 検証 + +### IT8: 表示値の型保持 `[Domain / Product]` +- 日付: ユーザ TZ で正しく表示 (UTC ↔ JST 変換ミスがないか) +- 数値: 桁区切り (1,234 / 1.234 / 1 234) の locale 切替 +- 通貨記号: ¥ / $ / € の位置 (前後) +- 数量単位: kg / L / mm の正しい表示 + +### IT9: SEO / ブックマーク `[Claims / Statutes]` +- `<title>` がエンティティ名を含む (`{title} — Site Name`) +- `<link rel="canonical" href="/items/123">` (重複防止) +- `<meta property="og:title" og:image>` で SNS シェア対応 +- 構造化データ (Product / Article / etc.) `<script type="application/ld+json">` + +### IT10: 削除後の挙動 `[State / Functional]` +- 詳細から削除 → 一覧へ自動遷移 +- 一覧から該当行が消える +- 削除直後にブラウザ「戻る」 → 「削除済み」メッセージ (キャッシュ汚染防止) + +### IT11: 編集後の表示反映 `[State / Product]` +- 編集して保存 → 詳細に戻る (or 同画面で更新) +- 表示値が編集内容と一致 +- 一覧に戻る → 一覧の表示も同期 + +### IT12: 共有 URL / クリップボード `[Functional / User]` +- 「URL コピー」ボタンがあれば押下 → クリップボード検証 (`page.evaluate("navigator.clipboard.readText()")`) +- 共有された URL を別ユーザが踏むと適切な認可 + +### IT13: 関連エンティティへのナビ `[Product / User]` +- 「同じカテゴリの他商品」「関連記事」リンクの整合性 +- リンク先 id が実在 +- 自エンティティへの自己参照リンクがない + +## 適用すべきテスト技法 + +| 技法 | 適用箇所 | +|------|---------| +| Domain Testing | IT1, IT4 (id partition) | +| Risk Testing | IT3, IT6 (IDOR / 認可) | +| State Transition | IT2 (active → deleted), IT10, IT11 | +| Claims Testing | IT9 (SEO 主張), IT8 (型保持仕様) | +| User Testing | IT5 (戻る期待), IT12 | + +## Playwright 実装パターン + +```python +# IDOR テスト (alice で bob のデータ id にアクセス) +page.goto(f"/items/{bob_owned_item_id}") +expect(page.locator("body")).to_contain_text("403") # or 404 +expect(page.get_by_role("button", name="編集")).to_be_hidden() + +# 直接編集 URL +response = page.goto(f"/items/{bob_owned_item_id}/edit") +assert response.status == 403 + +# API 直接 +api = playwright.request.new_context(base_url=base_url, storage_state="alice.json") +resp = api.patch(f"/api/items/{bob_owned_item_id}", data={"name": "hacked"}) +assert resp.status == 403 +``` + +## 共通チェックリスト併用 + +`checklist-common.md` の C1 (a11y, h1 単一), C3 (sec, IDOR / XSS sanitize), C7 (404), C8 (console). + +## 参考文献 + +- OWASP WSTG IDOR, https://owasp.org/www-project-web-security-testing-guide/ +- OWASP ASVS V8.2.5 Authorization +- RFC 9110 HTTP Semantics +- Schema.org Product / Article diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-list.md b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-list.md new file mode 100644 index 00000000..c6927309 Binary files /dev/null and b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-list.md differ diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-lp.md b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-lp.md new file mode 100644 index 00000000..44f7d046 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-lp.md @@ -0,0 +1,97 @@ +# Checklist: `lp` — Landing Page + +## 適用条件 +ドメインルート / `/lp/*` `/campaign/*` `/about` 等、外部到達ページ。 +ナビ + ヒーロー + 説明セクション + CTA。SEO meta tag が充実。 +ページ深度 1。機能リンクは少数 (1〜3)。 + +## 必須テスト観点 + +### LP1: ヒーロー領域レンダリング `[Claims / Claims]` +- LCP < 2.5s (`scripts/check_cwv.py --metric lcp`) +- ヒーロー画像/動画の poster が空白でない +- WebP / AVIF fallback が動作 + +### LP2: CTA 配置と動作 `[Claims / Purpose]` +- above-the-fold / mid-scroll / page-end の 3 箇所に CTA があるか +- CTA: `get_by_role("button", name=...)` または `get_by_role("link", name=...)` で取得 +- `expect(cta).to_be_visible()` + `cta.click()` → 期待遷移 +- コントラスト比 ≥ 4.5:1 (axe-core が検出) + +### LP3: ナビゲーション全リンク死活 `[Claims / Statutes]` +- `page.get_by_role("navigation").get_by_role("link").all()` で全リンク列挙 +- 各 href を curl で叩き status ≤ 399 +- 内部リンクは Playwright で踏み、ページ崩壊しないか +- 外部リンクは `target="_blank" rel="noopener noreferrer"` を持つ (axe `target-blank-security`) + +### LP4: SEO meta tag 充足 `[Claims / Statutes]` +- `<title>` (≤ 60 字) +- `<meta name="description">` (≤ 160 字) +- `<meta property="og:title" / og:description / og:image / og:url>` +- `<link rel="canonical">` +- 構造化データ (`<script type="application/ld+json">`) の妥当性 (Google Rich Results Test 相当) + +### LP5: レスポンシブ `[Compatibility / Image]` +- viewport: 320 / 768 / 1024 / 1920 で visual regression +- `expect(page).to_have_screenshot("lp-{viewport}.png", mask=[広告/動的領域])` +- mobile viewport meta tag (`<meta name="viewport" content="width=device-width">`) + +### LP6: アクセシビリティ強化 `[Automatic / Statutes]` +- axe-core `wcag2aa + wcag22aa` violations = 0 +- 見出し階層: h1 単一 + h2 連続 + h3 順次 +- ランドマーク: `<header><nav><main><footer>` 構造 +- 全 image に有意な alt または `alt=""` (装飾) + +### LP7: Cookie 同意バナー (GDPR/CCPA) `[Statutes / Claims]` +- 初回訪問でバナー表示 +- 「同意」「拒否」両方の選択肢 +- 拒否時に tracking script (gtag / facebook pixel) がロードされないこと +- network log で確認 (`page.on("request", ...)`) + +### LP8: フォーム CTA (お問い合わせ / サインアップ) `[Claims / Purpose]` +- フォーム遷移先が 200 OK +- 送信成功画面の表示 +- 詳細は `form` checklist に従う (兼ね適用) + +### LP9: 第三者 widget (chat / video / map) の遅延ロード `[Performance / Reliability]` +- LP 初回 LCP に widget が影響しないか +- widget のロード失敗時に LP 全体が壊れないか (`route` で widget host を `abort`) + +### LP10: スクロール時の CLS `[Claims / Image]` +- 画像 / iframe / web font の遅延差し替えで layout shift しない (`width/height` 属性 + aspect-ratio CSS) +- CLS < 0.1 (`scripts/check_cwv.py --metric cls`) + +### LP11: 内部 anchor link `[Functional / Product]` +- `#section1` の click で smooth scroll +- focus が anchor 先に移動する (a11y) + +### LP12: print stylesheet `[Statutes / 限定]` +- `page.emulate_media(media="print")` で重要セクションが見える +- 装飾だけが消える + +## 適用すべきテスト技法 + +| 技法 | 適用箇所 | +|------|---------| +| Claims Testing | LP1, LP2, LP4, LP10 (主張の検証) | +| Domain Testing | LP5 (viewport partition) | +| Automatic Checking | LP6 (axe-core) | +| Risk Testing | LP3 (外部リンク nofollow / opener) | +| Compatibility | LP5 | + +## 自動化境界 + +| 自動化容易 | 手動寄り | +|-----------|---------| +| LP1 (LCP), LP3 (リンク死活), LP4 (meta), LP6 (axe), LP10 (CLS) | LP2 (CTA の感情訴求 — Charisma), LP5 (レイアウト主観評価) | + +## 共通チェックリスト併用 + +`checklist-common.md` の C1 (a11y), C2 (perf), C5 (cross-browser), C7 (404 ページ) を全項目走査。 + +## 参考文献 + +- web.dev "Optimize LCP", https://web.dev/articles/optimize-lcp +- Unbounce, "CTA Placement" +- Schema.org, "Structured Data" +- WCAG 2.2 (4.1.2 Name, Role, Value) diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-modal-wizard.md b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-modal-wizard.md new file mode 100644 index 00000000..cac3bbbc --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-modal-wizard.md @@ -0,0 +1,161 @@ +# Checklist: `modal` / `wizard` — モーダル / ウィザード + +## 適用条件 +- **modal**: `role="dialog"` + `aria-modal="true"` + 背景 overlay + close ボタン +- **wizard**: 内部に step (進捗インジ) + 戻る/次へ + 保存 / 完了 + +代表: 削除確認 dialog, onboarding wizard, 設定 modal, login modal. + +W3C WAI-ARIA APG "Dialog (Modal) Pattern" 準拠が前提. + +## 必須テスト観点 (modal) + +### MD1: open 時のフォーカス移動 `[Statutes / Automatic]` +- ダイアログ open → 最初の操作可能要素 or `aria-labelledby` 要素にフォーカス +- 主要 CTA (例: "削除を確定") にフォーカスを置く実装も推奨 + +### MD2: Focus trap `[Statutes / User]` +- Tab で dialog 内をループ +- Shift+Tab で逆順ループ +- dialog 外の要素にフォーカスが行かない +- 背景の `inert` 属性 or `aria-hidden="true"` で背景を不活性化 + +### MD3: Esc キーで閉じる `[User / Statutes]` +- Esc 押下 → close +- ただし破壊的 dialog (削除確認等) は Esc を無視 (仕様による) + +### MD4: close 後のフォーカス復帰 `[State / User]` +- close → トリガー要素 (ダイアログを開いたボタン) にフォーカス復帰 +- スクリーンリーダーで「閉じた」アナウンス (任意) + +### MD5: オーバーレイクリックで閉じる `[User]` +- 背景 overlay クリック → close (仕様による) +- 重要な dialog は overlay クリックを無視 + +### MD6: 背景スクロールロック `[User]` +- mobile で重要: dialog open 中、`<body>` に `overflow: hidden` +- 背景がスクロールしない +- dialog 内部は独立してスクロール可能 + +### MD7: スクリーンリーダー読み上げ `[Statutes / Automatic]` +- `role="dialog"` + `aria-modal="true"` +- `aria-labelledby` で title 紐付け +- `aria-describedby` で本文紐付け + +### MD8: ネスト dialog `[State / User]` +- modal 内に確認 modal を開く +- 親 modal の状態保持 +- 子 modal close → 親 modal にフォーカス復帰 + +### MD9: アニメーション `[Statutes / User]` +- `prefers-reduced-motion` 環境でアニメ削減 +- `page.emulate_media(reduced_motion="reduce")` で確認 + +### MD10: モバイルでフルスクリーン化 `[Compatibility / User]` +- mobile viewport (≤ 480px) で dialog がフルスクリーン +- 閉じるボタンが指で押しやすい位置 (右上 / 下部 sticky) + +## 必須テスト観点 (wizard) + +### WZ1: step 進捗表示 `[Statutes / User]` +- 「Step 2 of 5」明示 +- `aria-current="step"` で現在 step +- スクリーンリーダーが進捗読み上げ + +### WZ2: 前後遷移と入力保持 `[State / User]` +- step2 → 「戻る」 → step1 (入力値保持) +- step2 で入力 → 「次へ」 → step3 → 「戻る」 → step2 (入力保持) +- 全 step を一巡 → 確認画面 (入力サマリ表示) + +### WZ3: 完了 step `[State / Functional]` +- 完了画面で「最初に戻る」 → 全クリア + step1 +- 「ダッシュボードへ」 → 別画面 (wizard 終了) +- 完了画面リロードで二重登録しない (POST/Redirect/GET) + +### WZ4: 中断と再開 `[Interruptions / Reliability]` +- step3 でブラウザ閉じる → 再アクセス時の挙動 (再開 / リセット) +- 中間保存があれば: 「続きから再開」「最初から」選択肢 + +### WZ5: skip / optional step `[Decision Table / State]` +- 任意 step を skip → 次 step へ +- skip した内容が確認画面に「未入力」と表示 +- skip 不可な必須 step を skip しようとすると拒否 + +### WZ6: 動的 step (条件分岐) `[Decision Table]` +**Decision Table 必須**. +- step1 で「個人」選択 → step2 = 個人情報, step3 確認 +- step1 で「法人」選択 → step2 = 会社情報, step3 = 担当者, step4 確認 +- 各分岐を別 testcase として網羅 + +### WZ7: a11y (form フィールド) `[Statutes / Automatic]` +- 各 step が独立した `<form>` (or fieldset) +- 各フィールドに label +- エラー表示は `aria-invalid` + `aria-describedby` +- 戻る / 次へボタンが `<button type="button"/"submit">` で意味的に正しい + +### WZ8: 確認画面の編集リンク `[State / User]` +- 各 section に「修正」リンク +- リンク先が該当 step +- 修正後は確認画面に戻る (or 順次踏み) + +### WZ9: 完了通知 `[Functional / i18n]` +- 完了メール / 通知の送信 +- 内容が wizard で入力した値と一致 + +### WZ10: 並行 wizard `[Multi-user / State]` +- 同じユーザが別タブで同じ wizard を進める +- どちらが優先されるか (last-write-wins / lock / merge) + +## 適用すべきテスト技法 + +| 技法 | 適用箇所 | +|------|---------| +| State Transition | MD1〜MD8 (open/close/focus), WZ2 (step1→2→3) | +| Decision Table | WZ5, WZ6 (動的 step) | +| Risk Testing | MD8 (ネスト), WZ4 (中断) | +| Compatibility | MD10 | +| Multi-user | WZ10 | + +## Playwright 実装パターン + +```python +# MD2: Focus trap +page.get_by_role("button", name="削除").click() # modal open +dialog = page.get_by_role("dialog") +expect(dialog).to_be_visible() + +# Tab で dialog 内をループ +focusable = dialog.locator("button, [href], input, [tabindex]:not([tabindex='-1'])").all() +for _ in range(len(focusable) + 2): # 1 周以上回す + page.keyboard.press("Tab") +focused = page.evaluate("document.activeElement.outerHTML") +# focused 要素が dialog 内であることを確認 +assert dialog.locator("*:focus").count() == 1 + +# MD3: Esc で close +page.keyboard.press("Escape") +expect(dialog).to_be_hidden() +# トリガーボタンにフォーカス復帰 +expect(page.get_by_role("button", name="削除")).to_be_focused() + +# MD7: aria attributes +expect(dialog).to_have_attribute("aria-modal", "true") +expect(dialog).to_have_attribute("aria-labelledby", re.compile(r".+")) + +# WZ2: 入力保持 +page.get_by_role("textbox", name="名前").fill("Alice") +page.get_by_role("button", name="次へ").click() +page.get_by_role("button", name="戻る").click() +expect(page.get_by_role("textbox", name="名前")).to_have_value("Alice") +``` + +## 共通チェックリスト併用 + +`checklist-common.md` の C1 (a11y, **C1.2 キーボード / C1.3 focus**), C5 (mobile viewport). + +## 参考文献 + +- W3C WAI-ARIA APG "Dialog (Modal) Pattern", https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ +- W3C WAI-ARIA APG "Wizard (multi-step form)" / `aria-current` 仕様 +- MDN `aria-modal`, `inert` +- WCAG 2.4.3 Focus Order, 2.1.2 No Keyboard Trap diff --git a/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-search.md b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-search.md new file mode 100644 index 00000000..c6e2d38a --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/docs/checklists/checklist-search.md @@ -0,0 +1,134 @@ +# Checklist: `search` — 検索ページ + +## 適用条件 +search box + 結果 list + ファセット + 件数表示 + ハイライト + サジェスト. + +代表 URL: `/search`, `/?q=...`, `/find`. + +兼ね role: 検索結果ページは `list` も兼ねるため、`list` checklist も併用. + +## 必須テスト観点 + +### SR1: zero results UX `[EP / User]` +**最重要**. 検索の 23%は 0 件 (UX 影響大). +- 「該当なし」メッセージ表示 +- 検索 box に入力値が残る (再修正用) +- 提案 (typo 補正 / 関連キーワード) +- フィルタリセットボタン +- 全件表示への導線 + +### SR2: クエリ injection `[Risk / Statutes]` +- SQL: `' OR '1'='1`, `1; DROP TABLE--`, `UNION SELECT ...` +- NoSQL: `{"$gt": ""}`, `{"$ne": null}` +- XSS: `<script>alert(1)</script>`, `<img src=x onerror=alert(1)>` +- Command injection: `; cat /etc/passwd` +- いずれもエラーや想定外結果を返さない、画面に sanitize された文字列として表示 + +### SR3: 全マッチ / 空クエリ `[Domain / Claims]` +- `?q=` (空) → 全件 or 検索無効化 +- `?q=*` (ワイルドカード) → 全件 or リテラル `*` 検索 +- `?q=%20` (スペースのみ) → 0 件 or 全件 (仕様確認) +- `?q=長すぎる文字列 (> 1000 文字)` → 切り詰め or 422 + +### SR4: 部分一致 / 完全一致 / fuzzy `[Claims / Product]` +仕様で定義された検索方式を逐一検証. +- 部分一致: `q=appl` で `apple`, `application` がヒット +- 完全一致 (`"...."` で囲む等): `q="apple pie"` で完全一致のみ +- fuzzy: `q=aple` で `apple` がヒット (typo tolerance) +- 前方一致: `q=app*` で `apple`, `application` (suffix なし) + +### SR5: 並び替え `[Domain / Claims]` +- relevance / 新着 / 人気 / 価格 (昇/降) +- 並び替え後の件数は変わらない +- 各並び順で先頭 10 件を取得 → 順序が仕様通り +- relevance 順位は「主張」(Claims) — 上位ほどキーワード一致度が高いか目視 + +### SR6: ファセット組合せ (Pairwise) `[Pairwise / Domain]` +ファセット ≥ 3 次元なら All-Pairs. +``` +ブランド × カテゴリ × 価格帯 × 在庫 = 全 N 通り → All-Pairs で ~M 通り +``` +各組合せで: +- 結果が必ず減少 (filter-monotone) +- 0 件 でも UI 崩壊しない +- ファセット解除で元件数 + +### SR7: ページネーション `[BVA / list 流用]` +list checklist LST2 を準用. +- out-of-range (`?page=99999`) で 0 件 + 適切 UI +- 検索条件 + ページ番号が URL に保持 + +### SR8: 同義語 / typo tolerance `[Claims / User]` +- 「シャツ」「しゃつ」「シャツ」(全角半角) で同じ結果 +- 「Shirt」「shirt」「SHIRT」(case insensitive) +- 設定された同義語辞書: 仕様書 SPEC-SEARCH-001 と一致 + +### SR9: 検索ログのプライバシー `[Statutes / GDPR]` +- 検索クエリに PII (氏名 / メール) が含まれる場合の扱い +- ログ保管期間 / 匿名化 / 第三者送信 (Google Analytics など) +- E2E では確認困難 → bug DB に注記 / セキュリティチームに確認依頼 + +### SR10: 結果と詳細の値一致 `[Product]` +- 検索結果に表示される値 (タイトル / 価格 / 在庫) と詳細ページの値が一致 +- インデックス遅延 (検索 vs 実 DB) の許容範囲 + +### SR11: ハイライト `[Functional / User]` +- 検索キーワードが結果中で `<mark>` ハイライトされる +- HTML エスケープ (XSS 防止) +- 大文字小文字を問わず + +### SR12: サジェスト / オートコンプリート `[Functional / User]` +- `q=appl` の途中で候補表示 +- 候補クリックで検索実行 (or 入力欄に補完) +- キーボード操作 (↑↓Enter) 対応 (a11y) +- `aria-autocomplete="list"` + `aria-controls` + +### SR13: 高度検索 (advanced search) `[Functional / User]` +- 複数フィールド: タイトル + カテゴリ + 期間 +- AND / OR / NOT 演算子 +- フィールド指定 (`title:apple`) + +## 適用すべきテスト技法 + +| 技法 | 適用箇所 | +|------|---------| +| EP | SR1 (zero / 1 / many), SR3 | +| Domain Testing | SR2 (clean/inj), SR3, SR8 | +| Pairwise | SR6 (ファセット) | +| Claims | SR4, SR5, SR8 | +| Risk | SR2, SR9 | +| BVA | SR7 | + +## Playwright 実装パターン + +```python +# zero results +page.goto("/search?q=明らかに存在しない検索語abc123") +expect(page.get_by_text(re.compile("該当なし|0 件"))).to_be_visible() +expect(page.get_by_role("searchbox")).to_have_value("明らかに存在しない検索語abc123") + +# XSS sanitize +page.get_by_role("searchbox").fill("<script>alert(1)</script>") +page.get_by_role("button", name="検索").click() +# 期待: alert が出ない + 文字列として表示 +expect(page.locator("body")).to_contain_text("<script>") + +# expect_response でファセット結果検証 +with page.expect_response("**/api/search?**brand=apple**") as info: + page.get_by_role("checkbox", name="apple").check() +resp = info.value +data = resp.json() +assert data["count"] < page.original_count # filter-monotone +``` + +## 共通チェックリスト併用 + +`checklist-common.md` の C1 (a11y, searchbox), C3 (XSS / SQL inj), C8 (console error). +`list` checklist の LST2 (ページネーション), LST7 (injection 観点) も準用. + +## 参考文献 + +- Algolia "Search UX Best Practices" +- OWASP WSTG INPV (Input Validation) +- W3C ARIA APG "Combobox Pattern" (autocomplete) +- Nielsen Norman Group "Search Patterns" diff --git a/plugins/ndf/skills/playwright-scenario-test/playwright_kit/__init__.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/__init__.py new file mode 100644 index 00000000..bd275e61 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/__init__.py @@ -0,0 +1,11 @@ +"""pytest-playwright 上で動く Web E2E シナリオテストパッケージ。 + +利用方法: +- pytest plugin として ``--pwk-config=...`` で読み込む (entry-point 経由で auto-load) +- ``pwk_config`` / ``pwk_role_<id>`` / ``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-scenario-test/playwright_kit/accessibility.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/accessibility.py new file mode 100644 index 00000000..588e3cd5 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/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-scenario-test/playwright_kit/body_check.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/body_check.py new file mode 100644 index 00000000..37a8c4c0 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/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-scenario-test/playwright_kit/config.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/config.py new file mode 100644 index 00000000..95a21caf --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/config.py @@ -0,0 +1,421 @@ +"""共通設定 (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 + + +# --- 接続/認証 ------------------------------------------------------- + +@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 の解決基点) + # 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, + 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-scenario-test/playwright_kit/fixtures/__init__.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/fixtures/__init__.py new file mode 100644 index 00000000..b9eb5000 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/fixtures/__init__.py @@ -0,0 +1,19 @@ +"""playwright-scenario-test pytest fixtures。 + +利用者は通常の pytest テストを書き、`pwk_config` / `pwk_role_<id>` 等の +fixture をパラメタ宣言するだけで NDF の機能 (config / 認証 / evidence / +accessibility / web vitals / overlay / Drive) を享受できる。 + +各 fixture の実体はサブモジュールに分離する: +- ``auth`` : ``pwk_config`` / ``pwk_role_<id>`` (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-scenario-test/playwright_kit/fixtures/accessibility.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/fixtures/accessibility.py new file mode 100644 index 00000000..eafc0a7c --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/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-scenario-test/playwright_kit/fixtures/auth.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/fixtures/auth.py new file mode 100644 index 00000000..964f1e19 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/fixtures/auth.py @@ -0,0 +1,318 @@ +"""認証 fixture: ``pwk_config`` と動的に生成する ``pwk_role_<id>``。 + +pytest-playwright が提供する ``page`` / ``context`` / ``browser_context_args`` +fixture と協調して動作する。 + +設計方針: +- ``pwk_config`` は session scope。``--pwk-config`` で指定された YAML を + 1 度だけ読み込む。利用者プロジェクトの ``conftest.py`` から override 可能。 +- 各 role に対し ``pwk_role_<id>`` 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 <path> もしくは 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_<id>`` 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_<id>`` 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-scenario-test/playwright_kit/fixtures/body_check.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/fixtures/body_check.py new file mode 100644 index 00000000..d6437c2e --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/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-scenario-test/playwright_kit/fixtures/evidence.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/fixtures/evidence.py new file mode 100644 index 00000000..c5ab4846 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/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/<run-id>/``)。 +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>/``。 + + 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-scenario-test/playwright_kit/fixtures/web_vitals.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/fixtures/web_vitals.py new file mode 100644 index 00000000..924e382a --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/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-scenario-test/playwright_kit/overlay.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/overlay.py new file mode 100644 index 00000000..21e9844a --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/overlay.py @@ -0,0 +1,220 @@ +"""動画録画用 HUD オーバーレイ (赤丸カーソル + 2 行字幕) の制御。 + +ブラウザ標準のカーソルは録画に焼き付かないため、JS で `<div>` を 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-scenario-test/playwright_kit/pytest_plugin.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/pytest_plugin.py new file mode 100644 index 00000000..48fe3b02 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/pytest_plugin.py @@ -0,0 +1,463 @@ +"""playwright-scenario-test の pytest plugin。 + +CLI options: +- ``--pwk-config <path>``: scenario.config.yaml を指定 +- ``--pwk-out-dir <path>``: 成果物 (HAR / trace / 動画 / report) の出力先 +- ``--pwk-no-evidence``: evidence 収集を OFF +- ``--pwk-overlay``: overlay (赤丸カーソル + 字幕、旧名 HUD) を ON +- ``--pwk-drive-folder <id>``: Drive 連携 + +markers: +- ``page_role(*roles)``: accessibility / web vitals autouse の判定材料 +- ``role(role_id)``: login する role を明示 (`pwk_role_<id>` 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/<run-id>/)", + ) + 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-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_<id>` 経由でも可)"), + ("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_<id>`` 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] + + +# --------------------------------------------------------------------------- +# 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/<run-id>/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_<id> fixture は動的登録されません。", + stacklevel=2, + ) + return None diff --git a/plugins/ndf/skills/playwright-scenario-test/playwright_kit/pytest_report.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/pytest_report.py new file mode 100644 index 00000000..2d67a89f --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/pytest_report.py @@ -0,0 +1,212 @@ +"""pytest hook で集めた test result から Markdown レポートを生成する。 + +``pytest_terminal_summary`` から呼ばれ、``reports/<run-id>/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-scenario-test/playwright_kit/uploaders/__init__.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/uploaders/__init__.py new file mode 100644 index 00000000..d4061f60 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/uploaders/__init__.py @@ -0,0 +1,139 @@ +"""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 + + +# google-auth スキルの sibling-skill discovery (upload_evidence.py と同一ロジック) +_CANDIDATES = ( + os.environ.get("GOOGLE_AUTH_SCRIPTS"), + os.path.expanduser("~/.claude/skills/google-auth/scripts"), + str(Path(__file__).resolve().parent.parent.parent / "scripts"), + str(Path(__file__).resolve().parent.parent.parent.parent / "google-auth" / "scripts"), +) + + +def _ensure_google_auth_on_path() -> None: + for p in _CANDIDATES: + if p and os.path.isdir(p): + if p not in sys.path: + sys.path.insert(0, p) + return + raise RuntimeError( + "google-auth スキルが見つかりません。GOOGLE_AUTH_SCRIPTS env で明示してください。" + ) + + +# 拡張子 → 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-scenario-test/playwright_kit/video.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/video.py new file mode 100644 index 00000000..e1a018ea --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/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-scenario-test/playwright_kit/web_vitals.py b/plugins/ndf/skills/playwright-scenario-test/playwright_kit/web_vitals.py new file mode 100644 index 00000000..034247ab --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/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-scenario-test/pyproject.toml b/plugins/ndf/skills/playwright-scenario-test/pyproject.toml new file mode 100644 index 00000000..14e535b1 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/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-scenario-test/scripts/_drive_auth.py b/plugins/ndf/skills/playwright-scenario-test/scripts/_drive_auth.py new file mode 100644 index 00000000..daf05734 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/scripts/_drive_auth.py @@ -0,0 +1,45 @@ +"""google-auth スキル経由で Drive API クレデンシャルを取得する共通ヘルパ。 + +3 つの uploader スクリプト (gdrive_upload_dir / build_gdoc_with_drive_links / +upload_md_as_gdoc) はいずれも同じ手順で `google_auth.get_credentials()` を +sys.path から発見する。本モジュールにロジックを集約する。 + +`GOOGLE_AUTH_SCRIPTS` 環境変数が設定されていればそれを使い、それ以外は +~/.claude/skills/google-auth/scripts → 並列の google-auth スキル の順で探す。 +""" + +from __future__ import annotations + +import os +import sys + + +_CANDIDATES: tuple[str | None, ...] = ( + os.environ.get("GOOGLE_AUTH_SCRIPTS"), + os.path.expanduser("~/.claude/skills/google-auth/scripts"), + os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", "..", "google-auth", "scripts", + ), +) + + +def _ensure_google_auth_on_path() -> None: + """`from google_auth import get_credentials` できるよう sys.path を整える。""" + for p in _CANDIDATES: + if p and os.path.isdir(p): + if p not in sys.path: + sys.path.insert(0, p) + return + raise RuntimeError( + "google_auth スキルの scripts/ が見つかりません。" + "GOOGLE_AUTH_SCRIPTS 環境変数で明示してください。" + ) + + +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-scenario-test/scripts/build_gdoc_with_drive_links.py b/plugins/ndf/skills/playwright-scenario-test/scripts/build_gdoc_with_drive_links.py new file mode 100644 index 00000000..47990c64 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/scripts/build_gdoc_with_drive_links.py @@ -0,0 +1,126 @@ +"""report.md の相対リンクを Google Drive URL に置換し、Google Docs として再アップロードする。 + +事前に対象ディレクトリを Drive にアップロード済みである前提。 +このスクリプトは: + 1. Drive 上の <run-id> フォルダから {相対パス: 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/<id>/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-scenario-test/scripts/check_cwv.py b/plugins/ndf/skills/playwright-scenario-test/scripts/check_cwv.py new file mode 100644 index 00000000..679554de --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/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-scenario-test/scripts/classify_page_role.py b/plugins/ndf/skills/playwright-scenario-test/scripts/classify_page_role.py new file mode 100644 index 00000000..2943843a --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/scripts/classify_page_role.py @@ -0,0 +1,238 @@ +"""URL の page role を DOM の (implicit + explicit) role 集計から判定する。 + +実装はブラウザ DOM クエリで `<button>` / `<a href>` / `<input>` 等を集計し、 +`page.evaluate` で取得した role 出現回数 + URL pattern スコアリングで分類する +(完全な a11y tree ではなく軽量な近似)。docs/02-page-roles.md の識別ヒューリスティック +を機械化したもの。 + +`page.accessibility.snapshot()` ベースのより厳密な分類は将来検討。 + +Usage: + python classify_page_role.py --url https://example.com/items + python classify_page_role.py --url https://example.com/items --storage-state alice.json + python classify_page_role.py --url-list urls.txt --output classifications.json +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +from playwright.sync_api import Page, sync_playwright + + +# 役割ごとのスコアリングルール (シンプルな足し算)。 +# docs/02-page-roles.md の識別ヒューリスティックを機械可読化したもの。 +@dataclass +class RoleSignal: + role: str + score: float + evidence: list[str] = field(default_factory=list) + + +# URL pattern → role hint +URL_PATTERNS: list[tuple[re.Pattern, str, float]] = [ + (re.compile(r"^/?$"), "lp", 1.0), + (re.compile(r"/lp/|/campaign/|/about|/pricing|/features"), "lp", 1.0), + (re.compile(r"/login|/signin|/register|/forgot-password|/auth/"), "auth", 1.5), + (re.compile(r"/cart$"), "cart", 1.5), + (re.compile(r"/checkout"), "checkout", 1.5), + (re.compile(r"/search|\?q=|\?query="), "search", 1.0), + (re.compile(r"/dashboard|/analytics|/reports"), "dashboard", 1.0), + (re.compile(r"/settings|/account|/profile"), "settings", 1.0), + (re.compile(r"/[^/]+/\d+/edit$|/edit$"), "edit", 1.5), + (re.compile(r"/[^/]+/\d+$|/[^/]+/[a-z0-9-]{8,}$"), "item", 1.0), + (re.compile(r"/contact|/signup|/apply|/subscribe"), "form", 0.8), +] + + +def _evaluate_role_summary(page: Page) -> dict[str, int]: + """ページから role の登場回数を集計する。""" + return page.evaluate("""() => { + const roles = {}; + // role 属性 (明示) + document.querySelectorAll("[role]").forEach(el => { + const r = el.getAttribute("role"); + if (r) roles[r] = (roles[r] || 0) + 1; + }); + // implicit role を簡易判定 + const implicit = { + 'a[href]': 'link', 'button': 'button', + 'h1': 'heading_1', 'h2': 'heading_2', 'h3': 'heading_3', + 'input[type="text"], input[type="email"], input[type="password"], input:not([type]), textarea': 'textbox', + 'input[type="search"]': 'searchbox', + 'input[type="checkbox"]': 'checkbox', + 'input[type="radio"]': 'radio', + 'select': 'combobox', + 'table': 'table', 'tr': 'row', + 'ul li, ol li': 'listitem', + 'nav': 'navigation', 'main': 'main', 'header': 'banner', 'footer': 'contentinfo', + 'form': 'form', 'fieldset': 'group', + 'dialog[open], [role="dialog"][aria-modal="true"]': 'dialog', + 'article': 'article', + '[aria-current]': 'aria_current', + }; + Object.entries(implicit).forEach(([sel, name]) => { + roles[name] = (roles[name] || 0) + document.querySelectorAll(sel).length; + }); + return roles; + }""") + + +def _score_from_dom(roles: dict[str, int], url: str) -> list[RoleSignal]: + """DOM の role 集計 + URL から各 page role の score を算出。""" + signals: dict[str, RoleSignal] = {} + + def add(role: str, score: float, evidence: str) -> None: + s = signals.setdefault(role, RoleSignal(role=role, score=0.0)) + s.score += score + s.evidence.append(evidence) + + n = roles.get + # auth: textbox + Email/Password label (label は別途 page から取得) + if n("textbox", 0) >= 2 and n("button", 0) >= 1: + add("auth", 0.5, f"textbox×{n('textbox',0)} + button") + # dialog + if n("dialog", 0) >= 1: + add("modal", 1.5, f"dialog×{n('dialog',0)}") + # list: row >= 5 (table) or listitem >= 5 or article >= 3 + if n("row", 0) >= 5: + add("list", 1.5, f"row×{n('row',0)}") + if n("listitem", 0) >= 5: + add("list", 1.0, f"listitem×{n('listitem',0)}") + if n("article", 0) >= 3: + add("list", 0.8, f"article×{n('article',0)}") + # search + if n("searchbox", 0) >= 1: + add("search", 1.5, f"searchbox×{n('searchbox',0)}") + # form: textbox >= 3 + form 要素 + if n("textbox", 0) >= 3 and n("form", 0) >= 1: + add("form", 1.0, f"textbox×{n('textbox',0)} + form") + if n("aria_current", 0) >= 1: + add("form", 0.5, "aria-current 検出 (step ?)") + add("wizard", 0.5, "aria-current 検出 (step ?)") + # edit: textbox + button "保存/Save" (textboxbox 個別判定は generate 側で) + # item: heading_1 + 説明文 + アクションボタン + if n("heading_1", 0) == 1 and n("button", 0) >= 1 and n("table", 0) == 0: + add("item", 0.7, "h1 + button (table なし)") + # dashboard: region + 多数の値表示 + region_count = n("region", 0) + if region_count >= 3: + add("dashboard", 1.0, f"region×{region_count}") + # lp: link 多数 + heading + button (CTA) + if n("link", 0) >= 8 and n("heading_1", 0) == 1 and n("textbox", 0) <= 2: + add("lp", 1.0, f"link×{n('link',0)} + h1 + textbox≤2") + + # URL pattern からの加算 + parsed = urlparse(url) + path = parsed.path or "/" + for pat, role, score in URL_PATTERNS: + if pat.search(path): + add(role, score, f"URL pattern: {pat.pattern}") + + return sorted(signals.values(), key=lambda s: -s.score) + + +def _check_login_form(page: Page) -> bool: + """Email + Password の組合せがあれば auth 候補。""" + try: + has_email = page.get_by_label(re.compile(r"email|メール", re.I)).count() > 0 + has_pw = page.get_by_label(re.compile(r"password|パスワード", re.I)).count() > 0 + return has_email and has_pw + except Exception: + return False + + +def classify( + url: str, + *, + storage_state: str | None = None, + timeout_ms: int = 30_000, + headless: bool = True, +) -> dict[str, Any]: + """URL を開いて role を判定し、結果を dict で返す。""" + with sync_playwright() as p: + browser = p.chromium.launch(headless=headless) + ctx_kwargs: dict = {"ignore_https_errors": True} + if storage_state: + ctx_kwargs["storage_state"] = storage_state + context = browser.new_context(**ctx_kwargs) + page = context.new_page() + try: + response = page.goto(url, timeout=timeout_ms, wait_until="domcontentloaded") + page.wait_for_load_state("networkidle", timeout=timeout_ms) + except Exception as exc: + return {"url": url, "error": str(exc)} + + roles = _evaluate_role_summary(page) + signals = _score_from_dom(roles, url) + if _check_login_form(page): + # 強い証拠なので auth スコアを底上げ + for s in signals: + if s.role == "auth": + s.score += 1.0 + s.evidence.append("Email + Password label 検出") + break + else: + signals.insert(0, RoleSignal(role="auth", score=2.0, + evidence=["Email + Password label 検出"])) + + # 上位を取り、score ≥ 1.0 のみ採用 + primary = signals[0] if signals and signals[0].score >= 1.0 else None + alternates = [s for s in signals[1:] if s.score >= 0.5][:3] + + result: dict[str, Any] = { + "url": url, + "status": response.status if response else None, + "primary_role": primary.role if primary else "unknown", + "primary_score": primary.score if primary else 0.0, + "primary_evidence": primary.evidence if primary else [], + "alternates": [asdict(s) for s in alternates], + "role_counts": dict(sorted(roles.items(), key=lambda kv: -kv[1])[:20]), + } + browser.close() + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description="URL の page role を判定する") + parser.add_argument("--url", help="判定対象 URL") + parser.add_argument("--url-list", type=Path, help="URL を 1 行 1 件で書いたファイル") + parser.add_argument("--storage-state", default=None, + help="ログイン済み storage_state.json (任意)") + parser.add_argument("--output", type=Path, default=None, + help="JSON 出力先 (省略時は stdout)") + parser.add_argument("--headed", action="store_true", help="ブラウザを headed で起動") + args = parser.parse_args() + + if not args.url and not args.url_list: + parser.error("--url または --url-list が必要です") + + urls: list[str] + if args.url_list: + urls = [line.strip() for line in args.url_list.read_text().splitlines() if line.strip()] + else: + urls = [args.url] + + results = [ + classify(u, storage_state=args.storage_state, headless=not args.headed) + for u in urls + ] + + output_text = json.dumps(results, indent=2, ensure_ascii=False) + if args.output: + args.output.write_text(output_text, encoding="utf-8") + print(f"OK: {len(results)} URL を分類 → {args.output}") + else: + print(output_text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/ndf/skills/playwright-scenario-test/scripts/gdrive_upload_dir.py b/plugins/ndf/skills/playwright-scenario-test/scripts/gdrive_upload_dir.py new file mode 100644 index 00000000..89bd1e85 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/scripts/gdrive_upload_dir.py @@ -0,0 +1,75 @@ +"""ローカルディレクトリツリーを再帰的に Google Drive へアップロードする。""" + +from __future__ import annotations + +import argparse +import mimetypes +import os +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" + + +def get_or_create_folder(service, name: str, parent_id: str) -> str: + """parent 配下に同名フォルダがあればその ID、なければ作成して ID を返す。""" + q = ( + f"'{parent_id}' in parents and name='{name}' " + f"and mimeType='{FOLDER_MIME}' and trashed=false" + ) + found = service.files().list( + q=q, spaces="drive", fields="files(id,name)", + supportsAllDrives=True, includeItemsFromAllDrives=True, + ).execute().get("files", []) + if found: + return found[0]["id"] + folder = service.files().create( + body={"name": name, "mimeType": FOLDER_MIME, "parents": [parent_id]}, + fields="id", supportsAllDrives=True, + ).execute() + print(f" [folder] created: {name} -> {folder['id']}") + return folder["id"] + + +def upload_file(service, path: Path, parent_id: str) -> str: + mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream" + media = MediaFileUpload(str(path), mimetype=mime, resumable=True) + file = service.files().create( + body={"name": path.name, "parents": [parent_id]}, + media_body=media, fields="id,name,size", + supportsAllDrives=True, + ).execute() + print(f" [file] {path.name} ({path.stat().st_size:,} bytes) -> {file['id']}") + return file["id"] + + +def upload_dir(service, local_dir: Path, drive_parent_id: str) -> None: + for entry in sorted(local_dir.iterdir()): + if entry.is_dir(): + sub_id = get_or_create_folder(service, entry.name, drive_parent_id) + upload_dir(service, entry, sub_id) + elif entry.is_file(): + upload_file(service, entry, drive_parent_id) + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--local", required=True, type=Path) + p.add_argument("--parent", required=True) + args = p.parse_args() + + service = drive_service(SCOPES) + print(f"Upload {args.local} -> drive folder {args.parent}") + upload_dir(service, args.local, args.parent) + print("Done.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/ndf/skills/playwright-scenario-test/scripts/init_project.bat b/plugins/ndf/skills/playwright-scenario-test/scripts/init_project.bat new file mode 100644 index 00000000..0882ae79 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/scripts/init_project.bat @@ -0,0 +1,186 @@ +@echo off +rem scenario-test ランタイムを利用者プロジェクトに埋め込む初期化スクリプト (Windows 版)。 +rem +rem 使い方: +rem scripts\init_project.bat <PROJECT_ROOT> [--runtime-dir <name>] [--dry-run] +rem +rem 例: +rem scripts\init_project.bat C:\path\to\your-app +rem scripts\init_project.bat C:\path\to\your-app --runtime-dir e2e +rem +rem 動作: scripts/init_project.sh と同じ内容を xcopy ベースで Windows 上にて実施。 + +setlocal EnableExtensions EnableDelayedExpansion + +set "PROJECT_ROOT=" +set "RUNTIME_DIR_NAME=scenario-test" +set "DRY_RUN=0" + +:parse_args +if "%~1"=="" goto :validate_args +if "%~1"=="--runtime-dir" ( + if "%~2"=="" ( + echo [init] --runtime-dir に値が必要です + exit /b 1 + ) + set "RUNTIME_DIR_NAME=%~2" + shift + shift + goto :parse_args +) +if "%~1"=="--dry-run" ( + set "DRY_RUN=1" + shift + goto :parse_args +) +if "%~1"=="-h" goto :show_help +if "%~1"=="--help" goto :show_help +if "%~1:~0,1%"=="-" ( + echo [init] unknown option: %~1 + exit /b 1 +) +if not "%PROJECT_ROOT%"=="" ( + echo [init] PROJECT_ROOT は 1 つだけ指定してください ^(既: %PROJECT_ROOT%, 追加: %~1^) + exit /b 1 +) +set "PROJECT_ROOT=%~1" +shift +goto :parse_args + +:show_help +echo 使い方: scripts\init_project.bat ^<PROJECT_ROOT^> [--runtime-dir ^<name^>] [--dry-run] +exit /b 0 + +:validate_args +if "%PROJECT_ROOT%"=="" ( + echo [init] PROJECT_ROOT は必須です + exit /b 1 +) + +rem --runtime-dir のサニタイズ。 +rem POSIX 側 (init_project.sh) と同じ whitelist `^[A-Za-z0-9._-]+$` で検証する。 +rem +rem 注意: cmd.exe の即時展開 (%VAR%) は cmd メタ文字 (`&`, `|`, `<`, `>` 等) を +rem 命令区切りとして解釈してしまうため、`echo %RUNTIME_DIR_NAME% | findstr ...` 形式は +rem command injection の余地がある。値を環境変数として PowerShell に渡し、 +rem PowerShell の正規表現で検証することで shell parsing を完全に回避する。 +rem (delayed expansion `!VAR!` も内部値が `&` 等を含むと安全性に依存があるため、 +rem 検証は外部プロセスの env 経由が最も堅牢) +if "!RUNTIME_DIR_NAME!"=="." ( + echo [init] --runtime-dir に '.' は指定できません + exit /b 1 +) +if "!RUNTIME_DIR_NAME!"==".." ( + echo [init] --runtime-dir に '..' は指定できません + exit /b 1 +) +set "_PWK_VALIDATE=!RUNTIME_DIR_NAME!" +powershell -NoProfile -Command "if ($env:_PWK_VALIDATE -notmatch '^[A-Za-z0-9._-]+$') { exit 1 }" +if errorlevel 1 ( + set "_PWK_VALIDATE=" + echo [init] --runtime-dir は英数字 / . / _ / - のみ使用可能です: !RUNTIME_DIR_NAME! + exit /b 1 +) +set "_PWK_VALIDATE=" + +rem Skill ディレクトリ (このスクリプトの 1 つ上) +for %%i in ("%~dp0..") do set "SKILL_DIR=%%~fi" + +rem ランタイム配置先 +set "RUNTIME_DIR=%PROJECT_ROOT%\%RUNTIME_DIR_NAME%" + +echo [init] Skill ディレクトリ : %SKILL_DIR% +echo [init] プロジェクトルート : %PROJECT_ROOT% +echo [init] ランタイム配置先 : %RUNTIME_DIR% +if "%DRY_RUN%"=="1" echo [init] (dry-run モード) + +if not exist "%PROJECT_ROOT%" ( + echo [init] PROJECT_ROOT が存在しません: %PROJECT_ROOT% + exit /b 1 +) + +if "%DRY_RUN%"=="0" ( + if not exist "%RUNTIME_DIR%\tests" mkdir "%RUNTIME_DIR%\tests" +) + +rem ---------- 1) playwright_kit / scripts / uv.lock を xcopy ---------- +echo [init] [1/4] playwright_kit / scripts / uv.lock をコピー +if "%DRY_RUN%"=="0" ( + xcopy /E /I /Y /Q "%SKILL_DIR%\playwright_kit" "%RUNTIME_DIR%\playwright_kit" >nul + xcopy /E /I /Y /Q "%SKILL_DIR%\scripts" "%RUNTIME_DIR%\scripts" >nul + copy /Y "%SKILL_DIR%\uv.lock" "%RUNTIME_DIR%\uv.lock" >nul +) else ( + echo xcopy %SKILL_DIR%\playwright_kit -^> %RUNTIME_DIR%\playwright_kit + echo xcopy %SKILL_DIR%\scripts -^> %RUNTIME_DIR%\scripts + echo copy %SKILL_DIR%\uv.lock -^> %RUNTIME_DIR%\uv.lock +) + +rem ---------- 2) runtime テンプレート (上書き) ---------- +echo [init] [2/4] runtime テンプレート +call :copy_overwrite "%SKILL_DIR%\templates\pyproject.toml.runtime" "%RUNTIME_DIR%\pyproject.toml" +call :copy_overwrite "%SKILL_DIR%\templates\run.sh" "%RUNTIME_DIR%\run.sh" +call :copy_overwrite "%SKILL_DIR%\templates\run.bat" "%RUNTIME_DIR%\run.bat" +call :copy_overwrite "%SKILL_DIR%\templates\runtime-gitignore" "%RUNTIME_DIR%\.gitignore" +call :copy_overwrite "%SKILL_DIR%\templates\runtime-README.md" "%RUNTIME_DIR%\README.md" + +rem ---------- 3) 利用者編集物 (上書きしない) ---------- +echo [init] [3/4] 利用者編集物 (scenario.config.yaml / tests/) — 既存があれば skip +call :copy_if_absent "%SKILL_DIR%\templates\scenario.config.yaml" "%RUNTIME_DIR%\scenario.config.yaml" +call :copy_if_absent "%SKILL_DIR%\templates\conftest.py.template" "%RUNTIME_DIR%\tests\conftest.py" +for %%t in (test_auth test_list test_form test_dashboard) do ( + call :copy_if_absent "%SKILL_DIR%\templates\%%t.py.template" "%RUNTIME_DIR%\tests\%%t.py" +) + +rem ---------- 4) 初回 uv sync + playwright install ---------- +if "%DRY_RUN%"=="1" ( + echo [init] [4/4] dry-run のためスキップ + echo [init] dry-run 完了 + exit /b 0 +) + +echo [init] [4/4] uv sync + playwright install chromium +where uv >nul 2>&1 +if errorlevel 1 ( + echo [init] WARN: uv が見つかりません。https://docs.astral.sh/uv/ からインストールしてください。 + echo [init] run.bat 起動時に初回 uv sync が走ります。 +) else ( + pushd "%RUNTIME_DIR%" + uv sync + if errorlevel 1 ( + popd + echo [init] ERROR: uv sync に失敗しました。 + echo [init] ネットワーク / pyproject.toml / uv.lock を確認してください。 + exit /b 1 + ) + uv run playwright install chromium + if errorlevel 1 ( + echo [init] WARN: playwright install chromium に失敗しました。 + echo [init] オフライン環境では PLAYWRIGHT_BROWSERS_PATH を設定してください。 + ) + popd +) + +echo. +echo [done] 初期化完了。次回以降は以下のコマンドで実行できます: +echo cd %RUNTIME_DIR% ^&^& run.bat +exit /b 0 + +:copy_overwrite +if "%DRY_RUN%"=="1" ( + echo cp ^(overwrite^): %~1 -^> %~2 + exit /b 0 +) +copy /Y "%~1" "%~2" >nul +exit /b 0 + +:copy_if_absent +if exist "%~2" ( + echo skip ^(exists^): %~2 + exit /b 0 +) +if "%DRY_RUN%"=="1" ( + echo cp ^(new^): %~1 -^> %~2 + exit /b 0 +) +copy /Y "%~1" "%~2" >nul +exit /b 0 diff --git a/plugins/ndf/skills/playwright-scenario-test/scripts/init_project.sh b/plugins/ndf/skills/playwright-scenario-test/scripts/init_project.sh new file mode 100755 index 00000000..e5986d30 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/scripts/init_project.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# scenario-test ランタイムを利用者プロジェクトに埋め込む初期化スクリプト。 +# +# 使い方: +# scripts/init_project.sh <PROJECT_ROOT> [--runtime-dir <name>] [--dry-run] +# +# 例: +# scripts/init_project.sh /path/to/your-app +# → /path/to/your-app/scenario-test/ 一式を作成 +# +# scripts/init_project.sh /path/to/your-app --runtime-dir e2e +# → /path/to/your-app/e2e/ 一式 (中身は同じ、ディレクトリ名のみ変更) +# +# scripts/init_project.sh /path/to/your-app --dry-run +# → 実際にはコピーせず、配置予定のファイル一覧のみ表示 +# +# 動作: +# 1) <PROJECT_ROOT>/<runtime-dir>/ を作成 +# 2) Skill 側の playwright_kit/ / scripts/ / uv.lock / 各テンプレートをコピー +# 3) scenario.config.yaml / tests/conftest.py / tests/test_*.py.template は +# 既に存在する場合は上書きしない (利用者の編集物保護) +# 4) 初回 uv sync + playwright install chromium を実行 +# +# このスクリプト完了後、利用者プロジェクトは Skill ディレクトリの存在に依存 +# しなくなる (all-in-one)。 +set -euo pipefail + +# ---------- 引数パース ---------- +PROJECT_ROOT="" +RUNTIME_DIR_NAME="scenario-test" +DRY_RUN=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --runtime-dir) + [[ $# -ge 2 ]] || { echo "[init] --runtime-dir に値が必要です" >&2; exit 1; } + RUNTIME_DIR_NAME="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h|--help) + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + -*) + echo "[init] unknown option: $1" >&2 + exit 1 + ;; + *) + if [[ -n "$PROJECT_ROOT" ]]; then + echo "[init] PROJECT_ROOT は 1 つだけ指定してください (既: $PROJECT_ROOT, 追加: $1)" >&2 + exit 1 + fi + PROJECT_ROOT="$1" + shift + ;; + esac +done + +[[ -n "$PROJECT_ROOT" ]] || { echo "[init] PROJECT_ROOT は必須です" >&2; exit 1; } +# `.` / `..` を弾いた上で英数字 + `.` `_` `-` のみ許可。 +# (先頭 `.` は許可するが、`.` 単体・`..` 単体・パス区切りはブロック) +if [[ "$RUNTIME_DIR_NAME" == "." || "$RUNTIME_DIR_NAME" == ".." ]]; then + echo "[init] --runtime-dir に '.' / '..' は指定できません: '$RUNTIME_DIR_NAME'" >&2 + exit 1 +fi +if [[ ! "$RUNTIME_DIR_NAME" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "[init] --runtime-dir は英数字 / . / _ / - のみ使用可能です: '$RUNTIME_DIR_NAME'" >&2 + exit 1 +fi + +SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)" +# bash 3.2 (macOS default) ではサブシェル失敗時の `||` 右辺が動かないケースがあるため、 +# 明示的な `-d` チェックを先に行う。 +if [[ ! -d "$PROJECT_ROOT" ]]; then + echo "[init] PROJECT_ROOT が存在しません: $PROJECT_ROOT" >&2 + exit 1 +fi +PROJECT_ROOT="$(cd "$PROJECT_ROOT" && pwd)" +RUNTIME_DIR="$PROJECT_ROOT/$RUNTIME_DIR_NAME" + +echo "[init] Skill ディレクトリ : $SKILL_DIR" +echo "[init] プロジェクトルート : $PROJECT_ROOT" +echo "[init] ランタイム配置先 : $RUNTIME_DIR" +[[ $DRY_RUN -eq 1 ]] && echo "[init] (dry-run モード: 実際にはコピーしません)" + +# ---------- 必要コマンドチェック ---------- +for cmd in rsync; do + command -v "$cmd" >/dev/null 2>&1 || { + echo "[init] 必要なコマンドが見つかりません: $cmd" >&2 + exit 1 + } +done + +# ---------- helper ---------- +copy() { + local src="$1" dst="$2" + if [[ $DRY_RUN -eq 1 ]]; then + echo " cp $src -> $dst" + return 0 + fi + cp "$src" "$dst" +} + +copy_if_absent() { + local src="$1" dst="$2" + if [[ -e "$dst" ]]; then + echo " skip (exists): $dst" + return 0 + fi + if [[ $DRY_RUN -eq 1 ]]; then + echo " cp (new): $src -> $dst" + return 0 + fi + cp "$src" "$dst" +} + +# ---------- 0) 配置先準備 ---------- +if [[ $DRY_RUN -eq 0 ]]; then + mkdir -p "$RUNTIME_DIR/tests" +fi + +# ---------- 1) パッケージ本体 + scripts + uv.lock を rsync ---------- +echo "[init] [1/4] playwright_kit / scripts / uv.lock をコピー" +# dry-run 時は rsync を起動しない (宛先 RUNTIME_DIR が未作成だと +# rsync -n でも failed to read directory で abort するため)。 +if [[ $DRY_RUN -eq 1 ]]; then + echo " rsync $SKILL_DIR/playwright_kit -> $RUNTIME_DIR/playwright_kit" + echo " rsync $SKILL_DIR/scripts -> $RUNTIME_DIR/scripts" + echo " cp $SKILL_DIR/uv.lock -> $RUNTIME_DIR/uv.lock" +else + RSYNC_OPTS=(-a + --exclude='.venv' --exclude='__pycache__' --exclude='.pytest_cache' + --exclude='reports' --exclude='*.egg-info' + ) + rsync "${RSYNC_OPTS[@]}" "$SKILL_DIR/playwright_kit" "$RUNTIME_DIR/" + rsync "${RSYNC_OPTS[@]}" "$SKILL_DIR/scripts" "$RUNTIME_DIR/" + cp "$SKILL_DIR/uv.lock" "$RUNTIME_DIR/uv.lock" +fi + +# ---------- 2) ランタイム用テンプレートをコピー (上書き) ---------- +echo "[init] [2/4] runtime テンプレート (pyproject.toml / run.sh / run.bat / .gitignore / README.md)" +copy "$SKILL_DIR/templates/pyproject.toml.runtime" "$RUNTIME_DIR/pyproject.toml" +copy "$SKILL_DIR/templates/run.sh" "$RUNTIME_DIR/run.sh" +copy "$SKILL_DIR/templates/run.bat" "$RUNTIME_DIR/run.bat" +copy "$SKILL_DIR/templates/runtime-gitignore" "$RUNTIME_DIR/.gitignore" +copy "$SKILL_DIR/templates/runtime-README.md" "$RUNTIME_DIR/README.md" +[[ $DRY_RUN -eq 0 ]] && chmod +x "$RUNTIME_DIR/run.sh" + +# ---------- 3) 利用者編集物 (上書きしない) ---------- +echo "[init] [3/4] 利用者編集物 (scenario.config.yaml / tests/) — 既存があれば skip" +copy_if_absent "$SKILL_DIR/templates/scenario.config.yaml" "$RUNTIME_DIR/scenario.config.yaml" +copy_if_absent "$SKILL_DIR/templates/conftest.py.template" "$RUNTIME_DIR/tests/conftest.py" +for t in test_auth test_list test_form test_dashboard; do + copy_if_absent "$SKILL_DIR/templates/${t}.py.template" "$RUNTIME_DIR/tests/${t}.py" +done + +# ---------- 4) 初回 uv sync + playwright install ---------- +if [[ $DRY_RUN -eq 1 ]]; then + echo "[init] [4/4] dry-run のため uv sync / playwright install はスキップ" + echo "[init] dry-run 完了" + exit 0 +fi + +echo "[init] [4/4] uv sync + playwright install chromium ($RUNTIME_DIR)" +if ! command -v uv >/dev/null 2>&1; then + echo "[init] WARN: uv が見つかりません。https://docs.astral.sh/uv/ を参照してインストールしてください。" + echo "[init] インストール後、$(printf '%q' "$RUNTIME_DIR")/run.sh が初回 uv sync を自動実行します。" +else + (cd "$RUNTIME_DIR" && uv sync) + (cd "$RUNTIME_DIR" && uv run playwright install chromium) || { + echo "[init] WARN: playwright install chromium に失敗しました。" + echo "[init] オフライン環境なら PLAYWRIGHT_BROWSERS_PATH を設定してください。" + } +fi + +echo +echo "[done] 初期化完了。次回以降は以下のコマンドで実行できます:" +echo " cd $(printf '%q' "$RUNTIME_DIR") && ./run.sh" +echo " または:" +echo " (cd $(printf '%q' "$PROJECT_ROOT") && ./$RUNTIME_DIR_NAME/run.sh)" diff --git a/plugins/ndf/skills/playwright-scenario-test/scripts/record_scenario.py b/plugins/ndf/skills/playwright-scenario-test/scripts/record_scenario.py new file mode 100644 index 00000000..a50e3257 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/scripts/record_scenario.py @@ -0,0 +1,104 @@ +"""Playwright codegen ラッパー。経験で書く代わりに「録画→生成」する。 + +Usage: + # 標準: 公開 URL を開いて操作を記録 + python record_scenario.py https://example.com + + # 認証必要: 既存の storage_state を読み込む + python record_scenario.py https://example.com --load-storage auth.json + + # 認証 storage を保存しながら記録 + python record_scenario.py https://example.com --save-storage auth.json + + # mobile デバイスエミュレーション + python record_scenario.py https://example.com --device "iPhone 13" + + # 出力先 (Python ファイル) を指定 + python record_scenario.py https://example.com --output recorded.py +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Playwright codegen ラッパー (Python sync 出力)" + ) + parser.add_argument("url", help="対象 URL") + parser.add_argument("--target", default="python", + choices=["python", "python-async", "python-pytest", "javascript", "java", "csharp"], + help="出力言語 (default: python)") + parser.add_argument("--device", default=None, help="device 名 (例: 'iPhone 13')") + parser.add_argument("--load-storage", default=None, + help="既存 storage_state.json を読み込んで認証済みで開始") + parser.add_argument("--save-storage", default=None, + help="ブラウザ閉じる時に storage_state.json として保存") + parser.add_argument("--output", type=Path, default=None, + help="出力 Python ファイル (省略時は stdout)") + parser.add_argument("--viewport", default=None, + help="viewport (例: 1280x720)") + parser.add_argument("--user-agent", default=None) + args = parser.parse_args() + + # playwright CLI が見つかるか (Maj-3: 検出した絶対パスを subprocess に渡し、 + # PATH 再検索による不一致や PATH 改竄リスクを避ける) + pw = shutil.which("playwright") + if not pw: + print("ERROR: playwright CLI が見つかりません。", file=sys.stderr) + print(" uv sync && uv run playwright install chromium", file=sys.stderr) + return 2 + + cmd = [pw, "codegen", "--target", args.target] + if args.device: + cmd.extend(["--device", args.device]) + if args.load_storage: + cmd.extend(["--load-storage", args.load_storage]) + if args.save_storage: + cmd.extend(["--save-storage", args.save_storage]) + if args.viewport: + cmd.extend(["--viewport-size", args.viewport]) + if args.user_agent: + cmd.extend(["--user-agent", args.user_agent]) + if args.output: + cmd.extend(["--output", str(args.output)]) + cmd.append(args.url) + + print("Running:", " ".join(cmd), file=sys.stderr) + print("(ブラウザを開いて操作してください。閉じると Python コードが出力されます)", + file=sys.stderr) + + try: + result = subprocess.run(cmd, check=False) + except FileNotFoundError: + print("ERROR: playwright CLI 実行に失敗", file=sys.stderr) + return 2 + + if args.output and args.output.exists(): + lang_label = { + "python": "Python (sync)", + "python-async": "Python (async)", + "python-pytest": "pytest-playwright", + "javascript": "JavaScript", + "java": "Java", + "csharp": "C#", + }.get(args.target, args.target) + print(f"\nOK: 録画コード ({lang_label}) → {args.output}", file=sys.stderr) + print("\n次のステップ:", file=sys.stderr) + print(f" 1. 出力された {lang_label} コードを開いて get_by_role/get_by_label が", + file=sys.stderr) + print(" 使われているか確認", file=sys.stderr) + print(" 2. CSS セレクタ (page.locator('#foo')) は a11y 名へ書き換える", file=sys.stderr) + print(" 3. templates/test_<role>.py.template に貼り付け、page_role / role marker を", + file=sys.stderr) + print(" 付与して `uv run pytest --pwk-config=...` で実行する", file=sys.stderr) + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/ndf/skills/playwright-scenario-test/scripts/run_a11y_scan.py b/plugins/ndf/skills/playwright-scenario-test/scripts/run_a11y_scan.py new file mode 100644 index 00000000..1c3918d8 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/scripts/run_a11y_scan.py @@ -0,0 +1,104 @@ +"""axe-core で a11y 違反を検出する CLI (`playwright_kit.accessibility` モジュールの薄いラッパ)。 + +docs/checklists/checklist-common.md C1 (a11y) の自動走査用。runner はテストケース +内蔵で同 module を呼ぶため、本 CLI は外部 URL の単発スキャン専用。 + +Usage: + python run_a11y_scan.py --url https://example.com + python run_a11y_scan.py --url-list urls.txt --output a11y-report.json + python run_a11y_scan.py --url https://example.com --storage-state alice.json --tags wcag22aa +""" + +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 + +# playwright_kit 配下を import 可能にする (skill 直下から呼ばれる前提) +_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.accessibility import DEFAULT_TAGS, scan_page # noqa: E402 + + +def scan( + url: str, + *, + storage_state: str | None = None, + tags: list[str] | None = None, + timeout_ms: int = 30_000, + headless: bool = True, +) -> dict[str, Any]: + with sync_playwright() as p: + browser = p.chromium.launch(headless=headless) + ctx_kwargs: dict = {"ignore_https_errors": True} + if storage_state: + ctx_kwargs["storage_state"] = storage_state + context = browser.new_context(**ctx_kwargs) + page = context.new_page() + try: + page.goto(url, timeout=timeout_ms, wait_until="domcontentloaded") + page.wait_for_load_state("networkidle", timeout=timeout_ms) + except Exception as exc: + browser.close() + return {"url": url, "error": str(exc)} + + violations = scan_page(page, tags=tuple(tags or DEFAULT_TAGS)) + browser.close() + return { + "url": url, + "violations_count": len(violations), + "violations": violations, + "tags": tags or list(DEFAULT_TAGS), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="axe-core で a11y 違反を検出") + parser.add_argument("--url", help="検査対象 URL") + parser.add_argument("--url-list", type=Path, help="URL を 1 行 1 件で書いたファイル") + parser.add_argument("--storage-state", default=None, help="ログイン済 storage_state.json") + parser.add_argument("--tags", nargs="+", default=list(DEFAULT_TAGS), + help=f"axe-core タグ (default: {list(DEFAULT_TAGS)})") + parser.add_argument("--output", type=Path, default=None, + help="JSON 出力先 (省略時 stdout)") + parser.add_argument("--fail-on-violations", action="store_true", + help="violations_count > 0 で 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 = [ + scan(u, storage_state=args.storage_state, tags=args.tags, + headless=not args.headed) + 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: a11y scan → {args.output}", file=sys.stderr) + else: + sys.stdout.write(text + "\n") + + total_violations = sum(r.get("violations_count", 0) for r in results) + print(f"violations: {total_violations}", file=sys.stderr) + return 1 if args.fail_on_violations and total_violations > 0 else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/ndf/skills/playwright-scenario-test/scripts/upload_evidence.py b/plugins/ndf/skills/playwright-scenario-test/scripts/upload_evidence.py new file mode 100644 index 00000000..37849d64 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/scripts/upload_evidence.py @@ -0,0 +1,213 @@ +"""trace.zip / HAR / video / 任意ファイルを Google Drive にアップロードする統合 uploader。 + +bug report の証跡を Drive リンクで貼るためのスクリプト。 + +⚠️ trace.zip / HAR / video は DOM snapshot や入力痕跡、HTTP request body を含む。 +既定では非公開アップロード。`--public` 明示時のみ anyone/read を付与する。 +playwright.dev の trace viewer URL は `--public` のときだけ生成される +(viewer は GET で trace.zip を取りに行くため anyone/read 必須)。 + +Usage: + upload_evidence.py reports/.../trace.zip --kind trace + upload_evidence.py reports/.../trace.zip --kind trace --public + upload_evidence.py reports/.../foo.har --kind har + upload_evidence.py reports/.../foo.mp4 --kind video --parent <DRIVE_FOLDER_ID> + upload_evidence.py reports/.../foo.zip --kind any +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path +from urllib.parse import quote + + +# google-auth スキルの sibling-skill discovery +_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"), +) + + +def _ensure_google_auth_on_path() -> None: + for p in _CANDIDATES: + if p and os.path.isdir(p): + if p not in sys.path: + sys.path.insert(0, p) + return + raise RuntimeError( + "google-auth スキルが見つかりません。GOOGLE_AUTH_SCRIPTS env で明示してください。" + ) + + +# 拡張子 → kind の自動判定 +_EXT_KIND: dict[str, str] = { + ".zip": "trace", # Playwright trace.zip + ".har": "har", + ".mp4": "video", + ".webm": "video", +} + + +_MIME_BY_KIND: dict[str, str] = { + "trace": "application/zip", + "har": "application/json", + "video": "video/mp4", # detect_mime() で .webm を別 MIME に振り分ける + "any": "application/octet-stream", +} + +# codex Min-3: kind=video でも実体が .webm の場合は MIME を実体に合わせる +# (Drive 側の preview/処理系の誤判定を避ける) +_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 + (kind 別) 補助 URL を返す。 + + Returns: + { + "file_id": str, + "drive_view": str, # webViewLink (Drive 上での閲覧 URL) + "direct_download": str | None, # public=True のときだけ生成 + "playwright_trace_viewer": str | None, # kind=trace + public=True のときだけ + "is_public": bool, + "kind": str, + } + """ + # Min-7: Python API として呼ばれた場合の防御的検査 (CLI argparse は別途 choices) + 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, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="trace/HAR/video/任意ファイルを Drive にアップロード", + ) + parser.add_argument("file", type=Path, help="アップロードするファイル") + parser.add_argument( + "--kind", choices=["trace", "har", "video", "any"], default=None, + help="evidence 種別 (省略時は拡張子から自動判定)", + ) + parser.add_argument( + "--parent-folder-id", default=None, + help="Drive の親フォルダ ID (省略時はマイドライブ直下)", + ) + parser.add_argument( + "--public", action="store_true", + help="anyone/read 公開リンクを付与する (既定は非公開、明示 opt-in)", + ) + args = parser.parse_args() + + if not args.file.exists(): + print(f"ERROR: file not found: {args.file}", file=sys.stderr) + return 2 + + kind = args.kind or detect_kind(args.file) + + result = upload( + args.file, + kind=kind, + parent_folder_id=args.parent_folder_id, + public=args.public, + ) + + if not args.public: + print( + "[NOTE] 非公開でアップロード済 (既定)。trace/HAR/video には DOM snapshot や" + "入力痕跡が含まれるため、安易な anyone/read は避けてください。", + file=sys.stderr, + ) + if kind == "trace": + print( + " playwright.dev viewer URL は anyone/read 公開時のみ生成されます。" + "チーム共有が必要なら Drive 上で個別共有するか、`--public` を付け直してください。", + file=sys.stderr, + ) + + print(f"kind: {result['kind']}") + print(f"file_id: {result['file_id']}") + print(f"Drive: {result['drive_view']}") + if result["direct_download"]: + print(f"Direct: {result['direct_download']}") + if result["playwright_trace_viewer"]: + print() + print("Playwright Trace Viewer URL (bug report に記載):") + print(f" {result['playwright_trace_viewer']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/ndf/skills/playwright-scenario-test/scripts/upload_md_as_gdoc.py b/plugins/ndf/skills/playwright-scenario-test/scripts/upload_md_as_gdoc.py new file mode 100644 index 00000000..af81eee8 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/scripts/upload_md_as_gdoc.py @@ -0,0 +1,47 @@ +"""Markdown ファイルを Google Drive にアップロードし、Google Docs に自動変換する。""" + +from __future__ import annotations + +import argparse +import os +import sys + +# このスクリプトを `python scripts/upload_md_as_gdoc.py` で直接実行できるように +# 自身のディレクトリを sys.path に追加 (`_drive_auth` を import するため) +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"] + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--md", required=True, help="Local markdown file") + p.add_argument("--parent", required=True, help="Drive parent folder ID") + p.add_argument("--name", default=None, help="Doc name (default: basename)") + args = p.parse_args() + + name = args.name or os.path.splitext(os.path.basename(args.md))[0] + service = drive_service(SCOPES) + + media = MediaFileUpload(args.md, mimetype="text/markdown", resumable=True) + meta = { + "name": name, + "mimeType": "application/vnd.google-apps.document", + "parents": [args.parent], + } + file = service.files().create( + body=meta, 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-scenario-test/templates/conftest.py.template b/plugins/ndf/skills/playwright-scenario-test/templates/conftest.py.template new file mode 100644 index 00000000..f231604e --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/templates/conftest.py.template @@ -0,0 +1,31 @@ +"""利用者プロジェクトの ``tests/conftest.py`` 雛形。 + +playwright-scenario-test (playwright_kit) を pytest-playwright 上で使うための +共通設定。plugin (``playwright_kit.pytest_plugin``) は entry-point 経由で +auto-load されるため、import や ``pytest_plugins`` 宣言は不要。 + +利用者は本ファイルをプロジェクトの ``conftest.py`` にコピーし、必要に応じて +fixture を追加する。 +""" + +from __future__ import annotations + +import pytest + +# pytest-playwright の標準 fixture (browser_context_args / page) は plugin 経由で +# 読み込まれる。playwright_kit 側 fixture (pwk_config / pwk_role_<id> / +# pwk_evidence / pwk_accessibility_scan / pwk_web_vitals_measure) も同様。 + +# 例: 全 test で headless=False にしたい場合は ``--headed`` を pytest に渡すか、 +# ``browser_type_launch_args`` を override する: +# +# @pytest.fixture(scope="session") +# def browser_type_launch_args(browser_type_launch_args): +# return {**browser_type_launch_args, "headless": False, "slow_mo": 200} + + +# プロジェクト固有の fixture をここに追加する。例: +# +# @pytest.fixture +# def admin_dashboard_url(pwk_config): +# return f"{pwk_config.base_url}/admin/dashboard" diff --git a/plugins/ndf/skills/playwright-scenario-test/templates/pyproject.toml.runtime b/plugins/ndf/skills/playwright-scenario-test/templates/pyproject.toml.runtime new file mode 100644 index 00000000..4577446b --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/templates/pyproject.toml.runtime @@ -0,0 +1,51 @@ +# scenario-test ランタイムプロジェクト用 pyproject.toml。 +# +# このファイルは Skill の `scripts/init_project.sh` によって利用者プロジェクトの +# `<runtime-dir>/pyproject.toml` として配置される。 +# Skill 開発用 (リポジトリルート相当) の `pyproject.toml` から、開発者向け設定 +# (optional-dependencies.dev など) を排除した「実行に必要な最小構成」。 + +[project] +name = "playwright_kit" +version = "0.5.0" +description = "scenario-test ランタイム — pytest-playwright 上で動く Web シナリオ E2E テスト (利用者プロジェクト埋め込み版)。" +requires-python = ">=3.11" +dependencies = [ + "playwright>=1.50,<2.0", + "pyyaml>=6.0", + # webm → mp4 変換用 (Google Drive プレビュアの安定性のため) + "imageio-ffmpeg>=0.4", + "pytest>=8.0", + "pytest-playwright>=0.5", + "pytest-xdist>=3.0", + # accessibility autouse は scenario.config.yaml で default 有効。 + # self-contained (Skill 非依存) を維持するため main dep に含める。 + "axe-playwright-python>=0.1.4", +] + +[project.optional-dependencies] +# Drive 連携: scripts/upload_evidence.py / gdrive_upload_dir.py 等が使用 +drive = [ + "google-api-python-client>=2.0", + "google-auth>=2.0", + "google-auth-oauthlib>=1.0", +] + +[project.entry-points."pytest11"] +# pytest が auto-discover する plugin entry-point。 +# `uv sync` 完了後 (editable install) は `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] +# all-in-one ランタイム配置では tests/ が runtime ディレクトリ直下にあるため、 +# 相対パス "tests" がそのまま効く。 +testpaths = ["tests"] +addopts = "-q" diff --git a/plugins/ndf/skills/playwright-scenario-test/templates/run.bat b/plugins/ndf/skills/playwright-scenario-test/templates/run.bat new file mode 100644 index 00000000..2f992862 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/templates/run.bat @@ -0,0 +1,95 @@ +@echo off +rem scenario-test ランタイムのワンコマンドランチャ (Windows 版)。 +rem +rem 使い方: +rem run.bat 全テスト実行 +rem run.bat -k test_login nodeid フィルタ +rem run.bat --pwk-overlay --pwk-drive-folder=<id> +rem run.bat --help help を表示 +rem +rem 動作: +rem 1) 自身が置かれているディレクトリを RUNTIME_DIR とし、CWD をそこに固定 +rem 2) .venv\ が無ければ `uv sync` + `playwright install chromium` を実行 +rem 3) `uv run pytest --pwk-config=%PWK_CONFIG%` を引数素通しで起動 +rem +rem 注意: +rem cmd.exe ネイティブの %* 展開ではスペースを含む引数 (例: -k "test login") の +rem クォートが正しく保持されません。スペース込みの引数を渡したい場合は +rem PowerShell から実行するか、引数を quote 不要な形に書き換えてください: +rem PowerShell> & .\run.bat -k 'test_login' (アンダースコア化を推奨) +rem PowerShell> & .\run.bat -m 'page_role' + +setlocal EnableExtensions EnableDelayedExpansion + +rem このスクリプト自身が置かれているディレクトリ +set "RUNTIME_DIR=%~dp0" +rem 末尾の \ を除去 +if "%RUNTIME_DIR:~-1%"=="\" set "RUNTIME_DIR=%RUNTIME_DIR:~0,-1%" + +rem --- help ------------------------------------------------------- +if "%~1"=="-h" goto :show_help +if "%~1"=="--help" goto :show_help +goto :run_steps + +:show_help +echo scenario-test ランタイムランチャ +echo. +echo 使い方: +echo run.bat [pytest 引数...] +echo. +echo 主な追加引数 (pytest にそのまま転送): +echo --pwk-config ^<path^> scenario.config.yaml のパス (env PWK_CONFIG でも可) +echo --pwk-out-dir ^<path^> 成果物出力先 (default: ./reports/^<run-id^>/) +echo --pwk-no-evidence HAR / trace / 動画 を OFF +echo --pwk-har-mode {minimal,full,none} +echo HAR 録画モード (default: minimal) +echo --pwk-overlay 動画に赤丸カーソル + 字幕 (旧名 HUD) を焼き込む +echo --pwk-drive-folder ^<id^> 終了後に成果物を Google Drive にアップロード +echo -k ^<expr^> nodeid 部分一致フィルタ +echo -m ^<expr^> marker フィルタ (page_role / role / phase 等) +echo --headed ブラウザを画面表示 (debug 用) +echo. +echo 環境変数: +echo PWK_CONFIG scenario.config.yaml のパス +echo. +exit /b 0 + +:run_steps + +rem --- 1) uv の存在確認 ------------------------------------------ +where uv >nul 2>&1 +if errorlevel 1 ( + echo [run.bat] uv が見つかりません。 + echo [run.bat] https://docs.astral.sh/uv/getting-started/installation/ を + echo [run.bat] 参照してインストールしてから再実行してください。 + exit /b 1 +) + +rem --- 2) 初回のみ uv sync + playwright install ------------------ +if not exist "%RUNTIME_DIR%\.venv" ( + echo [run.bat] 初回セットアップ: uv sync ^(%RUNTIME_DIR%^) + pushd "%RUNTIME_DIR%" + uv sync + if errorlevel 1 ( + popd + echo [run.bat] uv sync に失敗しました。 + exit /b 1 + ) + echo [run.bat] 初回セットアップ: playwright install chromium + uv run playwright install chromium + if errorlevel 1 ( + echo [run.bat] WARN: playwright install chromium に失敗しました。 + echo [run.bat] オフライン環境では PLAYWRIGHT_BROWSERS_PATH を共有 + echo [run.bat] キャッシュへ向ける運用を検討してください。 + ) + popd +) + +rem --- 3) pytest 実行 -------------------------------------------- +if "%PWK_CONFIG%"=="" set "PWK_CONFIG=.\scenario.config.yaml" + +pushd "%RUNTIME_DIR%" +uv run pytest --pwk-config="%PWK_CONFIG%" %* +set "EXITCODE=%ERRORLEVEL%" +popd +exit /b %EXITCODE% diff --git a/plugins/ndf/skills/playwright-scenario-test/templates/run.sh b/plugins/ndf/skills/playwright-scenario-test/templates/run.sh new file mode 100755 index 00000000..ea01aee2 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/templates/run.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# scenario-test ランタイムのワンコマンドランチャ。 +# +# 使い方: +# ./run.sh # 全テスト実行 +# ./run.sh -k test_login # nodeid フィルタ +# ./run.sh -m "page_role and role" # marker フィルタ +# ./run.sh --pwk-overlay --pwk-drive-folder=<id> +# ./run.sh --help # この help を表示 +# +# 動作: +# 1) 自身が置かれているディレクトリを RUNTIME_DIR とし、CWD をそこに固定 +# 2) `.venv/` が無ければ `uv sync` + `playwright install chromium` を実行 +# 3) `uv run pytest --pwk-config=$PWK_CONFIG` を引数素通しで起動 +# +# このスクリプトはディレクトリ名に依存しないため、 +# `--runtime-dir e2e` で任意名にリネームしてもそのまま動く。 +set -euo pipefail + +# このスクリプト自身が置かれているディレクトリ = ランタイムルート +RUNTIME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# --- help ---------------------------------------------------------- +case "${1:-}" in + -h|--help) + cat <<HELP +scenario-test ランタイムランチャ + +使い方: + $(basename "$0") [pytest 引数...] + +主な追加引数 (pytest にそのまま転送): + --pwk-config <path> scenario.config.yaml のパス (env PWK_CONFIG でも可) + --pwk-out-dir <path> 成果物出力先 (default: ./reports/<run-id>/) + --pwk-no-evidence HAR / trace / 動画 を OFF + --pwk-har-mode {minimal,full,none} + HAR 録画モード (default: minimal) + --pwk-overlay 動画に赤丸カーソル + 字幕 (旧名 HUD) を焼き込む + --pwk-drive-folder <id> 終了後に成果物を Google Drive にアップロード + -k <expr> nodeid 部分一致フィルタ + -m <expr> marker フィルタ (page_role / role / phase 等) + --headed ブラウザを画面表示 (debug 用) + +環境変数: + PWK_CONFIG scenario.config.yaml のパス。--pwk-config 未指定時に参照。 + +例: + $(basename "$0") -k test_login --pwk-overlay + $(basename "$0") -m "page_role" --pwk-drive-folder=ABCDEF + PWK_CONFIG=./alt.yaml $(basename "$0") +HELP + exit 0 + ;; +esac + +# --- 1) uv の存在確認 --------------------------------------------- +if ! command -v uv >/dev/null 2>&1; then + cat >&2 <<EOF +[run.sh] uv が見つかりません。 +https://docs.astral.sh/uv/getting-started/installation/ を参照して +インストールしてから再実行してください。 +EOF + exit 1 +fi + +# --- 2) 初回のみ uv sync + playwright install --------------------- +if [[ ! -d "$RUNTIME_DIR/.venv" ]]; then + echo "[run.sh] 初回セットアップ: uv sync ($RUNTIME_DIR)" + # サブシェル内の `set -euo pipefail` は外側に伝播しないため、 + # uv sync の終了コードを明示的に拾って失敗時に abort する。 + (cd "$RUNTIME_DIR" && uv sync) || { + echo "[run.sh] ERROR: uv sync に失敗しました。" >&2 + echo "[run.sh] ネットワーク / pyproject.toml / uv.lock を確認してください。" >&2 + exit 1 + } + echo "[run.sh] 初回セットアップ: playwright install chromium" + (cd "$RUNTIME_DIR" && uv run playwright install chromium) || { + echo "[run.sh] WARN: playwright install chromium に失敗しました。" + echo "[run.sh] オフライン環境では PLAYWRIGHT_BROWSERS_PATH を共有" + echo "[run.sh] キャッシュへ向ける運用を検討してください。" + } +fi + +# --- 3) pytest 実行 ------------------------------------------------ +cd "$RUNTIME_DIR" +exec uv run pytest \ + --pwk-config="${PWK_CONFIG:-./scenario.config.yaml}" \ + "$@" diff --git a/plugins/ndf/skills/playwright-scenario-test/templates/runtime-README.md b/plugins/ndf/skills/playwright-scenario-test/templates/runtime-README.md new file mode 100644 index 00000000..f3fc9abd --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/templates/runtime-README.md @@ -0,0 +1,121 @@ +# scenario-test ランタイム + +このディレクトリは利用者プロジェクトに埋め込まれた **playwright_kit** ベースの +Web シナリオ E2E テストランタイムです。Skill の有無に関係なく、このディレクトリ +**単体で動作**します。 + +## クイックスタート + +```bash +# 1) このディレクトリ (scenario-test/) で実行 +./run.sh + +# 2) プロジェクトルートから実行 +./scenario-test/run.sh + +# Windows +run.bat +``` + +初回は `uv sync` と `playwright install chromium` が自動実行されます (数分)。 +2 回目以降はキャッシュ済 `.venv` を使うので即起動します。 + +## ディレクトリ構成 + +``` +scenario-test/ +├── playwright_kit/ ← Python パッケージ本体 +├── scripts/ ← 補助 CLI (classify_page_role / accessibility / web_vitals / drive 等) +├── tests/ ← 利用者が書く pytest テスト +│ ├── conftest.py +│ └── test_*.py +├── reports/ ← 実行結果 (gitignore 推奨) +├── scenario.config.yaml ← base_url / roles / accessibility / web_vitals 等 +├── run.sh ← Linux/Mac 用ワンコマンドランチャ +├── run.bat ← Windows 用ワンコマンドランチャ +├── pyproject.toml ← runtime 用 (testpaths=tests) +├── uv.lock ← 依存 lockfile (commit して再現性を確保) +└── README.md ← 本ファイル +``` + +## 設定 + +`scenario.config.yaml` で以下を編集します。 + +- `target.base_url`: テスト対象 URL +- `target.basic_auth`: Basic 認証 (省略可) +- `roles`: ログインロール定義 (id / login.path / login.fields) +- `playwright`: viewport / step delay / overlay / trace / HAR mode +- `accessibility`: axe-core 自動スキャン (page_role による起動条件) +- `web_vitals`: Core Web Vitals 自動計測 (page_role による起動条件) +- `body_check`: PHP / SSR エラー文字列検出 (default 有効) + +## テストの書き方 + +`tests/test_*.py` で以下のように pytest-playwright スタイルで書きます。 + +```python +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.page_role("dashboard") +@pytest.mark.role("admin") +def test_admin_dashboard(page: Page, pwk_role_admin, pwk_config): + page.goto(f"{pwk_config.base_url}/admin", wait_until="domcontentloaded") + expect(page.get_by_role("heading", name="ダッシュボード")).to_be_visible() +``` + +`@pytest.mark.page_role(...)` を付与すると accessibility (axe-core) と +web vitals (LCP/CLS/TTFB) が **autouse で自動実行** されます。 + +## 主要 fixture + +- `pwk_config`: `scenario.config.yaml` をロードした `Config` オブジェクト +- `pwk_role_<id>`: 定義済 role でログイン済の `storage_state` を inject +- `pwk_evidence`: HAR / trace / console / pageerror collector +- `pwk_accessibility_scan()`: 明示的に axe-core を 1 回実行 +- `pwk_web_vitals_measure()`: 明示的に Web Vitals を 1 回計測 +- `pwk_body_check_scan()`: 明示的に body_check を 1 回実行 + +## 主要 CLI option + +```bash +./run.sh \ + --pwk-config=./scenario.config.yaml \ + --pwk-out-dir=./reports/manual-run/ \ + --pwk-overlay \ + --pwk-drive-folder=<DRIVE_FOLDER_ID> \ + -k test_admin -m page_role +``` + +- `--pwk-config <path>`: 設定ファイル (env `PWK_CONFIG` でも上書き可) +- `--pwk-out-dir <path>`: 成果物出力先 (default: `./reports/<run-id>/`) +- `--pwk-no-evidence`: HAR / trace / 動画を OFF +- `--pwk-har-mode {minimal,full,none}`: HAR 録画モード +- `--pwk-overlay`: 動画に赤丸カーソル + 字幕を焼き込む +- `--pwk-drive-folder <id>`: 終了後に成果物を Google Drive にアップロード + +## 補助スクリプト + +`scripts/` 配下に CLI ヘルパが置かれています。 + +- `scripts/classify_page_role.py <url>`: accessibility tree から page role を推定 +- `scripts/run_a11y_scan.py <url>`: axe-core で違反を検出 +- `scripts/check_cwv.py <url>`: Core Web Vitals を計測 +- `scripts/upload_evidence.py <path>`: trace / HAR / 動画を Drive にアップ + +## トラブルシューティング + +| 症状 | 対処 | +|---|---| +| `uv が見つかりません` | https://docs.astral.sh/uv/ を参照してインストール | +| `playwright install chromium` が失敗 | オフライン環境なら `PLAYWRIGHT_BROWSERS_PATH` を共有キャッシュへ向ける | +| pytest plugin が discover されない | `cd scenario-test && uv sync` を再実行。または `tests/conftest.py` に `pytest_plugins = ["playwright_kit.pytest_plugin"]` を追加 | +| 成果物 `reports/` の場所が想定と違う | `--pwk-out-dir` で明示的に指定する | + +## ドキュメント + +詳細な使い方は (このディレクトリの元になった) Skill ディレクトリの +`SKILL.md` / `docs/` を参照してください。Skill が利用者環境にない場合は +ソース管理リポジトリで参照可能です。 diff --git a/plugins/ndf/skills/playwright-scenario-test/templates/runtime-gitignore b/plugins/ndf/skills/playwright-scenario-test/templates/runtime-gitignore new file mode 100644 index 00000000..97115181 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/templates/runtime-gitignore @@ -0,0 +1,28 @@ +# scenario-test ランタイム用 .gitignore。 +# +# `playwright_kit/` パッケージ本体・`scripts/` 補助スクリプト・`tests/` 利用者 +# テスト・`pyproject.toml` / `uv.lock` / `scenario.config.yaml` / `run.sh` / +# `run.bat` / `README.md` は commit してよい (再現性のため逆に推奨)。 +# 一方、ローカル仮想環境とテスト出力物は当然 ignore する。 + +# Python 仮想環境 (uv sync で作られる) +.venv/ +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ + +# テスト実行で生成される成果物 (HAR / trace / 動画 / report.md / body_check.jsonl) +reports/ + +# 認証情報 (うっかり commit 防止) +.env +.env.* +!.env.example +*.local + +# OS / IDE +.DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/plugins/ndf/skills/playwright-scenario-test/templates/scenario.config.yaml b/plugins/ndf/skills/playwright-scenario-test/templates/scenario.config.yaml new file mode 100644 index 00000000..0f749ab0 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/templates/scenario.config.yaml @@ -0,0 +1,145 @@ +# scenario-test 共通設定 (pytest-playwright) +# +# 使い方: +# cp scenario.config.yaml ./scenario.config.yaml +# uv run pytest --pwk-config=./scenario.config.yaml +# +# pytest が起動する CWD に置けば ``--pwk-config`` 省略も可能。 +# 認証情報を含むため .gitignore 推奨。 +# +# ⚠️ 認証情報の管理: +# YAML に直接書かず ${ENV_VAR} / ${ENV_VAR:-default} で参照してください。 +# .env ファイルや shell export で環境変数を設定して使用します。 +# 例: export ADMIN_PASSWORD=secret または direnv / .env ファイルを活用 + +# --- 接続先 ---------------------------------------------------------- +target: + base_url: https://example.com + + # サイト全体に Basic 認証が掛かっている場合のみ指定 (掛かっていなければ削除可)。 + # 省略時は空文字列が入り、role 側で `requires_basic_auth: true` を指定した + # role のみが Basic 認証ヘッダを要求する。 + # basic_auth: + # user: ${BASIC_AUTH_USER} + # password: ${BASIC_AUTH_PASSWORD} + +# --- TLS 検査 (false で curl -k 相当) --------------------------- +verify_tls: false + +# --- ロール別ログイン情報 --------------------------------------- +# pytest テストから ``pwk_role_<id>`` fixture で参照する +roles: + admin: + label: 管理者 + login: + path: /admin/login + requires_basic_auth: true # /admin/ 配下のみ Basic 認証ゲートが掛かる場合 + fields: + # 認証情報は環境変数で管理する (YAML 直書き禁止) + # export ADMIN_LOGIN_ID=admin@example.com + # export ADMIN_PASSWORD=secret + LoginID: ${ADMIN_LOGIN_ID} + Password: ${ADMIN_PASSWORD} + # 任意: 独自フォーム名がある場合の優先セレクタ。省略時は role/type=submit + # の汎用フォールバックを fixture が試行する。 + # submit_selectors: + # - 'form[name="MyAdminLoginForm"] button[type="submit"]' + fail_if_url_contains: /admin/login + + user: + label: 一般ユーザ + login: + path: /user/login + requires_basic_auth: false + fields: + # export USER_LOGIN_ID=user@example.com + # export USER_PASSWORD=secret + LoginID: ${USER_LOGIN_ID} + Password: ${USER_PASSWORD} + fail_if_url_contains: /user/login + +# --- Playwright 設定 ----------------------------------------------- +playwright: + headless: true + # 1280x720 = HD 720p (16:9)。Google Drive のプレイヤで transcoding 不要で + # 即時再生しやすい標準解像度。video_size と必ず一致させる。 + viewport: + width: 1280 + height: 720 + slow_mo_ms: 0 + video_size: + width: 1280 + height: 720 + navigation_timeout_ms: 30000 + # 各ナビゲーション完了後にこの ms だけ待機する (動画用)。 + step_delay_ms: 1800 + enable_overlay: true + enable_trace: true + # mp4 推奨 (Drive プレビュアで再生互換性が高い)。webm にすると変換せず保存。 + video_format: mp4 + +# --- 許容する console.error / pageerror パターン ------------- +# 3rd party SDK の既知 warning など、プロジェクト責任外の noise を許容するときに +# 正規表現で除外できる。マッチした行は ``pwk_evidence`` の集計から除かれる。 +tolerated_console_errors: [] +tolerated_page_errors: [] +# 例: +# tolerated_console_errors: +# - "Failed to load resource: the server responded with a status of 404" +# - "ResizeObserver loop limit exceeded" +# tolerated_page_errors: +# - "ChunkLoadError" + +# --- accessibility (axe-core) 自動スキャン ------------------------------------ +# ``@pytest.mark.page_role("form")`` 等が付いた test の終了直前に axe-core を +# 自動実行する (page_role が auto_roles にマッチした場合のみ)。 +accessibility: + enabled: true + auto_roles: [lp, list, form, dashboard, cart, checkout, settings, auth] + tags: [wcag2a, wcag2aa, wcag21aa, wcag22aa] + fail_on_violations: true + +# --- Core Web Vitals (web_vitals) 自動計測 ---------------------------------------- +# page_role が auto_roles にマッチする test の終了直前に LCP/CLS/TTFB/longest_task +# を observe_ms ミリ秒観察する。poor が 1 件でもあれば fail_on_poor=true なら FAIL。 +web_vitals: + enabled: true + auto_roles: [lp, list, dashboard, search] + observe_ms: 5000 + fail_on_poor: true + +# --- body_check (PHP / SSR エラー検出, v0.4.0) ----------------------- +# console.error / pageerror では拾えない、サーバ側で HTML 本文に直接出力された +# "Fatal error" / "Warning:" 等のエラー文字列を検出する。 +# - default: enabled=true + PHP 系デフォルトパターン内蔵 (config を書かなくても効く) +# - autouse: page fixture を要求する test に対して page.on("response") 経由で +# 全 HTML レスポンスを監視。@pytest.mark.no_body_check で個別 opt-out 可能 +# - 各 test 終了時に case_dir/body_check.jsonl に違反詳細を出力 +# - 一切無効化したい場合は body_check.enabled: false を明示 +# - パターンを上書きしたい場合のみ下記キーを書く (省略すれば内蔵 default が効く) +# body_check: +# enabled: true +# # 本文全体への substring match (1 つでも含まれれば violation) +# fatal_patterns: +# - "Fatal error" +# - "Uncaught" +# - "Parse error" +# # 本文の先頭 warning_head_chars 文字のみ走査 +# # (本文中の説明文や入力例の "Notice:" 等を許容) +# warning_patterns: +# - "STRICT:" +# - "Warning:" +# - "Notice:" +# - "Deprecated:" +# warning_head_chars: 300 # (旧名 warning_head_bytes も alias で受理) +# not_found_patterns: +# - "File not found" +# fail_on_match: true # false にすると情報収集モード (PASS のまま report に出力) + +# --- レポート設定 ----------------------------------------------------- +# pytest_terminal_summary が ``reports/<run-id>/report.md`` に書き出す +# Markdown のタイトル等。 +report: + title: "シナリオ E2E テスト 実施報告書" + test_plan_link: "./test-plan.md" + phase_labels: {} diff --git a/plugins/ndf/skills/playwright-scenario-test/templates/test_auth.py.template b/plugins/ndf/skills/playwright-scenario-test/templates/test_auth.py.template new file mode 100644 index 00000000..95618649 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/templates/test_auth.py.template @@ -0,0 +1,35 @@ +"""認証 (auth) 系テストの雛形。 + +ログインフロー単体の検証。NDF が提供する ``pwk_role_<id>`` fixture で +``page`` に storage_state を inject 済みなので、test 関数は遷移先の URL や +画面要素のみ検証すればよい。 + +実行: + uv run pytest tests/test_auth.py --pwk-config=./scenario.config.yaml +""" + +from __future__ import annotations + +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.page_role("auth") +@pytest.mark.role("admin") +@pytest.mark.phase(1) +def test_admin_login_redirects_to_dashboard( + page: Page, pwk_role_admin, pwk_config, pwk_evidence +): + """admin login 後、/admin/dashboard が表示されること。""" + page.goto(f"{pwk_config.base_url}/admin/dashboard", wait_until="domcontentloaded") + expect(page).to_have_url(lambda u: "/admin/dashboard" in u) + expect(page.get_by_role("heading", name="ダッシュボード")).to_be_visible() + + +@pytest.mark.page_role("auth") +def test_logout_clears_session(page: Page, pwk_role_admin, pwk_config): + """logout 後にログインページへ遷移し、保護リソースに直接アクセスすると + 再ログインを要求されること。""" + page.goto(f"{pwk_config.base_url}/logout", wait_until="domcontentloaded") + page.goto(f"{pwk_config.base_url}/admin/dashboard", wait_until="domcontentloaded") + expect(page).to_have_url(lambda u: "/login" in u) diff --git a/plugins/ndf/skills/playwright-scenario-test/templates/test_dashboard.py.template b/plugins/ndf/skills/playwright-scenario-test/templates/test_dashboard.py.template new file mode 100644 index 00000000..8e18a294 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/templates/test_dashboard.py.template @@ -0,0 +1,25 @@ +"""ダッシュボード (dashboard) 系テストの雛形。 + +KPI / グラフ / リンクの存在と遷移を検証。``page_role("dashboard")`` で +accessibility / web vitals autouse される。 +""" + +from __future__ import annotations + +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.page_role("dashboard") +@pytest.mark.role("admin") +def test_admin_dashboard_kpi_cards(page: Page, pwk_role_admin, pwk_config): + page.goto(f"{pwk_config.base_url}/admin/dashboard", wait_until="domcontentloaded") + expect(page.get_by_role("heading", name="売上サマリ")).to_be_visible() + expect(page.get_by_role("heading", name="新規ユーザ")).to_be_visible() + + +@pytest.mark.page_role("dashboard") +def test_dashboard_link_to_user_list(page: Page, pwk_role_admin, pwk_config): + page.goto(f"{pwk_config.base_url}/admin/dashboard", wait_until="domcontentloaded") + page.get_by_role("link", name="ユーザ管理").click() + expect(page).to_have_url(lambda u: "/admin/users" in u) diff --git a/plugins/ndf/skills/playwright-scenario-test/templates/test_form.py.template b/plugins/ndf/skills/playwright-scenario-test/templates/test_form.py.template new file mode 100644 index 00000000..b7c25ee9 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/templates/test_form.py.template @@ -0,0 +1,33 @@ +"""フォーム (form) 系テストの雛形。 + +入力 → 送信 → 結果検証の正常系を 1 ファイル 1 シナリオで書く。 +``page_role("form")`` 付与で accessibility autouse。 +""" + +from __future__ import annotations + +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.page_role("form") +@pytest.mark.role("admin") +def test_create_user_happy_path(page: Page, pwk_role_admin, pwk_config): + page.goto(f"{pwk_config.base_url}/admin/users/new", wait_until="domcontentloaded") + + page.get_by_label("メールアドレス").fill("new.user@example.com") + page.get_by_label("氏名").fill("テスト太郎") + page.get_by_role("button", name="保存").click() + + expect(page.get_by_role("alert")).to_contain_text("作成しました") + expect(page).to_have_url(lambda u: "/admin/users" in u) + + +@pytest.mark.page_role("form") +def test_create_user_validation_blocks_empty_submit( + page: Page, pwk_role_admin, pwk_config +): + page.goto(f"{pwk_config.base_url}/admin/users/new", wait_until="domcontentloaded") + page.get_by_role("button", name="保存").click() + expect(page.get_by_text("メールアドレスは必須です")).to_be_visible() + expect(page).to_have_url(lambda u: "/users/new" in u) diff --git a/plugins/ndf/skills/playwright-scenario-test/templates/test_list.py.template b/plugins/ndf/skills/playwright-scenario-test/templates/test_list.py.template new file mode 100644 index 00000000..557f1596 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/templates/test_list.py.template @@ -0,0 +1,28 @@ +"""一覧 (list) 系テストの雛形。 + +ページネーション / ソート / 検索の正常系を検証。``page_role("list")`` 付与で +accessibility / web vitals が autouse される。 +""" + +from __future__ import annotations + +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.page_role("list") +@pytest.mark.role("admin") +def test_user_list_first_page(page: Page, pwk_role_admin, pwk_config): + page.goto(f"{pwk_config.base_url}/admin/users", wait_until="domcontentloaded") + expect(page.get_by_role("heading", name="ユーザ一覧")).to_be_visible() + + # 先頭ページに少なくとも 1 行表示されること + rows = page.get_by_role("row") + expect(rows).not_to_have_count(0) + + +@pytest.mark.page_role("list") +def test_user_list_pagination(page: Page, pwk_role_admin, pwk_config): + page.goto(f"{pwk_config.base_url}/admin/users", wait_until="domcontentloaded") + page.get_by_role("link", name="次へ").click() + expect(page).to_have_url(lambda u: "page=2" in u) diff --git a/plugins/ndf/skills/playwright-scenario-test/tests/__init__.py b/plugins/ndf/skills/playwright-scenario-test/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/ndf/skills/playwright-scenario-test/tests/conftest.py b/plugins/ndf/skills/playwright-scenario-test/tests/conftest.py new file mode 100644 index 00000000..0a84bea4 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/tests/conftest.py @@ -0,0 +1,23 @@ +"""tests/ から scripts/ と playwright_kit/ の両方を import 可能にする path 設定。 + +playwright_kit はパッケージなので import 可能。scripts/ 配下の単発スクリプト +(upload_evidence.py 等) はパッケージ化されていないため sys.path 追加が必要。 +pytest plugin の自己テスト用に ``pytester`` を有効化する。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# ``pytester`` fixture (test 内で別 pytest を実行するためのサンドボックス) を有効化。 +# playwright_kit.pytest_plugin の addoption / markers / fixture 動的登録を +# 隔離環境で検証するために使う。 +pytest_plugins = ["pytester"] + +_SKILL_ROOT = Path(__file__).resolve().parent.parent +_SCRIPTS_DIR = _SKILL_ROOT / "scripts" + +for p in (str(_SKILL_ROOT), str(_SCRIPTS_DIR)): + if p not in sys.path: + sys.path.insert(0, p) diff --git a/plugins/ndf/skills/playwright-scenario-test/tests/test_a11y_cwv_routing.py b/plugins/ndf/skills/playwright-scenario-test/tests/test_a11y_cwv_routing.py new file mode 100644 index 00000000..67b31804 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/tests/test_a11y_cwv_routing.py @@ -0,0 +1,80 @@ +"""accessibility / web_vitals モジュールの page_role 自動判定と Web Vitals 閾値判定のテスト。""" + +from __future__ import annotations + +from playwright_kit import accessibility, web_vitals + + +class TestA11yIsAvailable: + def test_returns_bool(self): + # Maj-9: 環境に依存するが、bool が返ることを最低保証 + assert isinstance(accessibility.is_available(), bool) + + +class TestA11yAutoScan: + def test_lp_triggers(self): + assert accessibility.should_auto_scan(["lp"]) is True + + def test_form_triggers(self): + assert accessibility.should_auto_scan(["form"]) is True + + def test_modal_does_not_trigger_by_default(self): + # modal は変動的なので自動スキャン対象外 (DEFAULT_AUTO_ROLES にない) + assert accessibility.should_auto_scan(["modal"]) is False + + def test_multiple_roles_any_match(self): + assert accessibility.should_auto_scan(["modal", "form"]) is True + + def test_empty_list(self): + assert accessibility.should_auto_scan([]) is False + + def test_custom_auto_roles(self): + assert accessibility.should_auto_scan(["custom"], auto_roles=frozenset({"custom"})) is True + + +class TestCwvAutoMeasure: + def test_lp_triggers(self): + assert web_vitals.should_auto_measure(["lp"]) is True + + def test_form_does_not_trigger(self): + # form はインタラクション主体なので CWV 自動計測対象外 + assert web_vitals.should_auto_measure(["form"]) is False + + +class TestCwvJudge: + def test_lcp_good(self): + assert web_vitals.judge("lcp_ms", 2000) == "good" + + def test_lcp_needs_improvement(self): + assert web_vitals.judge("lcp_ms", 3000) == "needs-improvement" + + def test_lcp_poor(self): + assert web_vitals.judge("lcp_ms", 5000) == "poor" + + def test_cls_good(self): + assert web_vitals.judge("cls", 0.05) == "good" + + def test_cls_poor(self): + assert web_vitals.judge("cls", 0.5) == "poor" + + def test_unknown_metric(self): + assert web_vitals.judge("foo", 1.0) == "unknown" + + def test_none_value(self): + assert web_vitals.judge("lcp_ms", None) == "unknown" + + +class TestCwvPassed: + def test_all_good(self): + assert web_vitals.passed({"lcp_ms": 2000, "cls": 0.05}) is True + + def test_needs_improvement_still_passes(self): + # poor でなければ pass + assert web_vitals.passed({"lcp_ms": 3000}) is True + + def test_poor_fails(self): + assert web_vitals.passed({"lcp_ms": 5000}) is False + + def test_empty_metrics_passes(self): + # 計測失敗 (空 dict) は判定不能なので fail にはしない + assert web_vitals.passed({}) is True diff --git a/plugins/ndf/skills/playwright-scenario-test/tests/test_auth_cache.py b/plugins/ndf/skills/playwright-scenario-test/tests/test_auth_cache.py new file mode 100644 index 00000000..f63783fe --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/tests/test_auth_cache.py @@ -0,0 +1,242 @@ +"""auth fixture の cache hit/miss 検証 + Amazon Q Critical-1/2 の回帰テスト。 + +_login_and_get_storage_state を monkeypatch で fake し、Playwright 実機不要で: +- 1 つ目の test では 1 回呼ばれる (cache miss) +- 2 つ目の test では呼ばれない (cache hit) +を確認する (Codex Major 5)。 + +Amazon Q Critical-1: fail_if_url_contains が空文字列の場合に全 login が失敗しない +Amazon Q Critical-2: context.close() 例外で browser.close() がスキップされない +の回帰テストも含む。 +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch, call + +import pytest + +from playwright_kit.fixtures.auth import ( + _StorageStateCache, + _login_and_get_storage_state, +) +from playwright_kit.config import Login, Role + + +# --------------------------------------------------------------------------- +# _StorageStateCache の単体テスト +# --------------------------------------------------------------------------- + + +def test_storage_state_cache_miss_then_hit(): + """cache miss → put → hit の流れを検証。""" + cache = _StorageStateCache.empty() + assert cache.get("admin") is None # miss + + state = {"cookies": [{"name": "session", "value": "abc"}], "origins": []} + cache.put("admin", state) + + assert cache.get("admin") == state # hit + assert cache.get("user") is None # 別 role は miss のまま + + +def test_storage_state_cache_multiple_roles(): + cache = _StorageStateCache.empty() + cache.put("admin", {"cookies": [], "origins": []}) + cache.put("user", {"cookies": [{"name": "u"}], "origins": []}) + assert cache.get("admin") is not None + assert cache.get("user") is not None + assert cache.get("guest") is None + + +# --------------------------------------------------------------------------- +# Amazon Q Critical-1: fail_if_url_contains 空文字列の回帰テスト +# --------------------------------------------------------------------------- + + +def _make_role(fail_if_url_contains: str) -> Role: + return Role( + id="testuser", + label="Test User", + login=Login( + path="/login", + requires_basic_auth=False, + fields={"username": "u", "password": "p"}, + fail_if_url_contains=fail_if_url_contains, + ), + ) + + +def test_fail_if_url_contains_empty_string_does_not_fail(monkeypatch): + """fail_if_url_contains が空文字列のとき login が失敗しないこと (Amazon Q Critical-1)。 + + 空文字列 "" はあらゆる URL に含まれるため、従来コードでは常に pytest.fail() していた。 + 修正後は空文字列の場合はチェックをスキップする。 + """ + role = _make_role(fail_if_url_contains="") + + # Playwright の各オブジェクトを mock する + fake_page = MagicMock() + fake_page.url = "https://example.com/dashboard" + + fake_context = MagicMock() + fake_context.new_page.return_value = fake_page + fake_context.storage_state.return_value = {"cookies": [], "origins": []} + + fake_browser = MagicMock() + fake_browser.new_context.return_value = fake_context + + fake_playwright = MagicMock() + fake_playwright.chromium.launch.return_value = fake_browser + + # pytest.fail が呼ばれないことを確認 + result = _login_and_get_storage_state( + playwright=fake_playwright, + base_url="https://example.com", + role=role, + basic_auth_user="", + basic_auth_password="", + verify_tls=False, + ) + assert result == {"cookies": [], "origins": []} + + +def test_fail_if_url_contains_non_empty_triggers_on_match(monkeypatch): + """fail_if_url_contains が非空で URL にマッチするとき pytest.fail が呼ばれる。""" + role = _make_role(fail_if_url_contains="/login") + + fake_page = MagicMock() + fake_page.url = "https://example.com/login?error=1" # login ページに留まっている + + fake_context = MagicMock() + fake_context.new_page.return_value = fake_page + + fake_browser = MagicMock() + fake_browser.new_context.return_value = fake_context + + fake_playwright = MagicMock() + fake_playwright.chromium.launch.return_value = fake_browser + + with pytest.raises(pytest.fail.Exception): + _login_and_get_storage_state( + playwright=fake_playwright, + base_url="https://example.com", + role=role, + basic_auth_user="", + basic_auth_password="", + verify_tls=False, + ) + + +# --------------------------------------------------------------------------- +# Amazon Q Critical-2: browser.close() リソースリーク回帰テスト +# --------------------------------------------------------------------------- + + +def test_browser_closed_even_when_context_close_raises(monkeypatch): + """context.close() が例外を投げても browser.close() が必ず呼ばれること (Amazon Q Critical-2)。 + + 新実装では context.close() の例外は finally 内で握りつぶされるため、 + 呼び出し元には伝播しない。storage_state は正常に返ること、 + かつ browser.close() が呼ばれることを検証する。 + """ + role = _make_role(fail_if_url_contains="") + + fake_page = MagicMock() + fake_page.url = "https://example.com/dashboard" + + fake_context = MagicMock() + fake_context.new_page.return_value = fake_page + fake_context.storage_state.return_value = {"cookies": [], "origins": []} + fake_context.close.side_effect = RuntimeError("context close failed") + + fake_browser = MagicMock() + fake_browser.new_context.return_value = fake_context + + fake_playwright = MagicMock() + fake_playwright.chromium.launch.return_value = fake_browser + + # context.close() が例外を投げても、新実装では握りつぶされて + # storage_state が正常に返ること + result = _login_and_get_storage_state( + playwright=fake_playwright, + base_url="https://example.com", + role=role, + basic_auth_user="", + basic_auth_password="", + verify_tls=False, + ) + assert result == {"cookies": [], "origins": []} + + # browser.close() が呼ばれていること + fake_browser.close.assert_called_once() + + +def test_browser_closed_when_goto_fails(monkeypatch): + """page.goto() が失敗して pytest.fail() が呼ばれても browser.close() されること (AQ Critical-2 完遂)。 + + goto / fill / expect_navigation / fail_if_url_contains の失敗は + いずれも pytest.fail() を raise し、旧実装では末尾 finally に到達しなかった。 + 新実装では関数全体を browser try/finally で囲んでいるため browser.close() が保証される。 + """ + role = _make_role(fail_if_url_contains="") + + fake_page = MagicMock() + # goto() が例外を投げる + fake_page.goto.side_effect = Exception("network error") + + fake_context = MagicMock() + fake_context.new_page.return_value = fake_page + + fake_browser = MagicMock() + fake_browser.new_context.return_value = fake_context + + fake_playwright = MagicMock() + fake_playwright.chromium.launch.return_value = fake_browser + + # pytest.fail() が発生する (goto 失敗) + with pytest.raises(pytest.fail.Exception): + _login_and_get_storage_state( + playwright=fake_playwright, + base_url="https://example.com", + role=role, + basic_auth_user="", + basic_auth_password="", + verify_tls=False, + ) + + # browser.close() が呼ばれていること + fake_browser.close.assert_called_once() + + +def test_browser_closed_when_fail_if_url_contains_triggers(monkeypatch): + """fail_if_url_contains でログイン失敗判定しても browser.close() されること (AQ Critical-2 完遂)。""" + role = _make_role(fail_if_url_contains="/login") + + fake_page = MagicMock() + fake_page.url = "https://example.com/login?error=1" # login ページに残留 + + fake_context = MagicMock() + fake_context.new_page.return_value = fake_page + fake_context.storage_state.return_value = {"cookies": [], "origins": []} + + fake_browser = MagicMock() + fake_browser.new_context.return_value = fake_context + + fake_playwright = MagicMock() + fake_playwright.chromium.launch.return_value = fake_browser + + with pytest.raises(pytest.fail.Exception): + _login_and_get_storage_state( + playwright=fake_playwright, + base_url="https://example.com", + role=role, + basic_auth_user="", + basic_auth_password="", + verify_tls=False, + ) + + # fail_if_url_contains でも browser.close() が呼ばれること + fake_browser.close.assert_called_once() diff --git a/plugins/ndf/skills/playwright-scenario-test/tests/test_body_check.py b/plugins/ndf/skills/playwright-scenario-test/tests/test_body_check.py new file mode 100644 index 00000000..f61f6b55 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/tests/test_body_check.py @@ -0,0 +1,394 @@ +"""body_check (v0.4.0) の純粋関数 / config / fixture ロジックのユニットテスト。 + +Playwright を起動しない部分のみ。E2E は Phase 3 以降の smoke で別途検証する。 +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from playwright_kit.body_check import ( + BodyViolation, + is_html_response, + scan_body, +) +from playwright_kit.config import ( + AccessibilityConfig, + BasicAuth, + BodyCheckConfig, + Config, + WebVitalsConfig, + PlaywrightConfig, + ReportConfig, + RunnerConfig, + _body_check_from_raw, +) +from playwright_kit.fixtures.body_check import ( + _build_response_handler, + _format_violation_summary, + _write_jsonl, +) +from playwright_kit.fixtures.evidence import PwkEvidence + + +# --------------------------------------------------------------------- +# scan_body +# --------------------------------------------------------------------- + + +def test_scan_body_detects_fatal_anywhere_in_body(): + body = "<html><body><p>ok</p>...<br>Fatal error: bang</body></html>" + violations = scan_body( + body, + url="https://e.example/x.php", + fatal_patterns=["Fatal error", "Uncaught"], + ) + assert len(violations) == 1 + v = violations[0] + assert v.category == "fatal" + assert v.pattern == "Fatal error" + assert "Fatal error: bang" in v.snippet + + +def test_scan_body_warning_only_in_head_chars(): + """warning_patterns は head N 文字 (code points) に限ってマッチさせる。""" + head_pollution = "STRICT: warning at top\n" + ("a" * 1000) + " STRICT: deep" + violations = scan_body( + head_pollution, + url="https://e.example/u.php", + warning_patterns=["STRICT:"], + warning_head_chars=100, + ) + # head のヒットだけ拾う (deep 側は無視) + assert len(violations) == 1 + assert violations[0].category == "warning" + assert violations[0].pattern == "STRICT:" + + +def test_scan_body_warning_head_uses_code_points_not_bytes(): + """warning_head_chars は **文字数** (code point) で切る。 + + PLAN18 のフィールド名は ``warning_head_bytes`` だが、説明文は + 「先頭 300 文字」と書かれており、実用的にも日本語ページで bytes だと + 100 字しか見られないため文字数を採用している。 + 日本語 (3 byte/字) を 200 字並べた後 ``Notice:`` を置いても、head + 閾値 300 文字の中に収まれば検出できる。 + """ + body = ("あ" * 200) + "Notice: header leak" + violations = scan_body( + body, + url="https://e.example/jp.php", + warning_patterns=["Notice:"], + warning_head_chars=300, + ) + # 文字数換算なので head 内 (200 字 < 300 字) + assert len(violations) == 1 + assert violations[0].pattern == "Notice:" + + +def test_scan_body_warning_head_excludes_beyond_chars(): + """head 閾値より後ろにある warning は拾わない (文字数換算で正しく弾く)。""" + body = ("a" * 350) + "Notice: too late" + violations = scan_body( + body, + url="https://e.example/x.php", + warning_patterns=["Notice:"], + warning_head_chars=300, + ) + assert violations == [] + + +def test_scan_body_warning_ignored_when_only_in_body_tail(): + """warning_patterns が head に出ていなければ拾わない (説明文の Notice: は許容)。""" + body = ("a" * 500) + "Notice: something explanatory mid-body" + violations = scan_body( + body, + url="https://e.example/u.php", + warning_patterns=["Notice:"], + warning_head_chars=300, + ) + assert violations == [] + + +def test_scan_body_not_found_anywhere(): + body = "<html>... File not found ...</html>" + violations = scan_body( + body, + url="https://e.example/x.php", + not_found_patterns=["File not found"], + ) + assert len(violations) == 1 + assert violations[0].category == "not_found" + + +def test_scan_body_returns_empty_for_empty_body(): + assert scan_body("", url="https://e/x", fatal_patterns=["Fatal error"]) == [] + + +def test_scan_body_ignores_empty_pattern_strings(): + """空文字列パターンは false-positive を防ぐため無視。""" + body = "anything" + assert scan_body(body, url="x", fatal_patterns=["", " "]) == [] + + +def test_scan_body_handles_multiple_categories(): + body = "STRICT: top\nFatal error inside\nFile not found at end" + violations = scan_body( + body, + url="https://e.example/y.php", + fatal_patterns=["Fatal error"], + warning_patterns=["STRICT:"], + not_found_patterns=["File not found"], + warning_head_chars=300, + ) + cats = {v.category for v in violations} + assert cats == {"fatal", "warning", "not_found"} + + +def test_body_violation_to_dict_round_trip(): + v = BodyViolation( + url="https://e/x", category="fatal", pattern="Fatal error", snippet="snip" + ) + d = v.to_dict() + assert d == { + "url": "https://e/x", + "category": "fatal", + "pattern": "Fatal error", + "snippet": "snip", + } + + +# --------------------------------------------------------------------- +# is_html_response +# --------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "ctype,expected", + [ + ("text/html", True), + ("text/html; charset=utf-8", True), + ("Application/XHTML+XML", True), + ("application/json", False), + ("image/png", False), + ("", False), + (None, False), + ], +) +def test_is_html_response(ctype, expected): + assert is_html_response(ctype) is expected + + +# --------------------------------------------------------------------- +# BodyCheckConfig loader +# --------------------------------------------------------------------- + + +def test_body_check_from_raw_defaults_when_empty(): + """body_check セクション未記述時は dataclass の default 値が効く。 + + config を書かなくても PHP プロジェクトで素直に動くよう、default で + enabled=True + PHP 系パターンを内蔵する。 + """ + cfg = _body_check_from_raw({}) + assert cfg.enabled is True + assert cfg.warning_head_chars == 300 + assert cfg.fail_on_match is True + # 内蔵 PHP 系 default パターン + assert "Fatal error" in cfg.fatal_patterns + assert "STRICT:" in cfg.warning_patterns + assert "File not found" in cfg.not_found_patterns + + +def test_body_check_from_raw_explicit_empty_disables_category(): + """``fatal_patterns: []`` を明示すれば default を上書きして空にできる。""" + cfg = _body_check_from_raw({"fatal_patterns": []}) + assert cfg.fatal_patterns == [] + # 他カテゴリは default のまま + assert cfg.warning_patterns != [] + + +def test_body_check_from_raw_accepts_legacy_warning_head_bytes_alias(): + """旧フィールド名 ``warning_head_bytes`` も alias として受理する。""" + cfg = _body_check_from_raw({"warning_head_bytes": 250}) + assert cfg.warning_head_chars == 250 + + +def test_body_check_from_raw_new_name_takes_priority_over_alias(): + """新旧両方が指定されたら新名 ``warning_head_chars`` を優先する。""" + cfg = _body_check_from_raw( + {"warning_head_chars": 400, "warning_head_bytes": 100} + ) + assert cfg.warning_head_chars == 400 + + +def test_body_check_from_raw_full(): + raw = { + "enabled": True, + "fatal_patterns": ["Fatal error", "Uncaught"], + "warning_patterns": ["STRICT:"], + "warning_head_chars": 200, + "not_found_patterns": ["File not found"], + "fail_on_match": False, + } + cfg = _body_check_from_raw(raw) + assert cfg.enabled is True + assert cfg.fatal_patterns == ["Fatal error", "Uncaught"] + assert cfg.warning_patterns == ["STRICT:"] + assert cfg.warning_head_chars == 200 + assert cfg.not_found_patterns == ["File not found"] + assert cfg.fail_on_match is False + + +def test_body_check_from_raw_coerces_non_str_patterns(): + raw = {"fatal_patterns": [123, "Fatal error"]} + cfg = _body_check_from_raw(raw) + assert cfg.fatal_patterns == ["123", "Fatal error"] + + +def test_config_default_body_check_is_enabled(): + """``Config`` の default で body_check は有効、PHP 系 default パターンが効く。""" + cfg = Config( + base_url="https://example.com", + basic_auth=BasicAuth(user="", password=""), + verify_tls=False, + roles={}, + playwright=PlaywrightConfig.defaults(), + runner=RunnerConfig(), + report=ReportConfig(), + config_path=Path("/tmp/scenario.config.yaml"), + accessibility=AccessibilityConfig(), + web_vitals=WebVitalsConfig(), + ) + assert cfg.body_check.enabled is True + assert "Fatal error" in cfg.body_check.fatal_patterns + + +# --------------------------------------------------------------------- +# fixture helpers +# --------------------------------------------------------------------- + + +def _make_config_with_body_check(**kwargs) -> Config: + bc = BodyCheckConfig( + enabled=kwargs.pop("enabled", True), + fatal_patterns=kwargs.pop("fatal_patterns", ["Fatal error"]), + warning_patterns=kwargs.pop("warning_patterns", ["STRICT:"]), + warning_head_chars=kwargs.pop("warning_head_chars", 300), + not_found_patterns=kwargs.pop("not_found_patterns", []), + fail_on_match=kwargs.pop("fail_on_match", True), + ) + return Config( + base_url="https://example.com", + basic_auth=BasicAuth(user="", password=""), + verify_tls=False, + roles={}, + playwright=PlaywrightConfig.defaults(), + runner=RunnerConfig(), + report=ReportConfig(), + config_path=Path("/tmp/scenario.config.yaml"), + accessibility=AccessibilityConfig(), + web_vitals=WebVitalsConfig(), + body_check=bc, + ) + + +def _make_response(*, url: str, body: str, content_type: str = "text/html") -> MagicMock: + resp = MagicMock() + resp.url = url + resp.headers = {"content-type": content_type} + resp.text.return_value = body + return resp + + +def test_response_handler_records_violation_for_html(tmp_path: Path): + cfg = _make_config_with_body_check() + ev = PwkEvidence(case_dir=tmp_path, config=cfg, enabled=True) + handler = _build_response_handler(cfg.body_check, ev) + + handler( + _make_response( + url="https://e.example/u.php", + body="STRICT:漏れ\n<body>...</body>", + ) + ) + assert len(ev.body_check_violations) == 1 + assert ev.body_check_violations[0]["pattern"] == "STRICT:" + assert ev.body_check_violations[0]["url"] == "https://e.example/u.php" + + +def test_response_handler_skips_non_html(tmp_path: Path): + cfg = _make_config_with_body_check() + ev = PwkEvidence(case_dir=tmp_path, config=cfg, enabled=True) + handler = _build_response_handler(cfg.body_check, ev) + + # JSON response with the same string must NOT be inspected + handler( + _make_response( + url="https://e.example/api", + body='{"x":"Fatal error"}', + content_type="application/json", + ) + ) + assert ev.body_check_violations == [] + + +def test_response_handler_swallows_text_failure(tmp_path: Path): + """response.text() が失敗しても test を落とさず log_lines に残らない (静かに skip)。""" + cfg = _make_config_with_body_check() + ev = PwkEvidence(case_dir=tmp_path, config=cfg, enabled=True) + handler = _build_response_handler(cfg.body_check, ev) + + resp = MagicMock() + resp.url = "https://e.example/x" + resp.headers = {"content-type": "text/html"} + resp.text.side_effect = Exception("boom") + + # 例外で test を潰さないこと + handler(resp) + assert ev.body_check_violations == [] + + +def test_write_jsonl_outputs_one_line_per_violation(tmp_path: Path): + cfg = _make_config_with_body_check() + ev = PwkEvidence(case_dir=tmp_path, config=cfg, enabled=True) + ev.body_check_violations = [ + {"url": "u1", "category": "fatal", "pattern": "Fatal error", "snippet": "..."}, + {"url": "u2", "category": "warning", "pattern": "STRICT:", "snippet": "..."}, + ] + _write_jsonl(ev) + + jsonl_path = tmp_path / "body_check.jsonl" + assert jsonl_path.exists() + lines = jsonl_path.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 2 + + +def test_write_jsonl_noop_when_no_violations(tmp_path: Path): + cfg = _make_config_with_body_check() + ev = PwkEvidence(case_dir=tmp_path, config=cfg, enabled=True) + _write_jsonl(ev) + assert not (tmp_path / "body_check.jsonl").exists() + + +def test_format_violation_summary_truncates_long_lists(): + vs = [ + {"url": f"u{i}", "category": "fatal", "pattern": "Fatal error", "snippet": "x"} + for i in range(10) + ] + s = _format_violation_summary(vs, limit=3) + assert "+7 more" in s + # head 3 件は含まれる + assert "u0" in s and "u1" in s and "u2" in s + # 末尾 (u9) は含まれない + assert "u9" not in s + + +def test_pwk_evidence_default_body_check_violations_is_empty(tmp_path: Path): + cfg = _make_config_with_body_check() + ev = PwkEvidence(case_dir=tmp_path, config=cfg, enabled=True) + assert ev.body_check_violations == [] diff --git a/plugins/ndf/skills/playwright-scenario-test/tests/test_config_basic_auth.py b/plugins/ndf/skills/playwright-scenario-test/tests/test_config_basic_auth.py new file mode 100644 index 00000000..62210696 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/tests/test_config_basic_auth.py @@ -0,0 +1,163 @@ +"""playwright_kit/config.py の Maj-4 fail-fast 検証 + Maj-10 tolerated_console_errors。 + +`requires_basic_auth=True` なロール宣言のときに `target.basic_auth.user` が空ならば +Config.load 時点で ValueError を投げることを確認する。 +""" + +from __future__ import annotations + +import pytest + +from playwright_kit.config import Config, PlaywrightConfig, _expand_env + + +_BASE_RAW = { + "target": { + "base_url": "https://example.com", + }, + "roles": { + "user": { + "label": "user", + "login": { + "path": "/login", + "requires_basic_auth": False, + "fields": {"username": "u", "password": "p"}, + "fail_if_url_contains": "/login", + }, + }, + }, +} + + +def _from_dict(raw: dict) -> Config: + from pathlib import Path + return Config._from_dict(raw, config_path=Path("/tmp/_test.yaml")) + + +def test_no_basic_auth_when_not_required_passes(): + cfg = _from_dict(_BASE_RAW) + assert cfg.basic_auth.user == "" + assert not cfg.roles["user"].login.requires_basic_auth + + +def test_requires_basic_auth_with_empty_user_raises(): + raw = { + **_BASE_RAW, + "roles": { + "admin": { + "label": "admin", + "login": { + "path": "/admin/login", + "requires_basic_auth": True, + "fields": {"username": "u", "password": "p"}, + "fail_if_url_contains": "/login", + }, + }, + }, + } + with pytest.raises(ValueError, match="requires_basic_auth"): + _from_dict(raw) + + +def test_requires_basic_auth_with_user_passes(): + raw = { + "target": { + "base_url": "https://example.com", + "basic_auth": {"user": "stage", "password": "secret"}, + }, + "roles": { + "admin": { + "label": "admin", + "login": { + "path": "/admin/login", + "requires_basic_auth": True, + "fields": {"username": "u", "password": "p"}, + "fail_if_url_contains": "/login", + }, + }, + }, + } + cfg = _from_dict(raw) + assert cfg.basic_auth.user == "stage" + + +def test_tolerated_patterns_default_empty(): + cfg = _from_dict(_BASE_RAW) + assert cfg.tolerated_console_errors == [] + assert cfg.tolerated_page_errors == [] + + +def test_empty_yaml_raises_value_error(tmp_path): + """空 YAML ファイルを Config.load() すると ValueError が出ること (Codex Minor 7)。""" + p = tmp_path / "empty.yaml" + p.write_text("", encoding="utf-8") + with pytest.raises(ValueError, match="空または辞書ではありません"): + Config.load(p) + + +# --- _expand_env 純関数テスト (Codex Major 4) ------------------------------ + + +def test_expand_env_simple(monkeypatch): + monkeypatch.setenv("MY_VAR", "hello") + assert _expand_env("${MY_VAR}") == "hello" + + +def test_expand_env_default_when_unset(monkeypatch): + monkeypatch.delenv("UNSET_VAR", raising=False) + assert _expand_env("${UNSET_VAR:-fallback}") == "fallback" + + +def test_expand_env_undefined_no_default_raises(monkeypatch): + monkeypatch.delenv("UNDEFINED_VAR", raising=False) + with pytest.raises(ValueError, match="UNDEFINED_VAR"): + _expand_env("${UNDEFINED_VAR}") + + +def test_expand_env_recursive(monkeypatch): + monkeypatch.setenv("DB_HOST", "localhost") + monkeypatch.setenv("DB_PASS", "secret") + raw = {"host": "${DB_HOST}", "nested": [{"pass": "${DB_PASS}"}]} + result = _expand_env(raw) + assert result == {"host": "localhost", "nested": [{"pass": "secret"}]} + + +def test_tolerated_patterns_loaded(): + raw = { + **_BASE_RAW, + "tolerated_console_errors": [r"favicon\.ico", "ResizeObserver loop limit"], + "tolerated_page_errors": ["ChunkLoadError"], + } + cfg = _from_dict(raw) + assert cfg.tolerated_console_errors == [ + r"favicon\.ico", + "ResizeObserver loop limit", + ] + assert cfg.tolerated_page_errors == ["ChunkLoadError"] + + +# --- playwright.har_mode (Issue #62) --------------------------------- + + +def test_har_mode_default_is_minimal(): + """config を書かない場合 default は minimal (Issue #62 race 回避)。""" + cfg = PlaywrightConfig.from_raw({}) + assert cfg.har_mode == "minimal" + + +@pytest.mark.parametrize("value", ["minimal", "full", "none"]) +def test_har_mode_accepts_valid_values(value: str): + cfg = PlaywrightConfig.from_raw({"har_mode": value}) + assert cfg.har_mode == value + + +@pytest.mark.parametrize("raw,expected", [("FULL", "full"), ("Minimal", "minimal"), ("NONE", "none")]) +def test_har_mode_lowercased(raw: str, expected: str): + """大文字混じりでも lowercase 化される。""" + cfg = PlaywrightConfig.from_raw({"har_mode": raw}) + assert cfg.har_mode == expected + + +def test_har_mode_invalid_raises(): + with pytest.raises(ValueError, match="har_mode"): + PlaywrightConfig.from_raw({"har_mode": "invalid"}) diff --git a/plugins/ndf/skills/playwright-scenario-test/tests/test_evidence_fixture.py b/plugins/ndf/skills/playwright-scenario-test/tests/test_evidence_fixture.py new file mode 100644 index 00000000..f5156fd0 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/tests/test_evidence_fixture.py @@ -0,0 +1,264 @@ +"""Phase 2 unit: evidence / accessibility / web_vitals fixture の純関数ロジック。 + +Playwright を起動しないため、``PwkEvidence`` の listener / page_role marker +の解釈 / autouse の guard 条件など、純粋なロジック部分のみをテストする。 +end-to-end は Phase 3 以降で smoke 化する。 +""" + +from __future__ import annotations + +import re +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from playwright_kit.config import ( + AccessibilityConfig, + BasicAuth, + Config, + WebVitalsConfig, + PlaywrightConfig, + ReportConfig, + RunnerConfig, +) +from playwright_kit.fixtures.accessibility import _page_roles_from_marker as a11y_marker +from playwright_kit.fixtures.web_vitals import _page_roles_from_marker as cwv_marker +from playwright_kit.fixtures.evidence import ( + PwkEvidence, + _resolve_har_mode, + _safe_case_slug, + _safe_slug, +) + + +def _make_config( + tolerated_console: list[str] | None = None, + tolerated_page: list[str] | None = None, +) -> Config: + return Config( + base_url="https://example.com", + basic_auth=BasicAuth(user="", password=""), + verify_tls=False, + roles={}, + playwright=PlaywrightConfig.defaults(), + runner=RunnerConfig(), + report=ReportConfig(), + config_path=Path("/tmp/scenario.config.yaml"), + tolerated_console_errors=tolerated_console or [], + tolerated_page_errors=tolerated_page or [], + accessibility=AccessibilityConfig(), + web_vitals=WebVitalsConfig(), + ) + + +# --- _safe_slug ----------------------------------------------------- + + +@pytest.mark.parametrize( + "name,expected_pattern", + [ + ("test_simple", r"^test_simple$"), + ("test/with/slashes", r"^test-with-slashes$"), + ("Test ID with spaces!!", r"^test-id-with-spaces$"), + ("", r"^test$"), # fallback + ], +) +def test_safe_slug(name: str, expected_pattern: str): + assert re.match(expected_pattern, _safe_slug(name, fallback="test")) + + +# --- _safe_case_slug ------------------------------------------------ + + +def _node(nodeid: str): + return SimpleNamespace(nodeid=nodeid, name=nodeid.split("::")[-1]) + + +def test_safe_case_slug_is_idempotent(monkeypatch): + """同じ nodeid + 同じ worker は常に同じ slug (idempotent)。""" + monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) + node = _node("tests/test_x.py::test_func") + assert _safe_case_slug(node) == _safe_case_slug(node) + + +def test_safe_case_slug_different_nodeid_gives_different_slug(monkeypatch): + monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) + slug_a = _safe_case_slug(_node("tests/test_x.py::test_a")) + slug_b = _safe_case_slug(_node("tests/test_x.py::test_b")) + assert slug_a != slug_b + + +def test_safe_case_slug_xdist_worker_changes_slug(monkeypatch): + """PYTEST_XDIST_WORKER が変われば slug も変わる。""" + node = _node("tests/test_x.py::test_func") + monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw0") + slug_gw0 = _safe_case_slug(node) + monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw1") + slug_gw1 = _safe_case_slug(node) + assert slug_gw0 != slug_gw1 + + +def test_safe_case_slug_length_bounded(monkeypatch): + """slug が適切な長さに収まる (70 文字以内)。""" + monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) + long_nodeid = "tests/" + "a" * 100 + ".py::test_very_long_name" + slug = _safe_case_slug(_node(long_nodeid)) + assert len(slug) <= 70 + + +# --- PwkEvidence listeners ------------------------------------------ + + +def test_pwk_evidence_console_listener_filters_tolerated(): + """tolerated パターンに合致する console.error は記録されない。""" + cfg = _make_config(tolerated_console=[r"benign 3rd-party warning"]) + ev = PwkEvidence(case_dir=Path("/tmp/dummy"), config=cfg, enabled=True) + + benign = SimpleNamespace( + type="error", + text="benign 3rd-party warning: x", + location={"url": "https://cdn.example.com/x.js"}, + ) + ev._on_console(benign) + assert ev.console_errors == [] + + real = SimpleNamespace( + type="error", + text="ReferenceError: foo is not defined", + location={"url": "https://example.com/page"}, + ) + ev._on_console(real) + assert len(ev.console_errors) == 1 + assert "ReferenceError" in ev.console_errors[0] + + +def test_pwk_evidence_pageerror_listener_filters_tolerated(): + cfg = _make_config(tolerated_page=[r"^Tolerable\b"]) + ev = PwkEvidence(case_dir=Path("/tmp/dummy"), config=cfg, enabled=True) + + ev._on_pageerror(Exception("Tolerable: ignore me")) + assert ev.page_errors == [] + ev._on_pageerror(RuntimeError("Real bug here")) + assert ev.page_errors == ["Real bug here"] + + +def test_pwk_evidence_console_listener_skips_non_error_type(): + cfg = _make_config() + ev = PwkEvidence(case_dir=Path("/tmp/dummy"), config=cfg, enabled=True) + info = SimpleNamespace(type="log", text="hello", location={}) + ev._on_console(info) + assert ev.console_errors == [] + + +def test_pwk_evidence_runtime_error_summary(): + cfg = _make_config() + ev = PwkEvidence(case_dir=Path("/tmp/dummy"), config=cfg, enabled=True) + assert ev.has_runtime_errors is False + assert ev.runtime_error_summary() == "" + + ev.console_errors.append("foo") + ev.page_errors.append("bar") + assert ev.has_runtime_errors is True + summary = ev.runtime_error_summary() + assert "console.error 1 件" in summary + assert "pageerror 1 件" in summary + + +def test_pwk_evidence_disabled_skips_tracing(): + cfg = _make_config() + ev = PwkEvidence( + case_dir=Path("/tmp/dummy"), + config=cfg, + enabled=False, + trace_path=None, + ) + fake_ctx = MagicMock() + ev.start_tracing(fake_ctx) + fake_ctx.tracing.start.assert_not_called() + ev.stop_tracing(fake_ctx) + fake_ctx.tracing.stop.assert_not_called() + + +def test_pwk_evidence_confirm_har_sets_relpath(tmp_path: Path): + cfg = _make_config() + har = tmp_path / "request.har" + har.write_text("{}", encoding="utf-8") + ev = PwkEvidence(case_dir=tmp_path, config=cfg, enabled=True, har_path=har) + ev.confirm_har() + assert ev.har_relpath == "request.har" + + +def test_pwk_evidence_confirm_har_skips_when_missing(tmp_path: Path): + cfg = _make_config() + ev = PwkEvidence( + case_dir=tmp_path, + config=cfg, + enabled=True, + har_path=tmp_path / "missing.har", + ) + ev.confirm_har() + assert ev.har_relpath is None + + +# --- page_role marker collector ------------------------------------- + + +def test_page_role_marker_collector_handles_str_args(): + item = MagicMock() + item.iter_markers.return_value = [SimpleNamespace(args=("form", "list"))] + assert a11y_marker(item) == ["form", "list"] + assert cwv_marker(item) == ["form", "list"] + + +def test_page_role_marker_collector_handles_list_arg(): + item = MagicMock() + item.iter_markers.return_value = [SimpleNamespace(args=(["dashboard", "lp"],))] + assert a11y_marker(item) == ["dashboard", "lp"] + + +def test_page_role_marker_collector_returns_empty_when_no_marker(): + item = MagicMock() + item.iter_markers.return_value = [] + assert a11y_marker(item) == [] + assert cwv_marker(item) == [] + + +# --- _resolve_har_mode (Issue #62) ----------------------------------- + + +def _make_pytestconfig(pwk_har_mode: str | None = None) -> SimpleNamespace: + """``pytestconfig.getoption("pwk_har_mode", default=None)`` を模す軽量 stub。""" + return SimpleNamespace( + getoption=lambda name, default=None: pwk_har_mode if name == "pwk_har_mode" else default + ) + + +def _make_config_with_har_mode(har_mode: str) -> Config: + """``_make_config`` の派生で playwright.har_mode のみ差し替える。""" + cfg = _make_config() + cfg.playwright.har_mode = har_mode # type: ignore[assignment] + return cfg + + +def test_resolve_har_mode_default_minimal_when_no_inputs(): + """CLI も config も無ければ default ``minimal`` (PlaywrightConfig 由来)。""" + assert _resolve_har_mode(_make_pytestconfig(None), None) == "minimal" + + +def test_resolve_har_mode_falls_back_to_config(): + """CLI 未指定時は config.playwright.har_mode が効く。""" + cfg = _make_config_with_har_mode("full") + assert _resolve_har_mode(_make_pytestconfig(None), cfg) == "full" + + +def test_resolve_har_mode_cli_overrides_config(): + """CLI 指定が config より優先される。""" + cfg = _make_config_with_har_mode("none") + assert _resolve_har_mode(_make_pytestconfig("full"), cfg) == "full" + + +def test_resolve_har_mode_lowercases_cli_value(): + """argparse choices で担保されているが defensive な lowercase 化を確認。""" + assert _resolve_har_mode(_make_pytestconfig("FULL"), None) == "full" diff --git a/plugins/ndf/skills/playwright-scenario-test/tests/test_makereport_user_properties.py b/plugins/ndf/skills/playwright-scenario-test/tests/test_makereport_user_properties.py new file mode 100644 index 00000000..22be905e --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/tests/test_makereport_user_properties.py @@ -0,0 +1,147 @@ +"""pytest_runtest_makereport が pwk_har / pwk_trace 等を user_properties に乗せることを +単体テストする (Playwright 実機不要) (Codex Major 5)。 + +hookwrapper 形式の hook は直接呼べないため、hook の中身を模擬する形でテストする。 +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from playwright_kit.fixtures.evidence import PwkEvidence +from playwright_kit.config import ( + AccessibilityConfig, + BasicAuth, + Config, + WebVitalsConfig, + PlaywrightConfig, + ReportConfig, + RunnerConfig, +) + + +def _make_ev( + tmp_path: Path, + *, + har_exists: bool = True, + trace_exists: bool = True, +) -> PwkEvidence: + cfg = Config( + base_url="https://example.com", + basic_auth=BasicAuth(user="", password=""), + verify_tls=False, + roles={}, + playwright=PlaywrightConfig.defaults(), + runner=RunnerConfig(), + report=ReportConfig(), + config_path=tmp_path / "scenario.config.yaml", + accessibility=AccessibilityConfig(), + web_vitals=WebVitalsConfig(), + ) + har_path = tmp_path / "request.har" + trace_path = tmp_path / "trace.zip" + if har_exists: + har_path.write_text("{}", encoding="utf-8") + if trace_exists: + trace_path.write_bytes(b"PK\x03\x04") + + ev = PwkEvidence( + case_dir=tmp_path, + config=cfg, + enabled=True, + har_path=har_path, + trace_path=trace_path, + ) + ev.confirm_har() + ev.trace_relpath = "trace.zip" if trace_exists else None + return ev + + +def _make_rep(ev: PwkEvidence | None = None) -> Any: + """user_properties リストを持つ fake TestReport を作る。""" + rep = SimpleNamespace( + when="call", + user_properties=[], + nodeid="tests/test_x.py::test_y", + head_line="test_y", + outcome="passed", + duration=0.5, + longrepr=None, + ) + return rep + + +def _simulate_makereport(item, rep): + """pytest_runtest_makereport の内側ロジックを直接実行する。""" + ev = getattr(item, "_pwk_evidence", None) + 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))) + + +def test_makereport_sets_har_and_trace_properties(tmp_path: Path): + """ev.har_relpath / trace_relpath が set されているとき user_properties に乗ること。""" + ev = _make_ev(tmp_path, har_exists=True, trace_exists=True) + item = SimpleNamespace(_pwk_evidence=ev) + rep = _make_rep(ev) + + _simulate_makereport(item, rep) + + props = dict(rep.user_properties) + assert "pwk_har" in props + assert "request.har" in props["pwk_har"] + assert "pwk_trace" in props + assert "trace.zip" in props["pwk_trace"] + + +def test_makereport_har_absent_not_in_properties(tmp_path: Path): + """HAR ファイルが存在しない場合 pwk_har は user_properties に含まれない。""" + ev = _make_ev(tmp_path, har_exists=False, trace_exists=False) + item = SimpleNamespace(_pwk_evidence=ev) + rep = _make_rep(ev) + + _simulate_makereport(item, rep) + + props = dict(rep.user_properties) + assert "pwk_har" not in props + assert "pwk_trace" not in props + + +def test_makereport_console_errors_count(tmp_path: Path): + """console_errors / page_errors のカウントが user_properties に乗ること。""" + ev = _make_ev(tmp_path, har_exists=False, trace_exists=False) + ev.console_errors.append("error1") + ev.console_errors.append("error2") + ev.page_errors.append("page error") + item = SimpleNamespace(_pwk_evidence=ev) + rep = _make_rep(ev) + + _simulate_makereport(item, rep) + + props = dict(rep.user_properties) + assert props["pwk_console_errors"] == 2 + assert props["pwk_page_errors"] == 1 + + +def test_makereport_no_evidence_skips_pwk_props(tmp_path: Path): + """_pwk_evidence が attach されていない item では pwk_* が user_properties に出ない。""" + item = SimpleNamespace() # _pwk_evidence なし + rep = _make_rep() + + _simulate_makereport(item, rep) + + props = dict(rep.user_properties) + assert "pwk_har" not in props + assert "pwk_trace" not in props + assert "pwk_console_errors" not in props diff --git a/plugins/ndf/skills/playwright-scenario-test/tests/test_pytest_plugin_bootstrap.py b/plugins/ndf/skills/playwright-scenario-test/tests/test_pytest_plugin_bootstrap.py new file mode 100644 index 00000000..ae314a9c --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/tests/test_pytest_plugin_bootstrap.py @@ -0,0 +1,135 @@ +"""Phase 1 smoke: ``playwright_kit.pytest_plugin`` がロードされて基本機能が動くこと。 + +ここでは Playwright を起動しない (auth fixture は session 内で 1 度ログインを +試みるため、Playwright browser binary が無くても落ちないこと、および +``--pwk-config`` が無い場合に skip されることを検証する)。 +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + + +def test_plugin_module_importable(): + """plugin module が import 可能で pytest_plugins/markers が宣言されている。""" + from playwright_kit import pytest_plugin + + assert "playwright_kit.fixtures.auth" in pytest_plugin.pytest_plugins + names = [m for m, _ in pytest_plugin._PWK_MARKERS] + assert {"page_role", "role", "phase", "priority"}.issubset(set(names)) + + +def test_addoption_registered(pytester): + """``--pwk-config`` 等の CLI option が pytest -h に出ること。""" + pytester.makepyfile("def test_dummy(): pass\n") + res = pytester.runpytest("--help") + out = res.stdout.str() + assert "--pwk-config" in out + assert "--pwk-out-dir" in out + assert "--pwk-no-evidence" in out + assert "--pwk-overlay" in out + assert "--pwk-drive-folder" in out + + +def test_markers_registered(pytester): + """``pytest --markers`` に ndf 系 marker が出ること。""" + pytester.makepyfile("def test_dummy(): pass\n") + res = pytester.runpytest("--markers") + out = res.stdout.str() + assert "page_role" in out + assert "role(role_id)" in out + assert "phase(num)" in out + assert "priority(level)" in out + + +def test_pwk_config_skips_when_unset(pytester): + """``--pwk-config`` 未指定 + ./scenario.config.yaml も無い場合、 + ``pwk_config`` を要求した test は skip される。""" + pytester.makepyfile( + textwrap.dedent( + """ + def test_uses_config(pwk_config): + assert pwk_config.base_url + """ + ) + ) + res = pytester.runpytest("-q") + res.assert_outcomes(skipped=1) + + +def test_pwk_config_loads_when_provided(pytester, tmp_path: Path): + """``--pwk-config`` を YAML で渡せば ``pwk_config`` fixture が Config を返す。""" + cfg_path = tmp_path / "scenario.config.yaml" + cfg_path.write_text( + textwrap.dedent( + """ + target: + base_url: https://example.com + roles: + admin: + label: 管理者 + login: + path: /login + requires_basic_auth: false + fail_if_url_contains: /login + fields: + email: admin@example.com + password: pass + """ + ).strip() + + "\n", + encoding="utf-8", + ) + pytester.makepyfile( + textwrap.dedent( + """ + def test_loads(pwk_config): + assert pwk_config.base_url == "https://example.com" + assert "admin" in pwk_config.roles + """ + ) + ) + res = pytester.runpytest("-q", f"--pwk-config={cfg_path}") + res.assert_outcomes(passed=1) + + +def test_role_fixture_dynamically_registered(pytester, tmp_path: Path): + """``pwk_role_<id>`` fixture が dynamic に登録されること + (Playwright を起動せずに fixture 名解決のみ確認)。""" + cfg_path = tmp_path / "scenario.config.yaml" + cfg_path.write_text( + textwrap.dedent( + """ + target: + base_url: https://example.com + roles: + admin: + label: 管理者 + login: + path: /login + requires_basic_auth: false + fail_if_url_contains: /login + fields: + email: admin@example.com + password: pass + """ + ).strip() + + "\n", + encoding="utf-8", + ) + pytester.makepyfile( + textwrap.dedent( + """ + def test_fixture_visible(request): + # `--fixtures` ではなく `getfixturedefs` で確認することで、 + # Playwright を起動せずに fixture の登録だけを assert する。 + defs = request._fixturemanager.getfixturedefs( + "pwk_role_admin", request.node + ) + assert defs is not None and len(defs) > 0 + """ + ) + ) + res = pytester.runpytest("-q", f"--pwk-config={cfg_path}") + res.assert_outcomes(passed=1) diff --git a/plugins/ndf/skills/playwright-scenario-test/tests/test_pytest_report.py b/plugins/ndf/skills/playwright-scenario-test/tests/test_pytest_report.py new file mode 100644 index 00000000..a4e40aa2 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/tests/test_pytest_report.py @@ -0,0 +1,328 @@ +"""Phase 3 unit: ``playwright_kit.pytest_report`` の純粋関数テスト。 + +pytest_terminal_summary 経由の集約は Playwright を要するため smoke では扱わず、 +``render_markdown`` と ``write_report`` を直接呼んで Markdown 出力を検証する。 +""" + +from __future__ import annotations + +import datetime as _dt +from pathlib import Path + +from playwright_kit.pytest_report import PwkTestEntry, render_markdown, write_report + + +def _entry(**overrides) -> PwkTestEntry: + base = dict( + nodeid="tests/test_x.py::test_y", + name="test_y", + outcome="passed", + duration_s=0.42, + ) + base.update(overrides) + return PwkTestEntry(**base) # type: ignore[arg-type] + + +def test_status_label_mapping(): + assert _entry(outcome="passed").status_label == "OK" + assert _entry(outcome="failed").status_label == "FAIL" + assert _entry(outcome="skipped").status_label == "SKIP" + assert _entry(outcome="xfailed").status_label == "XFAIL" + assert _entry(outcome="xpassed").status_label == "XPASS" + assert _entry(outcome="error").status_label == "ERROR" + + +def test_render_markdown_all_pass(): + started = _dt.datetime(2026, 4, 26, 12, 0, 0) + finished = _dt.datetime(2026, 4, 26, 12, 0, 5) + md = render_markdown( + [ + _entry(nodeid="t::a"), + _entry(nodeid="t::b", outcome="passed", duration_s=0.1), + ], + started_at=started, + finished_at=finished, + title="My Report", + base_url="https://example.com", + ) + assert "# My Report" in md + assert "https://example.com" in md + assert "2/2 test PASS" in md + assert "全PASS" in md + assert "`t::a`" in md + assert "`t::b`" in md + # FAIL section は出ない + assert "FAIL / ERROR の詳細" not in md + + +def test_render_markdown_with_failure_includes_details(): + started = _dt.datetime(2026, 4, 26, 12, 0, 0) + finished = _dt.datetime(2026, 4, 26, 12, 0, 10) + md = render_markdown( + [ + _entry(nodeid="t::ok"), + _entry( + nodeid="t::fail", + outcome="failed", + error_message="AssertionError: expected 1, got 2", + trace_path="/tmp/runs/x/trace.zip", + har_path="/tmp/runs/x/request.har", + ), + ], + started_at=started, + finished_at=finished, + ) + assert "1/2 test PASS" in md + assert "FAIL 1" in md + assert "FAIL / ERROR の詳細" in md + assert "AssertionError: expected 1, got 2" in md + assert "trace.zip" in md + assert "request.har" in md + + +def test_render_markdown_sorts_by_phase_then_priority_then_nodeid(): + """phase / priority / nodeid 昇順で並ぶ。""" + started = _dt.datetime(2026, 4, 26, 12, 0, 0) + finished = _dt.datetime(2026, 4, 26, 12, 0, 1) + md = render_markdown( + [ + _entry(nodeid="t::z", phase=2, priority="low"), + _entry(nodeid="t::a", phase=1, priority="high"), + _entry(nodeid="t::b", phase=1, priority="high"), + ], + started_at=started, + finished_at=finished, + ) + # `t::a` が `t::b` より先に出る + pos_a = md.index("`t::a`") + pos_b = md.index("`t::b`") + pos_z = md.index("`t::z`") + assert pos_a < pos_b < pos_z + + +def test_render_markdown_page_role_and_role_columns(): + started = _dt.datetime(2026, 4, 26, 12, 0, 0) + finished = _dt.datetime(2026, 4, 26, 12, 0, 1) + md = render_markdown( + [_entry(nodeid="t::x", role="admin", page_role=["form", "list"])], + started_at=started, + finished_at=finished, + ) + assert "admin" in md + assert "form,list" in md + + +def test_render_markdown_xfailed_counted_in_header(): + """xfailed / xpassed がヘッダ集計に出ること (Codex Major 3)。""" + started = _dt.datetime(2026, 4, 26, 12, 0, 0) + finished = _dt.datetime(2026, 4, 26, 12, 0, 5) + md = render_markdown( + [ + _entry(nodeid="t::ok", outcome="passed"), + _entry(nodeid="t::xf", outcome="xfailed"), + _entry(nodeid="t::xp", outcome="xpassed"), + ], + started_at=started, + finished_at=finished, + ) + assert "XFAIL 1" in md + assert "XPASS 1" in md + # xpassed がある場合は全PASS にならない + assert "全PASS" not in md + + +def test_render_markdown_xfailed_only_is_all_pass(): + """xfailed のみ (xpassed なし) は全PASS 扱い。""" + started = _dt.datetime(2026, 4, 26, 12, 0, 0) + finished = _dt.datetime(2026, 4, 26, 12, 0, 5) + md = render_markdown( + [ + _entry(nodeid="t::ok", outcome="passed"), + _entry(nodeid="t::xf", outcome="xfailed"), + ], + started_at=started, + finished_at=finished, + ) + assert "全PASS" in md + assert "XFAIL 1" in md + + +def test_render_markdown_xfailed_not_in_failure_section(): + """xfailed は FAIL / ERROR の詳細セクションに出ない。""" + started = _dt.datetime(2026, 4, 26, 12, 0, 0) + finished = _dt.datetime(2026, 4, 26, 12, 0, 5) + md = render_markdown( + [_entry(nodeid="t::xf", outcome="xfailed")], + started_at=started, + finished_at=finished, + ) + assert "FAIL / ERROR の詳細" not in md + + +def test_write_report_produces_file(tmp_path: Path): + started = _dt.datetime(2026, 4, 26, 12, 0, 0) + finished = _dt.datetime(2026, 4, 26, 12, 0, 1) + out = tmp_path / "out" + path = write_report( + [_entry()], out_dir=out, started_at=started, finished_at=finished + ) + assert path == out / "report.md" + assert path.exists() + txt = path.read_text(encoding="utf-8") + assert "1/1 test PASS" in txt + + +def test_render_markdown_body_check_column_present_with_zero(): + """body_check カラムは違反 0 件でもサマリ表に出る。""" + started = _dt.datetime(2026, 4, 26, 12, 0, 0) + finished = _dt.datetime(2026, 4, 26, 12, 0, 1) + md = render_markdown( + [_entry(nodeid="t::x")], + started_at=started, + finished_at=finished, + ) + # 表ヘッダに body_check が含まれる + assert "body_check" in md + # サマリ表の row には末尾に "| 0 |" (body_check_violations) が出る + assert "| 0 |" in md + + +def test_render_markdown_body_check_detail_section_for_violations(): + started = _dt.datetime(2026, 4, 26, 12, 0, 0) + finished = _dt.datetime(2026, 4, 26, 12, 0, 1) + md = render_markdown( + [ + _entry( + nodeid="t::pass_with_violations", + outcome="passed", + body_check_violations=2, + body_check_detail=[ + { + "url": "https://e/u.php", + "category": "warning", + "pattern": "STRICT:", + "snippet": "STRICT: page leak", + }, + { + "url": "https://e/v.php", + "category": "fatal", + "pattern": "Fatal error", + "snippet": "Fatal error: oops", + }, + ], + ), + ], + started_at=started, + finished_at=finished, + ) + assert "body_check 違反の詳細" in md + assert "STRICT:" in md + assert "Fatal error" in md + assert "https://e/u.php" in md + + +def test_render_markdown_no_body_check_section_when_clean(): + started = _dt.datetime(2026, 4, 26, 12, 0, 0) + finished = _dt.datetime(2026, 4, 26, 12, 0, 1) + md = render_markdown( + [_entry(nodeid="t::x")], + started_at=started, + finished_at=finished, + ) + assert "body_check 違反の詳細" not in md + + +def test_render_markdown_body_check_escapes_newlines_in_snippet(): + """snippet に改行が混入しても表が崩れない (1 violation = 1 row)。""" + started = _dt.datetime(2026, 4, 26, 12, 0, 0) + finished = _dt.datetime(2026, 4, 26, 12, 0, 1) + md = render_markdown( + [ + _entry( + nodeid="t::nl", + outcome="failed", + body_check_violations=1, + body_check_detail=[ + { + "url": "https://e/u.php", + "category": "fatal", + "pattern": "Fatal error", + "snippet": "line1\nline2\twith pipe | and `code`", + } + ], + ), + ], + started_at=started, + finished_at=finished, + ) + # detail セクションは存在する + assert "body_check 違反の詳細" in md + # 表の row には改行が混ざっていない (cell が複数行に割れない) + detail_section = md.split("body_check 違反の詳細", 1)[1] + table_rows = [ + line for line in detail_section.splitlines() + if line.startswith("| 1 ") + ] + assert len(table_rows) == 1 + row = table_rows[0] + assert "line1 line2 with pipe \\| and \\`code\\`" in row + # row 内に改行や生 backtick が残らない + assert "\n" not in row + assert "`code`" not in row # backtick is escaped + + +def test_render_markdown_body_check_escapes_pipe_in_pattern(): + """pattern に ``|`` が含まれても表が崩れない。""" + started = _dt.datetime(2026, 4, 26, 12, 0, 0) + finished = _dt.datetime(2026, 4, 26, 12, 0, 1) + md = render_markdown( + [ + _entry( + nodeid="t::pipe", + outcome="failed", + body_check_violations=1, + body_check_detail=[ + { + "url": "https://e/x", + "category": "fatal", + "pattern": "Fatal | error", + "snippet": "ok", + } + ], + ), + ], + started_at=started, + finished_at=finished, + ) + assert "Fatal \\| error" in md + + +def test_render_markdown_body_check_truncates_at_20(): + started = _dt.datetime(2026, 4, 26, 12, 0, 0) + finished = _dt.datetime(2026, 4, 26, 12, 0, 1) + detail = [ + { + "url": f"https://e/u{i}.php", + "category": "warning", + "pattern": "STRICT:", + "snippet": f"hit {i}", + } + for i in range(25) + ] + md = render_markdown( + [ + _entry( + nodeid="t::many", + outcome="failed", + body_check_violations=25, + body_check_detail=detail, + ), + ], + started_at=started, + finished_at=finished, + ) + assert "body_check.jsonl" in md + # 表示されない 21 件目以降のひとつ (u24) が出ていないこと + assert "u24.php" not in md + # 表示される 1 件目は出る + assert "u0.php" in md diff --git a/plugins/ndf/skills/playwright-scenario-test/tests/test_pytest_terminal_summary.py b/plugins/ndf/skills/playwright-scenario-test/tests/test_pytest_terminal_summary.py new file mode 100644 index 00000000..7084b14a --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/tests/test_pytest_terminal_summary.py @@ -0,0 +1,372 @@ +"""pytest_terminal_summary が report.md を正しく生成することを +terminalreporter mock で検証する (Playwright 実機不要) (Codex Major 5)。 +""" + +from __future__ import annotations + +import datetime as _dt +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from playwright_kit.pytest_plugin import ( + _collect_entries, + pytest_sessionfinish, + pytest_terminal_summary, +) +from playwright_kit.pytest_report import PwkTestEntry + + +def _make_rep( + nodeid: str = "tests/test_x.py::test_y", + outcome: str = "passed", + when: str = "call", + duration: float = 0.5, + user_properties: list | None = None, + longrepr=None, +) -> Any: + return SimpleNamespace( + nodeid=nodeid, + head_line=nodeid.split("::")[-1], + outcome=outcome, + when=when, + duration=duration, + user_properties=user_properties or [], + longrepr=longrepr, + ) + + +def _make_terminalreporter(stats: dict, out_dir: Path | None = None) -> Any: + tr = MagicMock() + tr.stats = stats + tr._sessionstarttime = _dt.datetime(2026, 4, 26, 12, 0, 0).timestamp() + return tr + + +# --------------------------------------------------------------------------- +# _collect_entries のユニットテスト +# --------------------------------------------------------------------------- + + +def test_collect_entries_passed(): + tr = _make_terminalreporter({ + "passed": [_make_rep(nodeid="t::ok", outcome="passed")], + }) + entries = _collect_entries(tr) + assert len(entries) == 1 + assert entries[0].outcome == "passed" + assert entries[0].nodeid == "t::ok" + + +def test_collect_entries_skips_teardown(): + """teardown phase の rep は集約しない。""" + tr = _make_terminalreporter({ + "failed": [ + _make_rep(nodeid="t::fail", outcome="failed", when="call"), + _make_rep(nodeid="t::teardown", outcome="failed", when="teardown"), + ], + }) + entries = _collect_entries(tr) + assert len(entries) == 1 + assert entries[0].nodeid == "t::fail" + + +def test_collect_entries_includes_xfailed_xpassed(): + """xfailed / xpassed も集約されること (Codex Major 3)。""" + tr = _make_terminalreporter({ + "passed": [_make_rep(nodeid="t::ok")], + "xfailed": [_make_rep(nodeid="t::xf", outcome="xfailed")], + "xpassed": [_make_rep(nodeid="t::xp", outcome="xpassed")], + }) + entries = _collect_entries(tr) + outcomes = {e.outcome for e in entries} + assert "xfailed" in outcomes + assert "xpassed" in outcomes + + +def test_collect_entries_promotes_teardown_failure_on_passed_call(): + """call=passed + teardown=failed (body_check fail) → outcome=failed に昇格。""" + tr = _make_terminalreporter({ + "passed": [_make_rep(nodeid="t::bc", outcome="passed", when="call")], + "": [ + _make_rep( + nodeid="t::bc", + outcome="failed", + when="teardown", + user_properties=[("pwk_body_check_violations", 2)], + longrepr="body_check teardown failure", + ) + ], + }) + entries = _collect_entries(tr) + assert len(entries) == 1 + assert entries[0].outcome == "failed" + assert entries[0].body_check_violations == 2 + assert entries[0].error_message == "body_check teardown failure" + + +def test_collect_entries_promotes_teardown_failure_on_xfailed_call(): + """call=xfailed + teardown=failed → outcome=failed (xfail で隠さない)。 + + 旧実装では ``entry.outcome == 'passed'`` 限定のため xfail テストの + teardown failure を拾い損ねていた (codex review Major #2)。 + """ + tr = _make_terminalreporter({ + "xfailed": [_make_rep(nodeid="t::xf", outcome="xfailed", when="call")], + "": [ + _make_rep( + nodeid="t::xf", + outcome="failed", + when="teardown", + user_properties=[("pwk_body_check_violations", 1)], + longrepr="body_check teardown failure on xfail", + ) + ], + }) + entries = _collect_entries(tr) + assert entries[0].outcome == "failed" + assert entries[0].body_check_violations == 1 + + +def test_collect_entries_promotes_teardown_error_with_error_outcome(): + """teardown=error は outcome=error に昇格 (failed と区別する)。""" + tr = _make_terminalreporter({ + "passed": [_make_rep(nodeid="t::e", outcome="passed", when="call")], + "": [ + _make_rep( + nodeid="t::e", + outcome="error", + when="teardown", + user_properties=[("pwk_body_check_violations", 0)], + longrepr="teardown error", + ) + ], + }) + entries = _collect_entries(tr) + assert entries[0].outcome == "error" + + +def test_collect_entries_promotes_teardown_failure_on_xpassed_call(): + """call=xpassed + teardown=failed → outcome=failed に昇格。""" + tr = _make_terminalreporter({ + "xpassed": [_make_rep(nodeid="t::xp", outcome="xpassed", when="call")], + "": [ + _make_rep( + nodeid="t::xp", + outcome="failed", + when="teardown", + user_properties=[("pwk_body_check_violations", 1)], + longrepr="body_check teardown failure on xpass", + ) + ], + }) + entries = _collect_entries(tr) + assert entries[0].outcome == "failed" + assert entries[0].body_check_violations == 1 + + +def test_collect_entries_promotes_teardown_failure_on_skipped_call(): + """call=skipped + teardown=failed → outcome=failed に昇格。""" + tr = _make_terminalreporter({ + "skipped": [_make_rep(nodeid="t::sk", outcome="skipped", when="call")], + "": [ + _make_rep( + nodeid="t::sk", + outcome="failed", + when="teardown", + user_properties=[("pwk_body_check_violations", 3)], + longrepr="body_check teardown failure on skip", + ) + ], + }) + entries = _collect_entries(tr) + assert entries[0].outcome == "failed" + assert entries[0].body_check_violations == 3 + + +def test_collect_entries_does_not_overwrite_genuine_call_failure(): + """call phase の本物の failure は teardown failure で上書きしない。""" + tr = _make_terminalreporter({ + "failed": [ + _make_rep( + nodeid="t::f", + outcome="failed", + when="call", + longrepr="real call failure", + ) + ], + "": [ + _make_rep( + nodeid="t::f", + outcome="failed", + when="teardown", + longrepr="teardown also failed", + ) + ], + }) + entries = _collect_entries(tr) + assert entries[0].outcome == "failed" + # error_message は call phase のものが残る + assert entries[0].error_message == "real call failure" + + +def test_collect_entries_error_message_only_for_failed(tmp_path: Path): + """failed のみ error_message が設定され、skipped は None になること (Amazon Q Critical-3)。""" + tr = _make_terminalreporter({ + "failed": [_make_rep(nodeid="t::fail", outcome="failed", longrepr="AssertionError: x")], + "skipped": [_make_rep(nodeid="t::skip", outcome="skipped", longrepr=("file", 1, "skipped"))], + }) + entries = _collect_entries(tr) + failed_entry = next(e for e in entries if e.outcome == "failed") + skipped_entry = next(e for e in entries if e.outcome == "skipped") + assert failed_entry.error_message == "AssertionError: x" + assert skipped_entry.error_message is None + + +# --------------------------------------------------------------------------- +# pytest_terminal_summary が report.md を生成するテスト +# --------------------------------------------------------------------------- + + +def test_terminal_summary_generates_report_md(tmp_path: Path): + """pytest_terminal_summary が report.md を out_dir に生成すること。""" + tr = _make_terminalreporter({ + "passed": [_make_rep(nodeid="t::ok", outcome="passed", duration=1.0)], + "failed": [_make_rep( + nodeid="t::fail", + outcome="failed", + duration=0.5, + longrepr="AssertionError: expected True got False", + )], + }) + + config = MagicMock() + config.getoption.return_value = str(tmp_path) + config._pwk_config = None + + pytest_terminal_summary(tr, exitstatus=1, config=config) + + report_path = tmp_path / "report.md" + assert report_path.exists(), "report.md が生成されていない" + + content = report_path.read_text(encoding="utf-8") + assert "t::ok" in content + assert "t::fail" in content + assert "AssertionError" in content + + +def test_terminal_summary_skips_when_no_tests(tmp_path: Path): + """テストが 1 件も無い場合は report.md を生成しない。""" + tr = _make_terminalreporter({}) + config = MagicMock() + config.getoption.return_value = str(tmp_path) + config._pwk_config = None + + pytest_terminal_summary(tr, exitstatus=0, config=config) + + assert not (tmp_path / "report.md").exists() + + +# --------------------------------------------------------------------------- +# pytest_sessionfinish の Drive upload 分岐 (codex review2 P3) +# --------------------------------------------------------------------------- + + +def _make_session(*, drive_folder: str | None, report_path: Path, out_dir: Path): + """``pytest_sessionfinish`` 用の薄い session mock を作る。""" + config = MagicMock() + config.getoption = lambda name, default=None: ( + drive_folder if name == "pwk_drive_folder" else default + ) + config._pwk_report_path = report_path + config._pwk_out_dir = out_dir + session = MagicMock() + session.config = config + return session + + +def test_sessionfinish_uploads_body_check_jsonl_with_any_kind(tmp_path: Path): + """body_check.jsonl が ``kind="any"`` で upload される (codex review2 P3)。 + + .zip / .har / .mp4 / .webm は ``detect_kind`` の結果、.jsonl は固定で ``any`` + として upload されることを mock 経由で検証する。 + """ + out_dir = tmp_path / "out" + sub = out_dir / "case-x" + sub.mkdir(parents=True) + (sub / "trace.zip").write_bytes(b"") + (sub / "request.har").write_bytes(b"") + (sub / "video.mp4").write_bytes(b"") + (sub / "body_check.jsonl").write_text("{}\n", encoding="utf-8") + # 拾わない拡張子 (sanity) + (sub / "ignore.txt").write_text("x", encoding="utf-8") + + report = out_dir / "report.md" + report.write_text("# r", encoding="utf-8") + + uploads: list[tuple[str, str]] = [] + + def fake_upload(f, *, kind, parent_folder_id, public): + uploads.append((Path(f).name, kind)) + + def fake_detect_kind(f): + suffix = Path(f).suffix + return {".zip": "trace", ".har": "har", ".mp4": "video", ".webm": "video"}.get( + suffix, "any" + ) + + with patch.dict( + "sys.modules", + { + "playwright_kit.uploaders": SimpleNamespace( + upload=fake_upload, detect_kind=fake_detect_kind + ) + }, + ): + session = _make_session( + drive_folder="DRIVE_FOLDER", report_path=report, out_dir=out_dir + ) + pytest_sessionfinish(session, exitstatus=0) + + by_name = dict(uploads) + # report.md は kind="any" でアップ (実装契約) + assert by_name.get("report.md") == "any" + # .jsonl は固定で kind="any" + assert by_name.get("body_check.jsonl") == "any" + # 既存の各種 evidence は detect_kind の結果が反映される + assert by_name.get("trace.zip") == "trace" + assert by_name.get("request.har") == "har" + assert by_name.get("video.mp4") == "video" + # 想定外拡張子は upload されない + assert "ignore.txt" not in by_name + + +def test_sessionfinish_skips_when_no_drive_folder(tmp_path: Path): + """``--pwk-drive-folder`` 未指定なら upload は走らない。""" + out_dir = tmp_path / "out" + out_dir.mkdir() + report = out_dir / "report.md" + report.write_text("# r", encoding="utf-8") + + uploads: list[str] = [] + + def fake_upload(*args, **kwargs): + uploads.append("called") + + with patch.dict( + "sys.modules", + { + "playwright_kit.uploaders": SimpleNamespace( + upload=fake_upload, detect_kind=lambda f: "any" + ) + }, + ): + session = _make_session( + drive_folder=None, report_path=report, out_dir=out_dir + ) + pytest_sessionfinish(session, exitstatus=0) + + assert uploads == [] diff --git a/plugins/ndf/skills/playwright-scenario-test/tests/test_pytester_integration.py b/plugins/ndf/skills/playwright-scenario-test/tests/test_pytester_integration.py new file mode 100644 index 00000000..1a1c1ef4 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/tests/test_pytester_integration.py @@ -0,0 +1,410 @@ +"""pytester を使った統合テスト (Codex Major-5 完遂 / 3回目強化版)。 + +本物の pytest セッションを隔離環境で実行し、以下 4 件を検証する: + +(a) auth cache hit/miss: + 同 role に対して pwk_role_<id> fixture を 2 つの test で使ったとき、 + _login_and_get_storage_state は 1 回だけ呼ばれること (2 回目は cache hit)。 + Playwright 実機不要: monkeypatch.setattr で fake に差し替え。 + call_count を stdout に出力して pytester から assert する。 + +(b) pytest_runtest_makereport 経由で artifact path が report.md に乗ること: + pytester で本物の pytest セッションを走らせ、 + FAIL した test の report.md 詳細セクションに request.har / trace.zip の + パスが含まれることを確認する (Codex Major-1 artifact 伝搬の直接検証)。 + 本物の pwk_evidence fixture lifecycle を使用する。 + +(c) call phase FAIL 時でも teardown 後の artifact が report.md に反映されること: + call phase で pytest.fail() する test を pytester で実行し、 + teardown 後に確定した HAR / trace path が report.md に含まれることを assert。 + +(d) _sessionstarttime の有無で report.md ヘッダ時刻が変わること: + terminalreporter に _sessionstarttime を持たせた場合とない場合で + write_report() に渡る started_at が異なることを確認 (AQ Critical-4)。 +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + + +# --------------------------------------------------------------------------- +# (a) auth cache hit/miss の統合テスト +# --------------------------------------------------------------------------- + + +def test_auth_cache_hit_miss_via_pytester(pytester, tmp_path: Path): + """pwk_role_<id> fixture が session を跨いで cache hit し、 + _login_and_get_storage_state が 2 回目以降呼ばれないことを検証する。 + + Playwright 実機不要: conftest.py で _login_and_get_storage_state を + call_count を記録する fake に差し替え。 + call_count を stdout に print して pytester の出力から assert する。 + """ + cfg_path = tmp_path / "scenario.config.yaml" + cfg_path.write_text( + textwrap.dedent( + """ + target: + base_url: https://example.com + roles: + admin: + label: 管理者 + login: + path: /login + requires_basic_auth: false + fail_if_url_contains: "" + fields: + email: admin@example.com + password: pass + """ + ).strip() + + "\n", + encoding="utf-8", + ) + + # pytester 内の conftest: _login_and_get_storage_state を fake に差し替え、 + # context fixture も MagicMock に override して Playwright 実機を不要にする。 + pytester.makeconftest( + textwrap.dedent( + """ + from unittest.mock import MagicMock + import pytest + import playwright_kit.fixtures.auth as auth_mod + + _call_count = 0 + + def _fake_login(**kwargs): + global _call_count + _call_count += 1 + return {"cookies": [{"name": "session", "value": "fake"}], "origins": []} + + @pytest.fixture(scope="session", autouse=True) + def _patch_login(): + import playwright_kit.fixtures.auth as m + orig = m._login_and_get_storage_state + m._login_and_get_storage_state = _fake_login + yield + m._login_and_get_storage_state = orig + + @pytest.fixture() + def context(): + ctx = MagicMock() + ctx.add_cookies = MagicMock() + ctx.new_page.return_value = MagicMock() + return ctx + """ + ) + ) + + pytester.makepyfile( + textwrap.dedent( + """ + import playwright_kit.fixtures.auth as auth_mod + import conftest as conftest_mod + + def test_first_call_cache_miss(pwk_role_admin, context): + # 1 回目: _login_and_get_storage_state が呼ばれる (cache miss) + context.add_cookies.assert_called() + print(f"CALL_COUNT_AFTER_FIRST={conftest_mod._call_count}") + + def test_second_call_cache_hit(pwk_role_admin, context): + # 2 回目: 同 role なので fake は再呼び出しされない (cache hit) + # call_count は 1 のまま変わっていないはず + print(f"CALL_COUNT_AFTER_SECOND={conftest_mod._call_count}") + assert conftest_mod._call_count == 1, ( + f"cache hit 失敗: _login_and_get_storage_state が " + f"{conftest_mod._call_count} 回呼ばれた (1 回のみ期待)" + ) + """ + ) + ) + res = pytester.runpytest("-v", "-s", f"--pwk-config={cfg_path}") + # 両方 passed であることを確認 + res.assert_outcomes(passed=2) + # call_count が 1 であることを stdout から確認 (cache hit 証明) + res.stdout.fnmatch_lines(["*CALL_COUNT_AFTER_FIRST=1*"]) + res.stdout.fnmatch_lines(["*CALL_COUNT_AFTER_SECOND=1*"]) + + +# --------------------------------------------------------------------------- +# (b) artifact path が report.md に反映されること (本物の pwk_evidence lifecycle) +# --------------------------------------------------------------------------- + + +def test_makereport_populates_artifact_paths_in_report(pytester, tmp_path: Path): + """本物の pwk_evidence fixture lifecycle を使い、 + FAIL した test の report.md 詳細セクションに + request.har / trace.zip パスが含まれることを検証する。 + + Codex Major-1 artifact 伝搬修正 (3回目) の直接検証: + - call phase で pytest.fail() して FAIL test を作る + - pwk_evidence fixture の context は MagicMock で Playwright 実機不要 + - teardown で confirm_har() が動き、har_path / trace_path が確定 + - _collect_entries() の teardown merge により report.md に artifact path が出る + """ + out_dir = tmp_path / "out" + + pytester.makeconftest( + textwrap.dedent( + f""" + import pytest + from unittest.mock import MagicMock, patch + from pathlib import Path + + DUMMY_CASE_DIR = Path({str(tmp_path)!r}) / "case" + + @pytest.fixture(scope="session", autouse=True) + def _patch_browser_context_args(pytestconfig): + \"\"\"browser_context_args が record_har_path を設定する際、 + ダミーの case_dir を使い HAR ファイルを事前に作成する。\"\"\" + yield + + @pytest.fixture() + def context(): + \"\"\"Playwright 実機不要: MagicMock context を返す。 + tracing.stop() 呼び出し時に trace.zip を実際に作成する。\"\"\" + ctx = MagicMock() + + def fake_tracing_stop(path=None): + if path: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b"PK") # ダミーの zip + + ctx.tracing.stop.side_effect = fake_tracing_stop + return ctx + + @pytest.fixture() + def page(): + return MagicMock() + """ + ) + ) + + # pwk_evidence fixture を本物のまま使い、call で pytest.fail() して FAIL させる。 + # HAR ファイルは browser_context_args の record_har_path で指定されるが、 + # MagicMock context では実際には作られないため、pwk_evidence の finalizer 前に + # har_path を手動で作成する fixture を挟む。 + pytester.makepyfile( + textwrap.dedent( + f""" + import pytest + from pathlib import Path + from unittest.mock import MagicMock + + @pytest.fixture(autouse=True) + def _create_dummy_har(pwk_evidence): + \"\"\"pwk_evidence の har_path に実体ファイルを作成し、 + confirm_har() が har_relpath を設定できるようにする。\"\"\" + if pwk_evidence.har_path: + pwk_evidence.har_path.parent.mkdir(parents=True, exist_ok=True) + pwk_evidence.har_path.write_text("{{}}", encoding="utf-8") + yield + + def test_fail_with_evidence(pwk_evidence): + \"\"\"call phase で FAIL させ、teardown 後の artifact が + report.md に乗ることを確認するためのダミー FAIL test。\"\"\" + pytest.fail("intentional failure for artifact propagation test") + """ + ) + ) + + res = pytester.runpytest("-v", "--pwk-out-dir", str(out_dir)) + # 1 件 FAIL であることを確認 + res.assert_outcomes(failed=1) + + # report.md が生成されていること + report_files = list(out_dir.glob("report.md")) + assert report_files, f"report.md が見つかりません: {list(out_dir.iterdir())}" + content = report_files[0].read_text(encoding="utf-8") + + # FAIL 詳細セクションに test 名が出ていること + assert "test_fail_with_evidence" in content, "test 名が report.md に含まれていない" + + # artifact path (request.har) が report.md に含まれていること + # Codex Major-1 修正: teardown merge により call phase FAIL でも HAR path が乗る + assert "request.har" in content, ( + f"request.har が report.md に含まれていない。\n" + f"report.md 内容:\n{content}" + ) + + +# --------------------------------------------------------------------------- +# (c) call phase FAIL 時でも teardown 後の artifact が report.md に反映されること +# --------------------------------------------------------------------------- + + +def test_artifact_propagation_call_to_teardown(pytester, tmp_path: Path): + """call phase で FAIL した test の report.md に + teardown 後に確定した HAR path が含まれることを直接 assert する。 + + これは _collect_entries() の teardown merge ロジック (Codex Major-1 / 3回目) + が正しく動いているかの直接検証テストである。 + """ + out_dir = tmp_path / "out" + + pytester.makeconftest( + textwrap.dedent( + """ + import pytest + from unittest.mock import MagicMock + from pathlib import Path + + @pytest.fixture() + def context(): + ctx = MagicMock() + def fake_tracing_stop(path=None): + if path: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b"PK") + ctx.tracing.stop.side_effect = fake_tracing_stop + return ctx + + @pytest.fixture() + def page(): + return MagicMock() + """ + ) + ) + + pytester.makepyfile( + textwrap.dedent( + """ + import pytest + from pathlib import Path + + @pytest.fixture(autouse=True) + def _ensure_har(pwk_evidence): + # HAR ファイルを事前作成して confirm_har() が har_relpath をセットできるようにする + if pwk_evidence.har_path: + pwk_evidence.har_path.parent.mkdir(parents=True, exist_ok=True) + pwk_evidence.har_path.write_text("{}", encoding="utf-8") + yield + + def test_call_fail_artifact_propagation(pwk_evidence): + # call phase で FAIL させる + # teardown で pwk_evidence.confirm_har() が呼ばれ har_relpath が確定 + # _collect_entries の teardown merge で report.md に har path が乗るはず + pytest.fail("call phase failure to test artifact propagation") + """ + ) + ) + + res = pytester.runpytest("-v", "--pwk-out-dir", str(out_dir)) + res.assert_outcomes(failed=1) + + report_files = list(out_dir.glob("report.md")) + assert report_files, f"report.md が生成されていません: {list(out_dir.iterdir())}" + content = report_files[0].read_text(encoding="utf-8") + + # FAIL 詳細セクションに request.har パスが含まれること + assert "request.har" in content, ( + "Codex Major-1 (3回目) artifact 伝搬修正が機能していない: " + "call phase FAIL の report.md に request.har パスが含まれていない\n" + f"report.md 内容:\n{content}" + ) + + +# --------------------------------------------------------------------------- +# (d) _sessionstarttime の有無で write_report の started_at が変わること +# --------------------------------------------------------------------------- + + +def test_sessionstarttime_affects_report_header(tmp_path: Path): + """terminalreporter._sessionstarttime がある場合とない場合で + write_report に渡る started_at が異なることを確認する (AQ Critical-4)。 + + pytester ではなく直接 pytest_terminal_summary を呼んで + write_report の引数をモックで検証する。 + """ + import datetime as _dt + from types import SimpleNamespace + from unittest.mock import MagicMock, patch + + from playwright_kit.pytest_plugin import pytest_terminal_summary + + def _make_rep(nodeid="t::ok", outcome="passed", when="call"): + return SimpleNamespace( + nodeid=nodeid, + head_line=nodeid.split("::")[-1], + outcome=outcome, + when=when, + duration=1.0, + user_properties=[], + longrepr=None, + ) + + # _sessionstarttime あり: fromtimestamp で started_at が決まる + fixed_ts = _dt.datetime(2026, 1, 1, 10, 0, 0).timestamp() + tr_with_ts = MagicMock() + tr_with_ts.stats = {"passed": [_make_rep()]} + tr_with_ts._sessionstarttime = fixed_ts + + config = MagicMock() + config.getoption.return_value = str(tmp_path / "with_ts") + config._pwk_config = None + + captured_started: list[_dt.datetime] = [] + + orig_write = __import__( + "playwright_kit.pytest_report", fromlist=["write_report"] + ).write_report + + def _capture_write(entries, *, out_dir, started_at, finished_at, **kwargs): + captured_started.append(started_at) + return orig_write( + entries, + out_dir=out_dir, + started_at=started_at, + finished_at=finished_at, + **kwargs, + ) + + with patch("playwright_kit.pytest_plugin.write_report", side_effect=_capture_write): + pytest_terminal_summary(tr_with_ts, exitstatus=0, config=config) + + assert len(captured_started) == 1 + expected = _dt.datetime.fromtimestamp(fixed_ts) + assert captured_started[0] == expected, ( + f"_sessionstarttime あり: started_at={captured_started[0]} != {expected}" + ) + + # _sessionstarttime なし: now() - sum(duration) で started_at が近似される + tr_no_ts = MagicMock() + tr_no_ts.stats = {"passed": [_make_rep()]} + del tr_no_ts._sessionstarttime # 属性を削除 + + config2 = MagicMock() + config2.getoption.return_value = str(tmp_path / "no_ts") + config2._pwk_config = None + + captured_started2: list[_dt.datetime] = [] + + def _capture_write2(entries, *, out_dir, started_at, finished_at, **kwargs): + captured_started2.append(started_at) + return orig_write( + entries, + out_dir=out_dir, + started_at=started_at, + finished_at=finished_at, + **kwargs, + ) + + before = _dt.datetime.now() + with patch("playwright_kit.pytest_plugin.write_report", side_effect=_capture_write2): + pytest_terminal_summary(tr_no_ts, exitstatus=0, config=config2) + after = _dt.datetime.now() + + assert len(captured_started2) == 1 + # fallback: now() - duration(1.0s) なので before - 2s < started_at < after + assert before - _dt.timedelta(seconds=2) <= captured_started2[0] <= after, ( + f"_sessionstarttime なし fallback: started_at={captured_started2[0]} が範囲外" + ) + # _sessionstarttime あり の結果 (2026-01-01) とは明確に異なる + assert captured_started2[0] != expected diff --git a/plugins/ndf/skills/playwright-scenario-test/tests/test_same_origin.py b/plugins/ndf/skills/playwright-scenario-test/tests/test_same_origin.py new file mode 100644 index 00000000..9e7b2aa2 --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/tests/test_same_origin.py @@ -0,0 +1,45 @@ +"""``_same_origin`` の挙動テスト (Gemini Minor 5.1 対応)。""" + +from __future__ import annotations + +from playwright_kit.fixtures.auth import _same_origin + + +def test_same_origin_exact_match() -> None: + assert _same_origin("https://example.com", "https://example.com") + + +def test_same_origin_with_path_in_origin_url() -> None: + # storage_state["origins"][i]["origin"] は通常 path を持たないが、 + # 万一 path 付きでも origin 部分で判定する。 + assert _same_origin("https://example.com/foo", "https://example.com") + + +def test_same_origin_rejects_different_host() -> None: + assert not _same_origin("https://ads.example.com", "https://example.com") + assert not _same_origin("https://other.test", "https://example.com") + + +def test_same_origin_rejects_different_scheme() -> None: + assert not _same_origin("http://example.com", "https://example.com") + + +def test_same_origin_rejects_different_port() -> None: + assert not _same_origin("https://example.com:8443", "https://example.com") + assert not _same_origin("https://example.com", "https://example.com:8443") + + +def test_same_origin_default_port_equivalence() -> None: + # 明示 port なしと "443" / "80" を厳密区別する設計 (Playwright の origin + # 表記と合わせる)。 + assert _same_origin("https://example.com", "https://example.com") + + +def test_same_origin_handles_invalid_url() -> None: + assert not _same_origin("", "https://example.com") + assert not _same_origin("not a url", "https://example.com") + + +def test_same_origin_rejects_subdomain() -> None: + # 厳格 origin 一致 (eTLD+1 ではない) とする。 + assert not _same_origin("https://api.example.com", "https://example.com") diff --git a/plugins/ndf/skills/playwright-scenario-test/tests/test_upload_evidence.py b/plugins/ndf/skills/playwright-scenario-test/tests/test_upload_evidence.py new file mode 100644 index 00000000..5c8df29b --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/tests/test_upload_evidence.py @@ -0,0 +1,57 @@ +"""scripts/upload_evidence.py の純粋関数をテストする (Drive API は呼ばない)。""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from upload_evidence import ALLOWED_KINDS, detect_kind, detect_mime, upload + + +class TestDetectKind: + def test_zip_is_trace(self): + assert detect_kind(Path("foo.trace.zip")) == "trace" + assert detect_kind(Path("simple.zip")) == "trace" + + def test_har(self): + assert detect_kind(Path("session.har")) == "har" + + def test_video(self): + assert detect_kind(Path("recording.mp4")) == "video" + assert detect_kind(Path("recording.webm")) == "video" + + def test_unknown_extension_falls_back_to_any(self): + assert detect_kind(Path("unknown.bin")) == "any" + assert detect_kind(Path("README.md")) == "any" + + +class TestUploadKindValidation: + """Min-7: Python API として呼ばれた場合の kind 値検査。""" + + def test_invalid_kind_raises_before_drive_call(self, tmp_path): + # Drive API には触らずに上流で ValueError が出ることを確認 + f = tmp_path / "x.bin" + f.write_bytes(b"") + with pytest.raises(ValueError, match="未対応の kind"): + upload(f, kind="bogus") + + def test_allowed_kinds_set(self): + assert ALLOWED_KINDS == frozenset({"trace", "har", "video", "any"}) + + +class TestDetectMime: + """codex Min-3: kind=video でも .webm は video/webm を返す。""" + + def test_webm_returns_video_webm(self): + assert detect_mime(Path("recording.webm"), "video") == "video/webm" + + def test_mp4_returns_video_mp4(self): + assert detect_mime(Path("recording.mp4"), "video") == "video/mp4" + + def test_har_returns_application_json(self): + assert detect_mime(Path("session.har"), "har") == "application/json" + + def test_unknown_extension_falls_back_to_kind_default(self): + assert detect_mime(Path("evidence.bin"), "any") == "application/octet-stream" + assert detect_mime(Path("evidence.bin"), "trace") == "application/zip" diff --git a/plugins/ndf/skills/playwright-scenario-test/uv.lock b/plugins/ndf/skills/playwright-scenario-test/uv.lock new file mode 100644 index 00000000..c89d074e --- /dev/null +++ b/plugins/ndf/skills/playwright-scenario-test/uv.lock @@ -0,0 +1,809 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] + +[[package]] +name = "axe-playwright-python" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "playwright" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/90/684e8ae7e7563318d197f1bfbacd561a19716e6cdb6420e7e97ef49d427c/axe_playwright_python-0.1.7.tar.gz", hash = "sha256:f0f3d59061abbaee9990d0309f855210ec8fd6ca5bf076f39210289ac0961258", size = 189815, upload-time = "2025-12-01T05:19:31.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/b0/6460c11d515cb09d90993e948c730a3d4b6673cc5cd99663179bbc48a758/axe_playwright_python-0.1.7-py3-none-any.whl", hash = "sha256:8699e904466cc2206fa2fa414b4d59aab68e34ba8449c6d082896ca1950440e2", size = 156509, upload-time = "2025-12-01T05:19:29.285Z" }, +] + +[[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", marker = "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/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" }, +] + +[[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/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/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 = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "47.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +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 = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[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" }, +] +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 = "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 = "greenlet" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c6/dba32cab7e3a625b011aa5647486e2d28423a48845a2998c126dd69c85e1/greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58", size = 285504, upload-time = "2026-04-08T15:52:14.071Z" }, + { url = "https://files.pythonhosted.org/packages/54/f4/7cb5c2b1feb9a1f50e038be79980dfa969aa91979e5e3a18fdbcfad2c517/greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6", size = 605476, upload-time = "2026-04-08T16:24:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/d6/af/b66ab0b2f9a4c5a867c136bf66d9599f34f21a1bcca26a2884a29c450bd9/greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875", size = 618336, upload-time = "2026-04-08T16:30:56.59Z" }, + { url = "https://files.pythonhosted.org/packages/6d/31/56c43d2b5de476f77d36ceeec436328533bff960a4cba9a07616e93063ab/greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76", size = 625045, upload-time = "2026-04-08T16:40:37.111Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5c/8c5633ece6ba611d64bf2770219a98dd439921d6424e4e8cf16b0ac74ea5/greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83", size = 613515, upload-time = "2026-04-08T15:56:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/704d4e2c90acb8bdf7ae593f5cbc95f58e82de95cc540fb75631c1054533/greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81", size = 419745, upload-time = "2026-04-08T16:43:04.022Z" }, + { url = "https://files.pythonhosted.org/packages/a9/df/950d15bca0d90a0e7395eb777903060504cdb509b7b705631e8fb69ff415/greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2", size = 1574623, upload-time = "2026-04-08T16:26:18.596Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e7/0839afab829fcb7333c9ff6d80c040949510055d2d4d63251f0d1c7c804e/greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71", size = 1639579, upload-time = "2026-04-08T15:57:29.231Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2b/b4482401e9bcaf9f5c97f67ead38db89c19520ff6d0d6699979c6efcc200/greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711", size = 238233, upload-time = "2026-04-08T17:02:54.286Z" }, + { url = "https://files.pythonhosted.org/packages/0c/4d/d8123a4e0bcd583d5cfc8ddae0bbe29c67aab96711be331a7cc935a35966/greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267", size = 235045, upload-time = "2026-04-08T17:04:05.072Z" }, + { url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" }, + { url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/44/57/eae2cac10421feae6c0987e3dc106c6d86262b1cb379e171b017aba893a6/greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", size = 624901, upload-time = "2026-04-08T16:40:38.981Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8a/0e73c9b94f31d1cc257fe79a0eff621674141cdae7d6d00f40de378a1e42/greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", size = 423927, upload-time = "2026-04-08T16:43:05.293Z" }, + { url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" }, + { url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" }, + { url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" }, + { url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" }, + { url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" }, + { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, + { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, + { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, + { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, + { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, + { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, + { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, +] + +[[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 = "imageio-ffmpeg" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" }, + { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[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 = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "playwright" +version = "1.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" }, + { url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, +] + +[[package]] +name = "playwright-kit" +version = "0.5.0" +source = { editable = "." } +dependencies = [ + { name = "axe-playwright-python" }, + { name = "imageio-ffmpeg" }, + { name = "playwright" }, + { name = "pytest" }, + { name = "pytest-playwright" }, + { name = "pytest-xdist" }, + { name = "pyyaml" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] +drive = [ + { name = "google-api-python-client" }, + { name = "google-auth" }, + { name = "google-auth-oauthlib" }, +] + +[package.metadata] +requires-dist = [ + { name = "axe-playwright-python", specifier = ">=0.1.4" }, + { name = "google-api-python-client", marker = "extra == 'drive'", specifier = ">=2.0" }, + { name = "google-auth", marker = "extra == 'drive'", specifier = ">=2.0" }, + { name = "google-auth-oauthlib", marker = "extra == 'drive'", specifier = ">=1.0" }, + { name = "imageio-ffmpeg", specifier = ">=0.4" }, + { name = "playwright", specifier = ">=1.50,<2.0" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-playwright", specifier = ">=0.5" }, + { name = "pytest-xdist", specifier = ">=3.0" }, + { name = "pyyaml", specifier = ">=6.0" }, +] +provides-extras = ["drive", "dev"] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[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 = "7.34.1" +source = { registry = "https://pypi.org/simple" } +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 = "3.0" +source = { registry = "https://pypi.org/simple" } +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 = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[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 = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-base-url" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" }, +] + +[[package]] +name = "pytest-playwright" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "playwright" }, + { name = "pytest" }, + { name = "pytest-base-url" }, + { name = "python-slugify" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/6b/913e36aa421b35689ec95ed953ff7e8df3f2ee1c7b8ab2a3f1fd39d95faf/pytest_playwright-0.7.2.tar.gz", hash = "sha256:247b61123b28c7e8febb993a187a07e54f14a9aa04edc166f7a976d88f04c770", size = 16928, upload-time = "2025-11-24T03:43:22.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/61/4d333d8354ea2bea2c2f01bad0a4aa3c1262de20e1241f78e73360e9b620/pytest_playwright-0.7.2-py3-none-any.whl", hash = "sha256:8084e015b2b3ecff483c2160f1c8219b38b66c0d4578b23c0f700d1b0240ea38", size = 16881, upload-time = "2025-11-24T03:43:24.423Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +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" }, +] +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 = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + +[[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/pr/SKILL.md b/plugins/ndf/skills/pr/SKILL.md index 4d3ba23d..8583afea 100644 --- a/plugins/ndf/skills/pr/SKILL.md +++ b/plugins/ndf/skills/pr/SKILL.md @@ -1,7 +1,7 @@ --- name: pr -description: "commit, push, PR作成を一括実行するワークフローコマンド" -argument-hint: "[base-branch]" +description: "commit, push, PR作成(または既存PR説明更新)を一括実行するワークフローコマンド。--draft指定でドラフトPR、base非mainの場合はcherry-pick-prに誘導する。" +argument-hint: "[--draft] [base-branch] or [commit-message]" disable-model-invocation: true allowed-tools: - Bash @@ -12,123 +12,150 @@ allowed-tools: # PR作成 -このプロジェクトのコードをcommit, pushし、GitHubでPull Requestを作成する。 +このプロジェクトのコードをcommit, pushし、GitHubでPull Requestを作成する。既にPRがあればPR説明を最新の変更内容に更新する。 **制約**: デフォルトブランチ(main, masterなど)で直接コミット禁止 -## 手順 +## 使用方法 -0. **PR確認** - - `git branch --show-current`で現在ブランチ確認 - - github mcpまたはghで現在のbranchから作成されているPRを確認 - - 既にPRが存在しOPEN状態なら`git add` → `git commit` → `git push`して終了(日本語メッセージ) - - 上位階層含むすべての変更をcommit - - PRがない、またはmerge/close済みなら次へ - -1. **ブランチ確認・切り替え** - - デフォルトブランチの場合: 新featureブランチ作成→切り替え - - デフォルトブランチ以外: git stash → git pull origin [デフォルトブランチ](コンフリクト時は停止しユーザに報告) → stash復帰 - -2. **変更コミット** - - `git status`→`git add`→`git commit`(日本語メッセージ) - - 上位階層含むすべての変更をcommit - -3. **プッシュ** - - `git push -u origin <branch-name>` - -4. **PR作成** - - **ベースブランチ**: $ARGUMENTS が渡された場合はそのブランチ、なければデフォルトブランチ - - タイトル・説明: 日本語、body: Summary+Test plan - - 機密情報(トークン、パスワード、APIキー等)を含めないこと - - body末尾に `<!-- I want to review in Japanese. -->` を入れる - - **bodyは必ずHEREDOC形式で渡すこと**(`\n`リテラル混入防止): - ```bash - gh pr create --title "タイトル" --body "$(cat <<'EOF' - ## Summary - - 変更内容 - - ## Test plan - - [ ] テスト項目 - - <!-- I want to review in Japanese. --> - EOF - )" - ``` +``` +/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へ誘導 +``` -## 命名規則 +## 引数の解釈 -- ブランチ: 英語(github flow) -- コミット・PR: 日本語 +- `--draft` が含まれていればドラフトPR +- 既知のベースブランチ名(`main`, `master`, `qa/*`, `release/*`, `staging/*` 等)が末尾にあればベース指定 +- それ以外の文字列はコミットメッセージとして扱う +- デフォルトは `main` ベース、非ドラフト -## 検証ブランチ(qa/epsilon等)へのPR作成手順 +## 手順 -**重要: featureブランチから直接検証ブランチへPRを作成してはいけません。** +### 0. PR確認 -### アンチパターン(禁止) +- `git branch --show-current` で現在ブランチを確認 +- `gh pr list --head <branch>` で既存PR確認 +- 既にPRが存在しOPEN状態なら: + - `git add` → `git commit`(日本語メッセージ)→ `git push` + - **既存PR説明を更新** する(「PR説明の更新」節を参照) + - 終了報告 +- PRがない、またはmerge/close済みなら次へ -``` -feature/xxx ──PR──→ qa/epsilon ← ❌ qa/epsilonをmergeするとmainが汚染される -``` +### 1. ブランチ確認・切り替え -`qa/epsilon`を`feature/xxx`にmergeしてconflictを解消すると、`feature/xxx → main`のPRにqa/epsilon固有のコードが混入します。 +- デフォルトブランチの場合: 新featureブランチを作成して切り替え +- デフォルトブランチ以外: `git stash` → `git pull origin <default-branch>`(コンフリクト時は停止しユーザに報告)→ `git stash pop` -### 正しい手順(cherry-pick方式) +### 2. ベースブランチ判定 -1. **検証ブランチから短命ブランチを作成** -2. **featureブランチから必要なコミットだけをcherry-pick** -3. **短命ブランチから検証ブランチへPRを作成** +- 引数の末尾が `main`/`master` 以外のベースブランチ名(`qa/staging`, `release/v2` 等)の場合: + - **警告を出して `/ndf:cherry-pick-pr <base>` に誘導する** + - 理由: base非mainのPRに直接pushすると `feature → main` のPRに環境固有コードが混入する(詳細は `/ndf:branch-fix-strategy`) + - ユーザーが明示的に継続を指示した場合のみ進める -```bash -# 1. 検証ブランチの最新を取得 -git fetch origin qa/epsilon +### 3. 変更コミット + +- `git status` → `git add` → `git commit`(日本語メッセージ) +- 引数で指定されたコミットメッセージがあればそれを使用、なければ差分から生成 +- 上位階層を含むすべての変更をcommit -# 2. 検証ブランチから短命ブランチを作成 -git checkout -b feature/xxx-for-epsilon origin/qa/epsilon +### 4. プッシュ -# 3. featureブランチから必要なコミットをcherry-pick -git log --oneline main..feature/xxx # コミット一覧を確認 -git cherry-pick <commit-hash-1> <commit-hash-2> ... +```bash +git push -u origin <branch-name> +``` -# 4. conflictがあれば解決してcontinue -git cherry-pick --continue +### 5. PR作成 -# 5. pushしてPR作成 -git push -u origin feature/xxx-for-epsilon -gh pr create --base qa/epsilon --title "feat: ○○機能(epsilon検証用)" --body "$(cat <<'EOF' +- `.github/pull_request_template.md` が存在すれば適用 +- `--draft` 指定ならドラフトPR作成 +- タイトル・説明は日本語、body は `## Summary` + `## Test plan` +- 機密情報(トークン、パスワード、APIキー等)を含めない +- body 末尾に `<!-- I want to review in Japanese. -->` を入れる +- **body は必ずHEREDOC形式で渡す**(`\n` リテラル混入防止): + +```bash +gh pr create --title "タイトル" $DRAFT_FLAG --body "$(cat <<'EOF' ## Summary -- feature/xxxからcherry-pickした検証用PR +- 変更内容 ## Test plan -- [ ] 検証環境で動作確認 +- [ ] テスト項目 <!-- I want to review in Japanese. --> EOF )" ``` -### コミット選定のコツ +`DRAFT_FLAG` は `--draft` 指定時のみ `--draft`、それ以外は空。 -```bash -# featureブランチの全コミットを確認(mainから分岐後) -git log --oneline main..feature/xxx +## PR説明の更新(既存PRがある場合) + +既存PRがある場合、以下の手順でPR説明を更新する: + +1. **変更内容の分析**: + - `git log origin/<default-branch>..HEAD` でブランチ全体のコミット履歴 + - `git diff origin/<default-branch>..HEAD --stat` で変更ファイル一覧 + - 必要に応じて変更ファイルの詳細を取得 +2. **既存PR説明の確認**: + - `gh pr view <number> --json body` で現在のPR説明を取得 + - 既存の関連リンク(Issue参照、設計ドキュメント等)は保持する +3. **PR説明の生成**: + - `.github/pull_request_template.md` のテンプレート構造に従う + - ブランチの**全コミット**の変更内容を反映する(最新コミットだけでなく全体) + - 「Summary」「Test plan」「やらないこと」等を適切に記述 +4. **更新の実行**: + ```bash + gh pr edit <number> --body "<new-description>" + ``` + +## 命名規則 + +- ブランチ: 英語(github flow) +- コミット・PR: 日本語 +- コミットメッセージ prefix 例: + - `feat:` 新機能 + - `fix:` バグ修正 + - `refactor:` リファクタリング + - `docs:` ドキュメント + - `test:` テスト + - `chore:` その他 + +## 検証ブランチ(qa/*等)へのPR作成 + +**重要: featureブランチから直接検証ブランチへPRを作成してはいけません。** + +### アンチパターン(禁止) -# 特定ファイルに関するコミットだけ確認 -git log --oneline main..feature/xxx -- path/to/file +``` +feature/xxx ──PR──→ qa/staging ← ❌ qa/staging をmergeするとmainが汚染される ``` -### まとめ +### 正しい手順 -| ケース | 方法 | -|--------|------| -| `feature/xxx → main` | 通常通りPR作成 | -| `feature/xxx → qa/epsilon` | 短命ブランチ + cherry-pickでPR作成 | -| conflict解消でqa/epsilonをmerge | **❌ 禁止**(main汚染の原因) | +`/ndf:cherry-pick-pr <base-branch>` を使う(自動化済み)。詳細な理由と手順は: +- `/ndf:cherry-pick-pr` — 自動化コマンド +- `/ndf:branch-fix-strategy` — 原則と手順 ## 作業完了報告(必須) -PR作成完了後、以下を報告: -- 基本情報(PRタイトル、ベース/ソースブランチ) +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/skills/problem-solving/SKILL.md b/plugins/ndf/skills/problem-solving/SKILL.md new file mode 100644 index 00000000..5be9d229 --- /dev/null +++ b/plugins/ndf/skills/problem-solving/SKILL.md @@ -0,0 +1,162 @@ +--- +name: problem-solving +description: "データ不整合・バグ・障害の問題解決ガイドライン。根本原因分析の手順、上流修正優先の原則、ハルシネーション防止チェック、データとコードの整合性検証、多層防御の考え方を扱う。" +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/skills/python-execution/SKILL.md b/plugins/ndf/skills/python-execution/SKILL.md index e7fc79c5..55f90bfa 100644 --- a/plugins/ndf/skills/python-execution/SKILL.md +++ b/plugins/ndf/skills/python-execution/SKILL.md @@ -1,14 +1,7 @@ --- name: python-execution -description: | - Python実行環境を自動判定し、適切なコマンドでPythonコードを実行します。uv、venv、システムPythonを自動検出。 - - このSkillは以下を提供します: - - プロジェクト環境の自動検出(uv/venv/システムPython) - - 実行コマンドの自動選択 - - uv環境のセットアップガイド - - Triggers: "python", "uv", "スクリプト", "python環境" +description: "Python 実行環境を自動判定し、適切なコマンドで Python コードを実行する。uv / venv / システム Python を自動検出し、uv 環境のセットアップガイドも含む。" +when_to_use: "Python スクリプトを実行 / セットアップするとき。Triggers: 'python', 'uv', 'スクリプト', 'python環境'" allowed-tools: - Read - Bash diff --git a/plugins/ndf/skills/qa-security-scan/03-report-template.md b/plugins/ndf/skills/qa-security-scan/03-report-template.md index c01e4566..d87bec9f 100644 --- a/plugins/ndf/skills/qa-security-scan/03-report-template.md +++ b/plugins/ndf/skills/qa-security-scan/03-report-template.md @@ -90,19 +90,30 @@ 2. **1週間以内**: Medium(悪用の可能性あり) 3. **次回リリース**: Low/Info(改善推奨) -## Codex CLI MCP統合 - -```javascript -// Codex でセキュリティスキャン実行 -const result = await codex({ - prompt: ` - 以下のコードをセキュリティスキャンしてください: - - OWASP Top 10 の脆弱性 - - 認証・認可の問題 - - 機密情報の露出 - - ${codeContent} - `, - 'approval-policy': 'on-request' -}); +## Codex CLI 連携 + +詳細な独立レビューが必要な場合は `corder` エージェントに委譲するか、`/ndf:codex` skill の手順で `codex exec` を直接起動する。例: + +```bash +# プロンプト書き出し +cat > /tmp/sec-scan-prompt.md <<'EOF' +あなたはセキュリティレビュアーです。以下の観点で対象ファイルを精査してください: +- OWASP Top 10 の脆弱性 +- 認証・認可の問題 +- 機密情報の露出 + +## 対象ファイル(絶対パス) +/workspace/src/... + +## 出力形式 +Markdown 標準出力。行番号と該当コードスニペットを明記。 +EOF + +# バックグラウンド起動 +codex exec --dangerously-bypass-approvals-and-sandbox -C "$PWD" \ + < /tmp/sec-scan-prompt.md \ + > /tmp/sec-scan-output.md \ + 2> /tmp/sec-scan-err.log & ``` + +詳細は `/ndf:codex` skill を参照。 diff --git a/plugins/ndf/skills/qa-security-scan/SKILL.md b/plugins/ndf/skills/qa-security-scan/SKILL.md index aa0f281c..ca1fa27b 100644 --- a/plugins/ndf/skills/qa-security-scan/SKILL.md +++ b/plugins/ndf/skills/qa-security-scan/SKILL.md @@ -1,15 +1,7 @@ --- name: qa-security-scan -description: | - Security scanning templates and checklists for OWASP Top 10, authentication, authorization, data protection. Use when conducting security testing or vulnerability assessment. - - This skill provides comprehensive security testing: - - OWASP Top 10 checklist with remediation - - Authentication and authorization testing - - Data protection verification - - Security report generation - - Triggers: "security scan", "vulnerability check", "OWASP", "security test", "セキュリティスキャン", "脆弱性チェック", "セキュリティテスト" +description: "Security scanning templates and checklists for OWASP Top 10, authentication, authorization, data protection. Includes remediation, auth/authz testing, data protection verification, security report generation." +when_to_use: "Use when conducting security testing or vulnerability assessment. Triggers: 'security scan', 'vulnerability check', 'OWASP', 'security test', 'セキュリティスキャン', '脆弱性チェック', 'セキュリティテスト'" --- # QA Security Scan Skill diff --git a/plugins/ndf/skills/researcher-report-templates/01-templates.md b/plugins/ndf/skills/researcher-report-templates/01-templates.md deleted file mode 100644 index ef600f66..00000000 --- a/plugins/ndf/skills/researcher-report-templates/01-templates.md +++ /dev/null @@ -1,178 +0,0 @@ -# レポートテンプレート集 - -## 調査レポートテンプレート - -```markdown -# [調査対象] 調査レポート - -## エグゼクティブサマリー - -[調査対象]は、[主要な特徴]を持つ[カテゴリ]です。 -- 主要ポイント1 -- 主要ポイント2 -- 主要ポイント3 - -本調査では、[調査内容]を調査しました。 - -## 調査目的 - -[なぜこの調査を行ったか] - -## 調査方法 - -[どのように情報を収集したか] -- 公式ドキュメント -- 技術ブログ -- 実装テスト - -## 調査結果 - -### 1. [トピック1] - -[詳細な発見事項] - -### 2. [トピック2] - -[詳細な発見事項] - -### 3. [トピック3] - -[詳細な発見事項] - -## 技術比較 - -| 項目 | オプションA | オプションB | オプションC | -|------|------------|------------|------------| -| [項目1] | [値] | [値] | [値] | -| [項目2] | [値] | [値] | [値] | -| [項目3] | [値] | [値] | [値] | - -## 推奨事項 - -1. **[推奨1]**: [詳細と理由] -2. **[推奨2]**: [詳細と理由] -3. **[推奨3]**: [詳細と理由] - -## 参考リンク - -- [リンクタイトル1](URL1) -- [リンクタイトル2](URL2) - -## 結論 - -[まとめと最終的な推奨] -``` - -## 技術比較テンプレート - -```markdown -# [技術A] vs [技術B] vs [技術C] - -## 概要 - -[比較の目的と背景] - -## 比較表 - -| 項目 | 技術A | 技術B | 技術C | -|------|-------|-------|-------| -| **タイプ** | [値] | [値] | [値] | -| **パフォーマンス** | [値] | [値] | [値] | -| **スケーラビリティ** | [値] | [値] | [値] | -| **コスト** | [値] | [値] | [値] | -| **学習曲線** | [値] | [値] | [値] | - -## 詳細分析 - -### 技術A - -**長所**: -- [長所1] -- [長所2] - -**短所**: -- [短所1] -- [短所2] - -**適用場面**: -- [ユースケース1] -- [ユースケース2] - -### 技術B - -[同様のフォーマット] - -### 技術C - -[同様のフォーマット] - -## 推奨 - -**プロジェクトの要件**: -- [要件1] -- [要件2] - -**推奨技術**: **[技術名]** - -**理由**: -1. [理由1] -2. [理由2] - -## 移行計画 - -1. [ステップ1] -2. [ステップ2] -3. [ステップ3] -``` - -## ベストプラクティステンプレート - -```markdown -# [トピック] ベストプラクティス - -## 背景 - -[なぜこれが重要か] - -## 推奨パターン - -### 1. [パターン名] - -**概要**: [説明] - -**実装例**: -```code -[コードサンプル] -``` - -**メリット**: -- [メリット1] -- [メリット2] - -### 2. [パターン名] - -[同様のフォーマット] - -## アンチパターン - -### ❌ [避けるべきパターン1] - -**問題**: [なぜ悪いか] - -**代替案**: [推奨される方法] - -### ❌ [避けるべきパターン2] - -[同様のフォーマット] - -## チェックリスト - -- [ ] [チェック項目1] -- [ ] [チェック項目2] -- [ ] [チェック項目3] - -## 参考リンク - -- [リンク1](URL1) -- [リンク2](URL2) -``` diff --git a/plugins/ndf/skills/researcher-report-templates/02-examples.md b/plugins/ndf/skills/researcher-report-templates/02-examples.md deleted file mode 100644 index b890c591..00000000 --- a/plugins/ndf/skills/researcher-report-templates/02-examples.md +++ /dev/null @@ -1,142 +0,0 @@ -# レポート実例集 - -## 例1: AWS Lambda 調査レポート - -```markdown -# AWS Lambda 調査レポート - -## エグゼクティブサマリー - -AWS Lambdaは、サーバーレスコンピューティングサービスであり、以下の特徴があります: -- 自動スケーリング -- 実行時間に基づく従量課金 -- 複数のランタイムサポート(Node.js、Python、Java等) - -本調査では、Lambdaのアーキテクチャ、ベストプラクティス、コスト最適化手法を調査しました。 - -## 調査結果 - -### 1. アーキテクチャ - -AWS Lambdaは、イベント駆動型のコンピューティングサービスです... - -### 2. パフォーマンス最適化 - -**コールドスタート対策**: -- プロビジョニング済み同時実行数の使用 -- 関数サイズの最小化 -- レイヤーの活用 - -### 3. コスト最適化 - -- 適切なメモリ配分(メモリとCPUは比例) -- タイムアウト設定の最適化 -- 不要な実行の削減(冪等性の確保) - -## 技術比較 - -| 項目 | AWS Lambda | Google Cloud Functions | Azure Functions | -|------|-----------|----------------------|----------------| -| 最大実行時間 | 15分 | 9分 | 10分(Consumption) | -| 対応言語 | 10+ | 8+ | 10+ | -| 最小メモリ | 128MB | 128MB | 128MB | -| 価格 | $0.20/100万リクエスト | $0.40/100万リクエスト | $0.20/100万リクエスト | - -## 推奨事項 - -1. **本番環境での推奨設定**: - - メモリ: 1024MB以上 - - タイムアウト: 実際の処理時間 + 30%のバッファ - - プロビジョニング済み同時実行数: ピークトラフィックの50% - -2. **監視・ロギング**: - - CloudWatch Logsの有効化 - - X-Rayトレーシングの有効化 - -## 参考リンク - -- [AWS Lambda公式ドキュメント](https://docs.aws.amazon.com/lambda/) -- [Lambda ベストプラクティス](https://docs.aws.amazon.com/lambda/latest/dg/best-practices.html) - -## 結論 - -AWS Lambdaは、スケーラブルで費用対効果の高いサーバーレス実装に最適です。 -``` - -## 例2: データベース選定レポート - -```markdown -# データベース選定: PostgreSQL vs MySQL vs MongoDB - -## 概要 - -プロジェクトに最適なデータベースを選定するため、3つの主要データベースを比較しました。 - -## 比較表 - -| 項目 | PostgreSQL | MySQL | MongoDB | -|------|-----------|-------|---------| -| **タイプ** | リレーショナル | リレーショナル | NoSQL(ドキュメント) | -| **トランザクション** | ACID準拠 | ACID準拠 | ACID準拠(4.0+) | -| **スケーラビリティ** | 垂直 + レプリケーション | 垂直 + レプリケーション | 水平シャーディング | -| **JSON サポート** | ネイティブ | 5.7+ | ネイティブ | - -## 詳細分析 - -### PostgreSQL - -**長所**: -- 高度なSQL機能(ウィンドウ関数、CTE、配列型) -- JSONBによる高速JSONクエリ -- 拡張性が高い(PostGIS、pg_vector等) - -**短所**: -- 水平スケーリングが複雑 -- 初期設定・チューニングが必要 - -**適用場面**: -- 複雑なクエリが必要 -- データ整合性が最重要 -- 分析ワークロード - -### MySQL - -**長所**: -- シンプルで学習しやすい -- 高速な読み取り性能 - -**短所**: -- 複雑なクエリのパフォーマンス -- 拡張性がPostgreSQLより低い - -### MongoDB - -**長所**: -- スキーマレス(柔軟性) -- 水平スケーリングが容易 - -**短所**: -- 複雑なJOINが苦手 -- データ重複の可能性 - -## 推奨 - -**プロジェクトの要件**: -- ユーザー、注文、商品データ -- トランザクション整合性が重要 -- 複雑な集計レポート - -**推奨データベース**: **PostgreSQL** - -**理由**: -1. ACID準拠の堅牢性 -2. 複雑なクエリのサポート -3. JSONデータの効率的な処理 - -## 移行計画 - -1. PostgreSQL 16のセットアップ -2. スキーマ設計とインデックス最適化 -3. パフォーマンステスト -4. 段階的なデータ移行 -``` diff --git a/plugins/ndf/skills/researcher-report-templates/SKILL.md b/plugins/ndf/skills/researcher-report-templates/SKILL.md deleted file mode 100644 index b2b16255..00000000 --- a/plugins/ndf/skills/researcher-report-templates/SKILL.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: researcher-report-templates -description: | - Generate structured research reports with findings, comparisons, recommendations, and citations. Use when documenting investigation results or creating technical comparisons. - - This skill provides comprehensive research report templates: - - Investigation findings with proper structure - - Technology comparison tables - - Best practices summaries - - Citation and reference management - - Triggers: "create report", "summarize findings", "compare technologies", "research report", "調査レポート", "技術比較", "ベストプラクティス" -allowed-tools: - - Read - - Write ---- - -# Researcher Report Templates Skill - -## 概要 - -researcherエージェントが調査結果を構造化されたレポートにまとめる際に使用します。調査レポート、技術比較、ベストプラクティス文書などのテンプレートを提供します。 - -## クイックリファレンス - -### テンプレート構造 - -**調査レポート**: -1. エグゼクティブサマリー(3-5行) -2. 調査目的・方法 -3. 調査結果 -4. 技術比較(表形式) -5. 推奨事項 -6. 参考リンク -7. 結論 - -**技術比較**: -1. 概要 -2. 比較表(項目別) -3. 詳細分析(長所・短所) -4. 推奨と理由 -5. 移行計画 - -### 基本的な使い方 - -```bash -node scripts/generate-report.js research-notes.md -``` - -## ベストプラクティス - -| DO | DON'T | -|----|-------| -| 構造化された形式を使用 | 主観的な評価 | -| 客観的な事実に基づく | 情報源の記載漏れ | -| 引用元を明記 | 冗長な説明 | -| 比較表で視覚化 | 不完全な比較 | -| エグゼクティブサマリーを最初に | 結論を最後まで隠す | - -## 詳細ガイド - -| ファイル | 内容 | -|---------|------| -| `01-templates.md` | 調査レポート、技術比較、ベストプラクティスのテンプレート | -| `02-examples.md` | AWS Lambda調査、データベース選定の実例 | - -## 関連リソース - -- **scripts/generate-report.js**: レポート生成スクリプト diff --git a/plugins/ndf/skills/resolve-pr-comments/SKILL.md b/plugins/ndf/skills/resolve-pr-comments/SKILL.md new file mode 100644 index 00000000..38d1c84d --- /dev/null +++ b/plugins/ndf/skills/resolve-pr-comments/SKILL.md @@ -0,0 +1,146 @@ +--- +name: resolve-pr-comments +description: "対応済みPRコメントに返信し、スレッドをresolvedにする。/ndf:fixで修正完了後のクロージング作業。修正は行わず、コメント返信とresolve操作のみ実行する。" +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=<comment_id> +``` + +#### スレッドをResolve(GraphQL) + +まず Thread Node ID を取得: + +```bash +gh api "repos/:owner/:repo/pulls/comments/<comment_id>" --jq '.node_id' +``` + +その上でResolve: + +```bash +gh api graphql -f query=' + mutation { + resolveReviewThread(input: {threadId: "<THREAD_NODE_ID>"}) { + 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/skills/review-branch/SKILL.md b/plugins/ndf/skills/review-branch/SKILL.md new file mode 100644 index 00000000..be447dd9 --- /dev/null +++ b/plugins/ndf/skills/review-branch/SKILL.md @@ -0,0 +1,129 @@ +--- +name: review-branch +description: "現在のブランチの実装をmainとの差分でレビューする。PR作成前のセルフレビュー用途。コード品質・セキュリティ・パフォーマンス・テストの観点でフィードバックを返す。修正は行わずレビュー結果のみ報告 (READ-ONLY)。" +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/skills/review-pr-comments/SKILL.md b/plugins/ndf/skills/review-pr-comments/SKILL.md new file mode 100644 index 00000000..e424e725 --- /dev/null +++ b/plugins/ndf/skills/review-pr-comments/SKILL.md @@ -0,0 +1,102 @@ +--- +name: review-pr-comments +description: "既存PRの全コメントを確認し、対応可否を判定する(READ-ONLY)。修正は一切行わず、重大/改善推奨/軽微/参考/別PR対応に分類する。/ndf:fixで修正する前の優先度判定用。" +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コメント取得 + +```bash +gh pr view "$PR_NUMBER" --json comments,reviewDecision +gh api "repos/:owner/:repo/pulls/$PR_NUMBER/comments" +``` + +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/skills/review/SKILL.md b/plugins/ndf/skills/review/SKILL.md index 59946ce2..dfce1fda 100644 --- a/plugins/ndf/skills/review/SKILL.md +++ b/plugins/ndf/skills/review/SKILL.md @@ -1,7 +1,7 @@ --- name: review -description: "PRを専門家としてレビューし、Approve/Request Changesを判定する" -argument-hint: "[PR番号]" +description: "PRを専門家としてレビューし、Approve/Request Changesを判定する。第二引数で外部AI(codex / gemini)への委譲も可能" +argument-hint: "[PR番号] [AIエージェント(codex|gemini)]" disable-model-invocation: true allowed-tools: - Bash @@ -14,22 +14,324 @@ allowed-tools: 直前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=<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`) は使わない**: 変数展開は必要だが、`$SHA` 等に特殊文字が混入した場合 +> 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 <PR番号> --json title,body,baseRefName,headRefName,url,headRefOid` で PR メタ情報を取得 +2. `gh pr diff <PR番号>` で差分を取得(または変更ファイル一覧 + 必要箇所を `gh pr view <PR番号> --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/<agent>-review-pr<番号>-payload.json` に書き出す +(codex なら `apply_patch`、gemini なら `write_file` を使用): + +\`\`\`json +{ + "commit_id": "<headRefOid>", + "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 <PR> --name-only` で取得した一覧から選ぶ) +- `line` は **差分に含まれる行**(追加行・コンテキスト行)に限る。`side=RIGHT` がデフォルト +- `commit_id` は `gh pr view <PR> --json headRefOid -q .headRefOid` の値を使う + +### 2. 投稿 + +\`\`\`bash +OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +gh api -X POST "repos/$OWNER_REPO/pulls/<PR>/reviews" \ + --input /tmp/<agent>-review-pr<番号>-payload.json \ + > /tmp/<agent>-review-pr<番号>-response.json +\`\`\` + +### 3. 結果サマリの書き出し(メインが読む) + +`/tmp/<agent>-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/<PR>#pullrequestreview-...", + "comments_count": 5, + "by_severity": {"critical": 0, "major": 2, "minor": 2, "nit": 1}, + "payload_path": "/tmp/<agent>-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 <PR> --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タイトル、レビュー結果、変更の種類) -- 良い点 -- 指摘事項(カテゴリ、重要度、ファイル、指摘内容、推奨対応) -- レビュー観点別評価(言語慣用性、可読性、コード品質、保守性、セキュリティ、テストカバレッジ) -- 統計(指摘問題数、重要度別内訳) +レビュー結果は **PR 上に投稿済み** であることが前提。ユーザーへの報告は以下に絞る: + +- 利用エージェント(claude / codex / gemini のいずれか) +- 投稿結果(review URL、event = APPROVE / REQUEST_CHANGES / COMMENT) +- 件数サマリ(インラインコメント数、重要度別内訳) +- 総評(review body)の要約 - PR URL + +詳細な指摘内容は PR 上のインラインコメントに残っているため、ユーザー宛報告では繰り返さない。 diff --git a/plugins/ndf/skills/scanner-pdf-analysis/01-usage-guide.md b/plugins/ndf/skills/scanner-pdf-analysis/01-usage-guide.md deleted file mode 100644 index fd0d50f7..00000000 --- a/plugins/ndf/skills/scanner-pdf-analysis/01-usage-guide.md +++ /dev/null @@ -1,85 +0,0 @@ -# PDF解析 使用ガイド - -## スクリプト詳細 - -### analyze-pdf.py - -PDFを解析し、構造化されたデータを抽出します。 - -**機能**: -- PyPDF2: テキスト抽出、メタデータ取得 -- tabula-py: テーブル抽出(Java必要) -- pdfplumber: 高精度なレイアウト解析 - -**コード概要**: -```python -import PyPDF2 -import tabula -import pdfplumber - -def analyze_pdf(pdf_path): - # メタデータ取得 - with open(pdf_path, 'rb') as f: - reader = PyPDF2.PdfReader(f) - page_count = len(reader.pages) - - # テキスト抽出 - text = ''.join([page.extract_text() for page in reader.pages]) - - # テーブル抽出 - tables = tabula.read_pdf(pdf_path, pages='all') - - # pdfplumberでレイアウト解析 - with pdfplumber.open(pdf_path) as pdf: - # セクション識別(フォントサイズで判定) - sections = extract_sections(pdf) - - return { - 'page_count': page_count, - 'text': text, - 'tables': tables, - 'sections': sections - } -``` - -## ライブラリの使い分け - -| ライブラリ | 用途 | 特徴 | -|-----------|------|------| -| PyPDF2 | テキスト抽出、メタデータ | 軽量、基本機能 | -| tabula-py | テーブル抽出 | 高精度、Java必要 | -| pdfplumber | レイアウト解析 | 座標情報取得可能 | -| camelot-py | テーブル抽出(代替) | 高精度、複雑な表対応 | - -## トラブルシューティング - -### Q: テキストが抽出できない - -A: 画像ベースPDFの可能性があります。OCR(Tesseract)を使用してください: -```bash -pip install pytesseract -# OCRでテキスト抽出 -python scripts/analyze-pdf.py --ocr document.pdf -``` - -### Q: テーブルが正しく抽出されない - -A: 複数の方法を試してください: -1. tabula-py(Java必要) -2. pdfplumber(Python純正) -3. camelot-py(高精度) - -### Q: 日本語が文字化けする - -A: エンコーディングを指定: -```python -text = extract_text(pdf_path, encoding='utf-8') -``` - -### Q: メモリ不足 - -A: ページ範囲を指定して処理: -```python -# 特定のページのみ処理 -tables = tabula.read_pdf(pdf_path, pages='1-10') -``` diff --git a/plugins/ndf/skills/scanner-pdf-analysis/02-examples.md b/plugins/ndf/skills/scanner-pdf-analysis/02-examples.md deleted file mode 100644 index fe65809b..00000000 --- a/plugins/ndf/skills/scanner-pdf-analysis/02-examples.md +++ /dev/null @@ -1,136 +0,0 @@ -# PDF解析 実装例 - -## 例1: 技術仕様書の分析 - -```python -# 技術仕様書から要件を抽出 -result = analyze_pdf('spec.pdf', extract_tables=True) - -# テーブル(要件一覧)をCSVに保存 -for i, table in enumerate(result['tables']): - table.to_csv(f'requirements_{i}.csv', index=False) - -# 要約をMarkdownに保存 -with open('spec-summary.md', 'w') as f: - f.write(f"# 仕様書要約\n\n") - f.write(f"ページ数: {result['page_count']}\n\n") - f.write(f"## 抽出要件\n\n") - for i, table in enumerate(result['tables']): - f.write(f"### 要件テーブル {i+1}\n\n") - f.write(table.to_markdown()) - f.write("\n\n") -``` - -## 例2: 論文の要約 - -```python -# 論文PDFを読み込み -result = analyze_pdf('research-paper.pdf', summarize=True) - -# 重要なセクションを抽出 -sections_of_interest = ['Abstract', 'Introduction', 'Conclusion'] -summary = [] - -for section in result['sections']: - if section['title'] in sections_of_interest: - summary.append(f"## {section['title']}\n{section['text']}\n") - -# 要約を保存 -with open('paper-summary.md', 'w') as f: - f.write('\n'.join(summary)) -``` - -## 例3: 請求書からデータ抽出 - -```python -# 請求書PDFからテーブル抽出 -result = analyze_pdf('invoice.pdf', extract_tables=True) - -# 最初のテーブル(請求明細)を取得 -invoice_items = result['tables'][0] - -# CSVに変換 -invoice_items.to_csv('invoice-items.csv', index=False) - -# 合計金額を計算 -total = invoice_items['金額'].sum() -print(f"合計金額: {total}円") -``` - -## 例4: 複数PDFの一括処理 - -```python -import glob - -def batch_analyze(pdf_dir, output_dir): - for pdf_path in glob.glob(f'{pdf_dir}/*.pdf'): - result = analyze_pdf(pdf_path) - - # ファイル名を取得 - filename = os.path.basename(pdf_path).replace('.pdf', '') - - # 結果を保存 - with open(f'{output_dir}/{filename}-analysis.md', 'w') as f: - f.write(f"# {filename} 分析結果\n\n") - f.write(f"ページ数: {result['page_count']}\n\n") - f.write(f"## テキスト\n{result['text'][:1000]}...\n") - -# 使用 -batch_analyze('documents/', 'analysis/') -``` - -## 例5: OCRを使用した画像PDF処理 - -```python -import pytesseract -from pdf2image import convert_from_path - -def analyze_scanned_pdf(pdf_path): - # PDFを画像に変換 - images = convert_from_path(pdf_path) - - text = [] - for i, image in enumerate(images): - # OCRでテキスト抽出 - page_text = pytesseract.image_to_string(image, lang='jpn') - text.append(f"--- Page {i+1} ---\n{page_text}") - - return '\n'.join(text) - -# 使用 -text = analyze_scanned_pdf('scanned-document.pdf') -print(text) -``` - -## 出力テンプレート - -```markdown -# [ファイル名] 分析結果 - -## 概要 -- ページ数: XX -- テーブル数: XX -- 作成日: YYYY-MM-DD - -## 重要ポイント -1. [ポイント1] -2. [ポイント2] -3. [ポイント3] - -## 抽出テーブル - -### テーブル1 (ページ X) -| 列1 | 列2 | 列3 | -|-----|-----|-----| -| A | B | C | - -## セクション構造 -1. はじめに (p.1) -2. 背景 (p.3) -3. 方法 (p.7) -4. 結果 (p.15) -5. 結論 (p.23) - -## 全文テキスト -[抽出されたテキスト...] -``` diff --git a/plugins/ndf/skills/scanner-pdf-analysis/SKILL.md b/plugins/ndf/skills/scanner-pdf-analysis/SKILL.md deleted file mode 100644 index 2aab8330..00000000 --- a/plugins/ndf/skills/scanner-pdf-analysis/SKILL.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -name: scanner-pdf-analysis -description: | - Analyze PDF documents with table extraction, section identification, and content summarization. Use when reading technical documents, reports, or papers. - - This skill provides PDF analysis capabilities: - - Text extraction and OCR - - Table detection and CSV conversion - - Section and heading identification - - Key points summarization - - Triggers: "analyze PDF", "extract tables", "summarize document", "read PDF", "PDF解析", "テーブル抽出", "ドキュメント要約" -allowed-tools: - - Bash - - Write ---- - -# Scanner PDF Analysis Skill - -## 概要 - -scannerエージェントがPDFドキュメントを分析し、構造化されたデータを抽出する際に使用します。テーブル抽出、セクション識別、要約生成などの機能を提供します。 - -## ツール優先順位 - -1. **MarkItDown MCP(最優先)** - `mcp-markitdown@ai-plugins` プラグイン -2. **Python スクリプト(フォールバック)** - MarkItDown MCPが利用できない場合 - -## クイックリファレンス - -### 方法1: MarkItDown MCP(推奨) - -```bash -# ローカルPDFをMarkdownに変換 -mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown uri="file:///path/to/report.pdf" - -# URLからPDFを変換 -mcp__plugin_mcp-markitdown_markitdown__convert_to_markdown uri="https://example.com/report.pdf" -``` - -MarkItDown MCPはPDFのテキスト・テーブルをMarkdownに変換します。追加ライブラリのインストールは不要です。 - -### 方法2: Python スクリプト(フォールバック) - -MarkItDown MCPが利用できない場合や、テーブル個別抽出など高度な処理が必要な場合に使用します。 - -```bash -# 基本的な分析 -python scripts/analyze-pdf.py report.pdf - -# テーブル抽出 + 要約 -python scripts/analyze-pdf.py report.pdf --extract-tables --summarize - -# 出力ファイル指定 -python scripts/analyze-pdf.py report.pdf --output=analysis-result.md -``` - -**必要なライブラリ**: -```bash -pip install PyPDF2 tabula-py pdfplumber -``` - -### 出力形式 - -```markdown -# report.pdf 分析結果 - -## 概要 -- ページ数: 25 -- テーブル数: 3 - -## 重要ポイント -1. [ポイント1] -2. [ポイント2] - -## 抽出テーブル -[テーブルデータ] -``` - -## ベストプラクティス - -| DO | DON'T | -|----|-------| -| 高品質なPDF(テキストベース) | スキャンPDFに直接適用 | -| ページ範囲指定(必要な部分のみ) | 複雑なレイアウト | -| テーブル抽出結果を検証 | 暗号化PDF | -| OCR使用(画像ベースPDF) | 大量ページの一括処理 | - -## 詳細ガイド - -| ファイル | 内容 | -|---------|------| -| `01-usage-guide.md` | スクリプト詳細、ライブラリの使い分け | -| `02-examples.md` | 技術仕様書、論文、請求書の解析例 | - -## 関連Skill - -- **scanner-excel-extraction**: Excelファイル解析 -- **data-analyst-export**: 抽出データのエクスポート diff --git a/plugins/ndf/skills/skill-development/01-skill-structure.md b/plugins/ndf/skills/skill-development/01-skill-structure.md deleted file mode 100644 index f7d3c418..00000000 --- a/plugins/ndf/skills/skill-development/01-skill-structure.md +++ /dev/null @@ -1,175 +0,0 @@ -# SKILL.md構造ガイド - -## 基本構造 - -```yaml ---- -name: my-skill-name -description: | - スキルの説明。Claudeがいつ使用するか判断するために使用。 - - Use when asked about keyword1 or keyword2. -allowed-tools: - - Read - - Write ---- - -# スキルタイトル - -## 概要 - -スキルの目的と使用方法の概要。 - -## 使用方法 - -具体的な使用手順。 -``` - -## YAMLフロントマター フィールド一覧 - -### 必須/推奨フィールド - -| フィールド | 必須 | 説明 | -|-----------|------|------| -| `name` | いいえ | スキル名(省略時はディレクトリ名)。小文字、数字、ハイフンのみ。最大64文字 | -| `description` | **推奨** | スキルの目的と使用タイミング。Claudeの自動読み込み判断に使用 | - -### ツール制御 - -| フィールド | デフォルト | 説明 | -|-----------|----------|------| -| `allowed-tools` | なし | 許可なく使用できるツールのリスト | - -```yaml -# 例 -allowed-tools: - - Read - - Grep - - Glob - - Bash(git *) # gitで始まるBashコマンドのみ - - Bash(npm *) # npmで始まるBashコマンドのみ -``` - -### 呼び出し制御 - -| フィールド | デフォルト | 説明 | -|-----------|----------|------| -| `disable-model-invocation` | `false` | `true`でClaude自動呼び出し禁止(手動のみ) | -| `user-invocable` | `true` | `false`でスラッシュメニューから非表示 | - -```yaml -# deployなど副作用のあるスキル ---- -name: deploy -disable-model-invocation: true ---- -``` - -### 実行コンテキスト - -| フィールド | デフォルト | 説明 | -|-----------|----------|------| -| `context` | なし | `fork`でサブエージェントとして実行 | -| `agent` | なし | `context: fork`時のエージェントタイプ | -| `model` | 継承 | 使用するモデル | -| `effort` | 継承 | `low` / `medium` / `high` / `max`(maxはOpus 4.6のみ) | -| `paths` | なし | globパターンで自動有効化を特定ファイルに制限 | -| `shell` | `bash` | `!`command``のシェル(`bash` or `powershell`) | - -```yaml -# サブエージェントとして実行 ---- -name: analyze -context: fork -agent: Explore ---- -``` - -```yaml -# 特定ファイルパターンでのみ自動有効化 ---- -name: react-helper -paths: "src/components/**/*.tsx, src/hooks/**/*.ts" ---- -``` - -```yaml -# effortレベル指定 ---- -name: deep-analysis -effort: high -context: fork -agent: Explore ---- -``` - -### フック - -settings.jsonと同じ構造をYAMLフロントマター内に記述する。 - -```yaml ---- -hooks: - PreToolUse: - - matcher: "Bash" - hooks: - - type: command - command: "./scripts/security-check.sh" - PostToolUse: - - matcher: "Write" - hooks: - - type: command - command: "./scripts/lint.sh" ---- -``` - -> **注意**: `pre-invoke`/`post-invoke`というイベント名は存在しない。`PreToolUse`, `PostToolUse`等のイベント名を使用すること。 - -## name フィールドのルール - -- **使用可能文字**: 小文字、数字、ハイフン(`-`) -- **最大長**: 64文字 -- **省略時**: ディレクトリ名を使用 - -```yaml -# OK -name: my-skill-name -name: deploy-prod -name: code-review - -# NG -name: MySkill # 大文字 -name: my_skill # アンダースコア -name: my skill # スペース -``` - -## description のベストプラクティス - -### 良い例 - -```yaml -description: | - Explains code with visual diagrams and analogies. - Use when explaining how code works, teaching about a codebase, - or when the user asks "how does this work?" -``` - -### 悪い例 - -```yaml -# 曖昧すぎる -description: コードに関するスキル - -# 長すぎる(250文字でtruncateされる) -description: このスキルは...(長文) -``` - -## スキルの配置場所 - -| レベル | パス | 適用範囲 | -|-------|-----|---------| -| Personal | `~/.claude/skills/<name>/SKILL.md` | 全プロジェクト | -| Project | `.claude/skills/<name>/SKILL.md` | このプロジェクトのみ | -| Plugin | `<plugin>/skills/<name>/SKILL.md` | プラグイン有効時 | - -**優先順位**: Enterprise > Personal > Project(同名の場合) diff --git a/plugins/ndf/skills/skill-development/02-progressive-disclosure.md b/plugins/ndf/skills/skill-development/02-progressive-disclosure.md deleted file mode 100644 index 59836967..00000000 --- a/plugins/ndf/skills/skill-development/02-progressive-disclosure.md +++ /dev/null @@ -1,147 +0,0 @@ -# Progressive Disclosure(多段階読み込み) - -## 設計思想 - -コンテキストウィンドウの効率的な利用のため、情報を段階的に読み込む設計。全情報を一度に読み込まず、必要な時に必要な分だけ読み込む。 - -## 3段階の読み込みフロー - -| 段階 | 読み込み内容 | タイミング | トークン消費 | -|-----|------------|----------|------------| -| **第1段階** | `name` + `description`のみ | 常時(システムプロンプト) | 最小 | -| **第2段階** | `SKILL.md`全体 | スキル呼び出し時 | 中 | -| **第3段階** | 参照ファイル | Claudeが必要と判断時 | 必要分のみ | - -## ディレクトリ構成 - -### 推奨構成 - -``` -my-skill/ -├── SKILL.md # メイン指示(500行以下推奨) -├── 01-quick-start.md # クイックスタート -├── 02-detailed-guide.md # 詳細ガイド -├── 03-api-reference.md # APIリファレンス -├── 04-examples.md # 使用例 -└── scripts/ - └── helper.sh # 実行用スクリプト(読み込まず実行) -``` - -### 順序prefixのルール - -- `01-`, `02-`, `03-`... で読み込み順序を示す -- 番号順に重要度/基本度が高い -- Claudeは番号順に読み込みを検討 - -## SKILL.mdでの参照方法 - -### テーブル形式(推奨) - -```markdown -## 詳細ガイド - -| ファイル | 内容 | -|---------|------| -| `01-quick-start.md` | 基本的な使い方 | -| `02-detailed-guide.md` | 詳細な設定方法 | -| `03-api-reference.md` | API仕様 | -``` - -### リンク形式 - -```markdown -## 追加リソース - -- 詳細は [01-quick-start.md](01-quick-start.md) を参照 -- API仕様は [03-api-reference.md](03-api-reference.md) を参照 -``` - -## 実装例 - -### SKILL.md(メインファイル) - -```yaml ---- -name: database-ops -description: | - データベース操作のヘルパー。クエリ最適化、マイグレーション、バックアップを支援。 - - Use when working with databases, writing SQL queries, running migrations, or managing backups. -allowed-tools: - - Read - - Bash(psql *) ---- - -# Database Operations - -## 概要 - -データベース操作を支援するスキルです。 - -## クイックリファレンス - -| 操作 | コマンド | -|-----|---------| -| 接続テスト | `psql -h host -U user -d db` | -| バックアップ | `pg_dump db > backup.sql` | - -## 詳細ガイド - -| ファイル | 内容 | -|---------|------| -| `01-connection.md` | 接続設定 | -| `02-optimization.md` | クエリ最適化 | -| `03-migration.md` | マイグレーション | -``` - -### 01-connection.md(詳細ファイル) - -```markdown -# 接続設定ガイド - -## 環境変数 - -```bash -export PGHOST=localhost -export PGUSER=postgres -export PGDATABASE=mydb -``` - -## 接続文字列 - -``` -postgresql://user:password@host:5432/database -``` -... -``` - -## コンテキスト管理の注意点 - -### 上限 - -- description単体は**250文字**でtruncateされる -- 全スキルのdescription合計はコンテキストウィンドウの**1%**(フォールバック**8,000文字**)を超えると一部除外 -- 環境変数`SLASH_COMMAND_TOOL_CHAR_BUDGET`で調整可能 - -### 確認方法 - -```bash -# 除外されたスキルを確認 -/context -``` - -## ベストプラクティス - -### DO(推奨) - -- **SKILL.mdは500行以下**に保つ(公式推奨) -- **詳細は順序prefix付きファイル**に分割 -- **テーブル形式で参照ファイルを明示** -- **クイックリファレンスをSKILL.mdに含める** - -### DON'T(非推奨) - -- **500行超のSKILL.md**(公式上限) -- **全情報を1ファイルに詰め込む** -- **参照ファイルへのリンクなし** -- **曖昧なファイル名(`details.md`等)** diff --git a/plugins/ndf/skills/skill-development/03-tools-triggers.md b/plugins/ndf/skills/skill-development/03-tools-triggers.md deleted file mode 100644 index b0c7e93a..00000000 --- a/plugins/ndf/skills/skill-development/03-tools-triggers.md +++ /dev/null @@ -1,191 +0,0 @@ -# ツールとトリガー設定ガイド - -## allowed-tools の設定 - -### 基本構文 - -YAMLリスト形式とスペース区切り文字列の両方が有効。 - -```yaml -# YAMLリスト形式 ---- -allowed-tools: - - Read - - Write - - Grep - - Glob ---- - -# スペース区切り文字列形式 ---- -allowed-tools: Read Write Grep Glob ---- -``` - -### パターンマッチング - -```yaml ---- -allowed-tools: - - Bash(git *) # gitで始まるすべてのBashコマンド - - Bash(npm *) # npmで始まるすべてのBashコマンド - - Bash(python *) # pythonで始まるすべてのBashコマンド - - Bash(uv *) # uvで始まるすべてのBashコマンド ---- -``` - -### よく使うツール組み合わせ - -| 用途 | allowed-tools | -|-----|---------------| -| 読み取り専用 | `Read, Grep, Glob` | -| コード編集 | `Read, Write, Edit, Grep, Glob` | -| Git操作 | `Read, Bash(git *)` | -| Python実行 | `Read, Bash(python *), Bash(uv *)` | -| 調査/探索 | `Read, Grep, Glob, WebFetch` | - -### 制限事項 - -- ユーザーのパーミッション設定が基本承認を制御 -- スキル外のツールは通常の承認フローに従う -- `/compact`や`/init`などの組み込みコマンドは利用不可 - -## description による自動呼び出し制御 - -### 「Use when」形式で使用タイミングを明示(公式推奨) - -```yaml ---- -name: code-review -description: | - コードレビューを実施し、品質とセキュリティをチェックします。 - Use when reviewing code quality, checking pull requests, or when asked to review code. ---- -``` - -### トリガー制御の比較 - -| 設定 | ユーザー呼び出し | Claude呼び出し | description読み込み | -|-----|---------------|--------------|------------------| -| デフォルト | ✅ | ✅ | 常時 | -| `disable-model-invocation: true` | ✅ | ❌ | 呼び出し時のみ | -| `user-invocable: false` | ❌ | ✅ | 常時 | - -### 副作用のあるスキル - -```yaml -# deploy, commit, push などは自動呼び出しを禁止 ---- -name: deploy -description: 本番環境にデプロイします -disable-model-invocation: true -allowed-tools: - - Bash(git *) - - Bash(npm *) ---- -``` - -## 引数の使用 - -### 位置引数 - -```yaml ---- -name: analyze -description: ファイルを分析 ---- - -# 分析対象: $ARGUMENTS - -$ARGUMENTS[0] を分析してレポートを生成します。 -``` - -| 変数 | 説明 | -|-----|------| -| `$ARGUMENTS` | すべての引数 | -| `$ARGUMENTS[0]` または `$0` | 最初の引数 | -| `$ARGUMENTS[1]` または `$1` | 2番目の引数 | - -### セッション変数 - -```yaml -${CLAUDE_SESSION_ID} # セッションID -${CLAUDE_SKILL_DIR} # SKILL.mdのあるディレクトリの絶対パス -``` - -### 引数ヒント - -```yaml ---- -name: fix-issue -argument-hint: "[issue-number]" ---- -``` - -スラッシュメニューで `/fix-issue [issue-number]` と表示される。 - -## 動的コンテキスト注入 - -シェルコマンドの結果をスキル内容に注入できる。 - -```yaml ---- -name: pr-summary -context: fork -agent: Explore -allowed-tools: - - Bash(gh *) ---- - -## PRコンテキスト - -- PR差分: !`gh pr diff` -- PRコメント: !`gh pr view --comments` -- 変更ファイル: !`gh pr diff --name-only` - -## タスク - -上記のPR情報を要約してください。 -``` - -### 構文 - -``` -!`command` # コマンド結果を注入 -``` - -## サブエージェント実行 - -```yaml ---- -name: deep-analysis -context: fork -agent: Explore # または Plan, general-purpose -model: sonnet # オプション ---- - -## タスク - -$ARGUMENTSを詳細に分析してください。 -``` - -| agent | 用途 | -|-------|-----| -| `Explore` | コードベース調査 | -| `Plan` | 実装計画策定 | -| `general-purpose` | 汎用タスク | - -## ベストプラクティス - -### DO(推奨) - -- **descriptionにトリガーキーワードを明示** -- **副作用スキルは`disable-model-invocation: true`** -- **allowed-toolsは必要最小限に** -- **パターンマッチングで安全に制限** - -### DON'T(非推奨) - -- **`Bash`を無制限に許可** -- **曖昧なdescription** -- **deploy/pushを自動呼び出し可能に** diff --git a/plugins/ndf/skills/skill-development/SKILL.md b/plugins/ndf/skills/skill-development/SKILL.md deleted file mode 100644 index 6708e288..00000000 --- a/plugins/ndf/skills/skill-development/SKILL.md +++ /dev/null @@ -1,170 +0,0 @@ ---- -name: skill-development -description: | - Claude Code Skills の開発ガイド。SKILL.md構造、フロントマター、Progressive Disclosure、動的コンテンツ注入を解説。 - Use when creating new skills, writing SKILL.md files, configuring allowed-tools, or designing skill architecture. Also use when asked about skill frontmatter fields, description best practices, or context: fork setup. -allowed-tools: - - Read - - Write - - Bash - - Glob ---- - -# Skill Development Guide - -公式ドキュメント準拠のClaude Code Skills開発ガイド。 - -> 情報源: [Skills公式ドキュメント](https://docs.claude.com/en/docs/claude-code/skills) / [Agent Skills仕様](https://agentskills.io/specification) / [anthropics/skills](https://github.com/anthropics/skills) - -## フロントマター完全リファレンス - -全フィールドは**任意**。`description`のみ推奨(Recommended)。 - -```yaml ---- -name: my-skill # 省略時はディレクトリ名。小文字・数字・ハイフンのみ(最大64文字) -description: | # 推奨。250文字で切り詰め。重要な用途を先頭に - What this skill does. - Use when ... -argument-hint: "[issue-number]" # オートコンプリートに表示されるヒント -disable-model-invocation: false # trueでClaude自動呼び出し禁止(手動/nameのみ) -user-invocable: true # falseでスラッシュメニュー非表示 -allowed-tools: Read Grep Glob # スペース区切り文字列 or YAMLリスト -model: sonnet # セッションのモデルを上書き -effort: high # low / medium / high / max(maxはOpus 4.6のみ) -context: fork # forkでサブエージェント実行 -agent: Explore # context:fork時のエージェント(省略時: general-purpose) -paths: "src/**/*.ts, lib/**" # 自動有効化のglobパターン -shell: bash # !`command`のシェル(bash / powershell) -hooks: # settings.jsonと同じ構造 - PreToolUse: - - matcher: "Bash" - hooks: - - type: command - command: "./scripts/check.sh" ---- -``` - -### 呼び出し制御マトリクス - -| 設定 | ユーザー呼出 | Claude呼出 | descriptionのコンテキスト | -|------|:-----------:|:----------:|:------------------------:| -| デフォルト | Yes | Yes | 常時 | -| `disable-model-invocation: true` | Yes | No | **含まれない** | -| `user-invocable: false` | No | Yes | 常時 | - -## description の書き方(公式推奨) - -**原則**: 「何をするか」+「いつ使うか」の両方を含める。 - -公式注意: Claudeはスキルを**使い損ねる傾向(undertrigger)**がある。少し積極的(pushy)な記述が推奨。 - -```yaml -# パターン1: Use when(公式標準) -description: | - Extracts text and tables from PDF files, fills forms, and merges PDFs. - Use when working with PDF documents or when the user mentions PDFs, - forms, or document extraction. - -# パターン2: TRIGGER when / DO NOT TRIGGER when(claude-apiスキルで使用) -description: | - Build apps with the Claude API or Anthropic SDK. - TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`, or user asks - to use Claude API. DO NOT TRIGGER when: code imports `openai`/other AI SDK. -``` - -**制限**: 250文字で切り詰め。重要な用途は先頭に。全スキルdescription合計はコンテキストの1%(フォールバック8,000文字)。 - -## Progressive Disclosure(3層構造) - -| 層 | 内容 | タイミング | サイズ目安 | -|----|------|-----------|-----------| -| Metadata | name + description | 常時コンテキストに存在 | ~100トークン | -| Instructions | SKILL.md本文 | スキル起動時にロード | <5,000トークン(500行以下推奨) | -| Resources | 参照ファイル | 必要時のみロード | 制限なし | - -### ディレクトリ構成 - -``` -my-skill/ -├── SKILL.md # 必須 — 概要とナビゲーション(500行以下推奨) -├── reference.md # 詳細APIドキュメント -├── examples.md # 使用例 -└── scripts/ - └── helper.py # 実行用スクリプト(読み込まず実行) -``` - -SKILL.md内で参照ファイルを明示: -```markdown -## Additional resources -- For complete API details, see [reference.md](reference.md) -- For usage examples, see [examples.md](examples.md) -``` - -> 300行超の参照ファイルには目次を含めること。ファイル参照はSKILL.mdから1階層に留める。 - -## 動的コンテンツ - -### シェルコマンド前処理 - -`` !`command` `` — Claudeに渡す**前に**実行され、結果で置換される。 - -```yaml ---- -name: pr-summary -description: Summarize changes in a pull request -context: fork -agent: Explore -allowed-tools: Bash(gh *) ---- -## PR context -- PR diff: !`gh pr diff` -- Changed files: !`gh pr diff --name-only` -``` - -### 文字列置換変数 - -| 変数 | 説明 | -|------|------| -| `$ARGUMENTS` | 全引数。未使用時は自動で末尾に`ARGUMENTS: <value>`が付与 | -| `$ARGUMENTS[N]` / `$N` | N番目の引数(0始まり) | -| `${CLAUDE_SESSION_ID}` | セッションID | -| `${CLAUDE_SKILL_DIR}` | SKILL.mdのあるディレクトリの絶対パス | - -### Extended Thinking - -スキル内に `ultrathink` と記述するだけで有効化。 - -## スキルの配置場所 - -| レベル | パス | 適用範囲 | -|-------|-----|---------| -| Enterprise | managed settings | 組織全体 | -| Personal | `~/.claude/skills/<name>/SKILL.md` | 全プロジェクト | -| Project | `.claude/skills/<name>/SKILL.md` | プロジェクトのみ | -| Plugin | `<plugin>/skills/<name>/SKILL.md` | プラグイン有効時 | - -**優先順位**: Enterprise > Personal > Project(同名時)。Plugin skillsは`plugin-name:skill-name`の名前空間で競合しない。 - -## スキルのコンテンツタイプ - -| タイプ | 用途 | 例 | -|--------|------|-----| -| **Reference** | 知識をインラインで適用(規約、パターン、スタイルガイド) | API設計規約、コーディング規約 | -| **Task** | 特定アクションの手順書(`/name`で手動実行) | デプロイ、コミット、コード生成 | - -Task型は `disable-model-invocation: true` 推奨。 - -## 詳細ガイド - -| ファイル | 内容 | -|---------|------| -| [01-skill-structure.md](01-skill-structure.md) | フロントマター各フィールドの詳細と例 | -| [02-progressive-disclosure.md](02-progressive-disclosure.md) | 多段階読み込みの実装 | -| [03-tools-triggers.md](03-tools-triggers.md) | allowed-tools、呼び出し制御、引数 | - -## 関連リソース - -- [Claude Code Skills 公式ドキュメント](https://docs.claude.com/en/docs/claude-code/skills) -- [Agent Skills 仕様](https://agentskills.io/specification) -- [anthropics/skills リポジトリ](https://github.com/anthropics/skills)(17個の公式スキル実例) diff --git a/plugins/ndf/skills/skill-stats/SKILL.md b/plugins/ndf/skills/skill-stats/SKILL.md new file mode 100644 index 00000000..9780602a --- /dev/null +++ b/plugins/ndf/skills/skill-stats/SKILL.md @@ -0,0 +1,99 @@ +--- +name: skill-stats +description: "Claude Code の transcript を集計して Skill 利用統計を算出する。呼び出し数、関連話題出現数、ヒット率を出力。特定 skill の利用傾向分析や新規 skill 候補の発見に使う。" +when_to_use: "Skill 利用統計 / hit rate を算出したいとき。Triggers: 'skill stats', 'skill統計', 'skill利用分析', 'skill usage', 'skill hit rate'" +allowed-tools: + - Bash + - Read +--- + +# Skill 利用統計スキル + +Claude Code の transcript JSONL ファイル (`~/.claude/projects/*.jsonl`) を解析し、NDFプラグインのskill利用統計を算出する。 + +## 用途 + +- どの skill がよく呼び出されているか把握 +- 呼び出されるべきだったのに呼ばれなかった skill を発見 +- skill の description / triggers の網羅性改善に役立てる + +## 使用方法 + +```bash +# 過去90日分を全プロジェクト合算 (デフォルト、transcript保持期間に合わせる) +/ndf:skill-stats + +# --- 期間フィルタ --- +/ndf:skill-stats --days 30 # 直近30日 +/ndf:skill-stats --from 2026-04-01 # 2026-04-01 以降 +/ndf:skill-stats --from 2026-04-01 --to 2026-04-30 # 絶対範囲 (両端inclusive) + +# --- skill / プロジェクト フィルタ --- +/ndf:skill-stats --skill pr # skill名部分一致 +/ndf:skill-stats --project carmo # プロジェクト名部分一致 + +# --- プロジェクト別集計 --- +/ndf:skill-stats --by-project # プロジェクトごとに表を分けて出力 +/ndf:skill-stats --by-project --project carmo # carmo を含むプロジェクトだけ分解 + +# --- 出力形式 --- +/ndf:skill-stats --format json # JSON (projects配列 + grand_skills) +/ndf:skill-stats --show-keywords # 抽出されたTriggersも併記 + +# --include-fallback: Triggers 未定義 skill でも description から語彙抽出してマッチ +# (ノイズが多いので通常は不要) +/ndf:skill-stats --include-fallback +``` + +内部的には以下のコマンドを実行する: + +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/skills/skill-stats/scripts/skill-stats.py "$@" +``` + +### プロジェクトの決定方法 + +transcript JSONL 先頭の `cwd` フィールドを優先してプロジェクトラベルを決める (例: `/work/ai-plugins` → `ai-plugins`)。取得できない場合は transcript ディレクトリ名 (例: `-work-ai-plugins`) を復元 (`-` → `/`) して使用する。 + +## 集計項目 + +| 項目 | 定義 | +|---|---| +| **呼び出し数** (invocations) | `assistant` メッセージ内の `tool_use.name=="Skill"` で `input.skill=="ndf:<name>"` の件数 | +| **関連話題数** (triggers) | `user` メッセージのテキストに、skillの `description` / Triggers キーワードが含まれる件数 | +| **ヒット数** (hits) | 関連話題を含むユーザーメッセージの直後 (次のユーザーメッセージまでの間) に該当skillが呼ばれた件数 | +| **ヒット率** (hit_rate) | `hits / triggers` (%) | + +### ヒット率の解釈 + +- **高い (80%+)**: description/triggers が適切で、該当文脈で正しく起動できている +- **低い (< 30%)**: キーワードが広すぎて関係ない話題にマッチしているか、モデルがskillを起動しにくい description になっている可能性 +- **triggers が 0**: description のキーワードがユーザー入力に出現していない → 該当用途で使われていないか、triggersの定義見直しが必要 + +## 出力例 (Markdown) + +``` +| skill | 呼び出し数 | 関連話題 | ヒット | ヒット率 | +|---|---:|---:|---:|---:| +| ndf:pr | 12 | 25 | 10 | 40.0% | +| ndf:fix | 3 | 8 | 3 | 37.5% | +... +| **合計** | **56** | **142** | **45** | **31.7%** | +``` + +## 前提条件 + +- Python 3.8+ (標準ライブラリのみ使用) +- `~/.claude/projects/` に transcript JSONL が存在 +- transcript の保持期間は `~/.claude/settings.json` の `cleanupPeriodDays` に依存。NDFプラグインの保持期間フックが 90 日を確保する + +## 制限事項 + +- **モデル起動型以外は関連話題数が計算できない場合がある**: `disable-model-invocation: true` の skill (例: `/ndf:pr` などのワークフロー系) は、ユーザーが明示的にスラッシュコマンドで呼び出すのが通常。triggers キーワードが description に明示されていなければ「関連話題」が 0 となり、ヒット率も計算不能となる +- **ユーザーメッセージのパース**: `<local-command-*>`, `<command-name>`, `<system-reminder>` タグは除外する。tool_result ブロックも除外 +- **日本語キーワードマッチ**: 単純な部分一致 (case-insensitive) のため、文脈を考慮した判定ではない + +## 関連スキル + +- `/ndf:markdown-writing` — 結果を読みやすく整形するためのガイドライン +- `/ndf:python-execution` — Python実行環境の判定 diff --git a/plugins/ndf/skills/skill-stats/scripts/skill-stats.py b/plugins/ndf/skills/skill-stats/scripts/skill-stats.py new file mode 100755 index 00000000..4a7e7182 --- /dev/null +++ b/plugins/ndf/skills/skill-stats/scripts/skill-stats.py @@ -0,0 +1,512 @@ +#!/usr/bin/env python3 +"""NDF skill usage statistics from Claude Code transcripts. + +Scans ~/.claude/projects/**/*.jsonl and counts, for each NDF skill: + - invocations: tool_use where name="Skill" and input.skill="ndf:<name>" + - triggers: user messages whose text contains keywords from the skill's + description / "Triggers:" line + - hits: user messages that (a) matched a trigger AND (b) were followed + by an invocation of the same skill before the next user turn + - hit_rate: hits / triggers (percent) + +Supports project-level breakdown and date-range filtering. +""" +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import sys +from collections import Counter, defaultdict +from datetime import datetime, timedelta +from typing import Iterable + + +def plugin_root_default() -> pathlib.Path: + env = os.environ.get("CLAUDE_PLUGIN_ROOT") + if env: + return pathlib.Path(env) + # scripts/skill-stats.py -> plugins/ndf/skills/skill-stats/scripts/ + return pathlib.Path(__file__).resolve().parents[3] + + +def _parse_date(s: str | None) -> datetime | None: + if not s: + return None + for fmt in ("%Y-%m-%d", "%Y/%m/%d"): + try: + return datetime.strptime(s, fmt) + except ValueError: + continue + raise SystemExit(f"[skill-stats] invalid date: {s} (expected YYYY-MM-DD)") + + +def iter_transcripts( + days: int | None, + date_from: datetime | None, + date_to: datetime | None, +) -> Iterable[pathlib.Path]: + """Yield transcript .jsonl paths whose mtime falls in the requested window. + + Priority: explicit --from/--to > --days (if neither given and days>0, use days). + """ + root = pathlib.Path.home() / ".claude" / "projects" + if not root.exists(): + return + + # Compute effective cutoffs + lower: datetime | None = date_from + upper: datetime | None = date_to + if lower is None and days is not None and days > 0: + lower = datetime.now() - timedelta(days=days) + # make upper inclusive to end-of-day + if upper is not None: + upper = upper + timedelta(days=1) - timedelta(microseconds=1) + + for p in root.rglob("*.jsonl"): + try: + mtime = datetime.fromtimestamp(p.stat().st_mtime) + except OSError: + continue + if lower and mtime < lower: + continue + if upper and mtime > upper: + continue + yield p + + +def iter_events(path: pathlib.Path) -> Iterable[dict]: + try: + with path.open("r", encoding="utf-8", errors="replace") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError: + continue + except OSError: + return + + +_FRONT_MATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL) +_QUOTED_RE = re.compile(r"['\"]([^'\"]+)['\"]") +_JA_WORD_RE = re.compile(r"[一-龥ぁ-んァ-ヶー]{2,}|[A-Za-z][A-Za-z0-9_-]{2,}") +_STOPWORDS = { + "true", "false", "null", "none", "when", "triggers", "trigger", + "description", "use", "used", "using", +} + + +def parse_front_matter(text: str) -> dict[str, str]: + m = _FRONT_MATTER_RE.match(text) + if not m: + return {} + fm = m.group(1) + out: dict[str, str] = {} + key = None + buf: list[str] = [] + for line in fm.splitlines(): + if re.match(r"^[A-Za-z_-]+:\s*", line): + if key is not None: + out[key] = "\n".join(buf).strip() + k, _, v = line.partition(":") + key = k.strip() + buf = [v.strip()] + else: + buf.append(line) + if key is not None: + out[key] = "\n".join(buf).strip() + return out + + +def extract_triggers(description: str, include_fallback: bool = False) -> tuple[list[str], str]: + triggers: list[str] = [] + m = re.search(r"Triggers?:\s*(.+)", description, re.IGNORECASE | re.DOTALL) + if m: + for q in _QUOTED_RE.findall(m.group(1)): + triggers.append(q.strip()) + if triggers: + return _dedupe_ci(triggers), "explicit" + if not include_fallback: + return [], "none" + flat = description.replace('"', " ").replace("'", " ") + seen: set[str] = set() + for w in _JA_WORD_RE.findall(flat): + w = w.strip() + if not w or w.lower() in _STOPWORDS: + continue + if len(w) < 3: + continue + if w not in seen: + seen.add(w) + triggers.append(w) + if len(triggers) >= 10: + break + return _dedupe_ci(triggers), "fallback" + + +def _dedupe_ci(items: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for t in items: + tl = t.lower() + if tl in seen: + continue + seen.add(tl) + out.append(t) + return out + + +def load_skills(plugin_root: pathlib.Path, include_fallback: bool = False) -> list[dict]: + out: list[dict] = [] + skills_dir = plugin_root / "skills" + if not skills_dir.is_dir(): + return out + for d in sorted(skills_dir.iterdir()): + if not d.is_dir(): + continue + f = d / "SKILL.md" + if not f.exists(): + continue + try: + text = f.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + fm = parse_front_matter(text) + name = fm.get("name", d.name).strip().strip('"') + desc = fm.get("description", "").strip().strip('"') + triggers, source = extract_triggers(desc, include_fallback=include_fallback) + out.append({ + "name": name, + "qualified": f"ndf:{name}", + "triggers": triggers, + "triggers_source": source, + "dir": d.name, + }) + return out + + +_SYSTEM_TAG_RE = re.compile(r"^\s*<(local-command|command-name|command-message|command-args|system-reminder)") + + +def extract_user_text(ev: dict) -> str: + if ev.get("type") != "user": + return "" + msg = ev.get("message") or {} + c = msg.get("content") + if isinstance(c, str): + if _SYSTEM_TAG_RE.match(c): + return "" + return c + if isinstance(c, list): + parts: list[str] = [] + for b in c: + if not isinstance(b, dict): + continue + if b.get("type") == "text": + t = b.get("text", "") + if t and not _SYSTEM_TAG_RE.match(t): + parts.append(t) + return "\n".join(parts) + return "" + + +def extract_skill_invocations(ev: dict) -> list[str]: + if ev.get("type") != "assistant": + return [] + msg = ev.get("message") or {} + content = msg.get("content") or [] + invoked: list[str] = [] + for c in content: + if not isinstance(c, dict): + continue + if c.get("type") != "tool_use": + continue + if c.get("name") != "Skill": + continue + inp = c.get("input") or {} + skill = inp.get("skill") or inp.get("name") or "" + if skill: + invoked.append(skill) + return invoked + + +def detect_project(path: pathlib.Path, first_cwd: str | None) -> str: + """Return a short project label for a transcript path. + + Preference: `cwd` field from transcript events > decoded parent dir name. + """ + if first_cwd: + # /work/ai-plugins -> ai-plugins + p = pathlib.Path(first_cwd) + return p.name or str(p) + # fallback: encoded dir name (e.g. "-work-ai-plugins") + parent = path.parent.name + if parent.startswith("-"): + # decode `-` back to `/` + return parent[1:].replace("-", "/") + return parent + + +def build_timeline(path: pathlib.Path) -> tuple[list[tuple[str, object]], str]: + """Return (timeline, project_label).""" + timeline: list[tuple[str, object]] = [] + first_cwd: str | None = None + for ev in iter_events(path): + if first_cwd is None: + cwd = ev.get("cwd") + if isinstance(cwd, str) and cwd: + first_cwd = cwd + t = ev.get("type") + if t == "user": + text = extract_user_text(ev) + if text: + timeline.append(("user", text)) + elif t == "assistant": + for skill in extract_skill_invocations(ev): + timeline.append(("skill", skill)) + project = detect_project(path, first_cwd) + return timeline, project + + +def aggregate_by_project( + transcripts: list[pathlib.Path], + skills: list[dict], + lookahead_cap: int = 100, +) -> dict[str, tuple[Counter, Counter, Counter]]: + """Return { project: (invocations, triggers_hits, hits) }.""" + result: dict[str, tuple[Counter, Counter, Counter]] = defaultdict( + lambda: (Counter(), Counter(), Counter()) + ) + skill_triggers = [ + (s["qualified"], [t.lower() for t in s["triggers"] if t]) + for s in skills + ] + for path in transcripts: + tl, project = build_timeline(path) + inv, trig_h, hits = result[project] + for i, (kind, data) in enumerate(tl): + if kind == "skill": + inv[data] += 1 + continue + if kind != "user": + continue + text_l = str(data).lower() + for qualified, trs in skill_triggers: + if not trs: + continue + if any(t in text_l for t in trs): + trig_h[qualified] += 1 + end = min(i + 1 + lookahead_cap, len(tl)) + for j in range(i + 1, end): + k2, d2 = tl[j] + if k2 == "user": + break + if k2 == "skill" and d2 == qualified: + hits[qualified] += 1 + break + return result + + +def merge_counters( + per_project: dict[str, tuple[Counter, Counter, Counter]], +) -> tuple[Counter, Counter, Counter]: + inv_total: Counter = Counter() + trig_total: Counter = Counter() + hits_total: Counter = Counter() + for inv, trig, hits in per_project.values(): + inv_total.update(inv) + trig_total.update(trig) + hits_total.update(hits) + return inv_total, trig_total, hits_total + + +def build_rows( + skills: list[dict], + invocations: Counter, + triggers_hits: Counter, + hits: Counter, +) -> tuple[list[dict], dict]: + rows: list[dict] = [] + total_inv = total_trig = total_hit = 0 + for s in sorted(skills, key=lambda x: x["name"]): + q = s["qualified"] + inv = invocations.get(q, 0) + trig = triggers_hits.get(q, 0) + hit = hits.get(q, 0) + rate = round(hit / trig * 100, 1) if trig else 0.0 + rows.append({ + "skill": q, + "triggers_source": s["triggers_source"], + "invocations": inv, + "triggers": trig, + "hits": hit, + "hit_rate_pct": rate, + "trigger_keywords": s["triggers"], + }) + total_inv += inv + if s["triggers_source"] == "explicit": + total_trig += trig + total_hit += hit + total_rate = round(total_hit / total_trig * 100, 1) if total_trig else 0.0 + total = { + "invocations": total_inv, + "triggers": total_trig, + "hits": total_hit, + "hit_rate_pct": total_rate, + } + return rows, total + + +def format_markdown(rows: list[dict], total: dict, heading: str | None = None) -> str: + lines: list[str] = [] + if heading: + lines.append(heading) + lines.extend([ + "| skill | triggers源 | 呼び出し数 | 関連話題 | ヒット | ヒット率 |", + "|---|---|---:|---:|---:|---:|", + ]) + for r in rows: + src = r["triggers_source"] + if src == "none": + rate = "-" + trig = "-" + hit = "-" + else: + rate = f"{r['hit_rate_pct']}%" if r["triggers"] else "-" + trig = str(r["triggers"]) + hit = str(r["hits"]) + lines.append( + f"| {r['skill']} | {src} | {r['invocations']} | {trig} | {hit} | {rate} |" + ) + lines.append( + f"| **合計** | | **{total['invocations']}** | **{total['triggers']}** | **{total['hits']}** | **{total['hit_rate_pct']}%** |" + ) + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="NDF skill usage statistics from Claude Code transcripts", + ) + ap.add_argument("--days", type=int, default=90, + help="集計対象の遡及日数 (default: 90、--from/--to 指定時は無視)") + ap.add_argument("--from", dest="date_from", default=None, + help="開始日 YYYY-MM-DD (inclusive)") + ap.add_argument("--to", dest="date_to", default=None, + help="終了日 YYYY-MM-DD (inclusive)") + ap.add_argument("--plugin-root", default=None, + help="NDFプラグインのルート (default: 自動検出)") + ap.add_argument("--format", choices=["md", "json"], default="md", + help="出力形式 (default: md)") + ap.add_argument("--skill", default=None, + help="skill名(部分一致)でフィルタ") + ap.add_argument("--project", default=None, + help="プロジェクト名(部分一致)でフィルタ") + ap.add_argument("--by-project", action="store_true", + help="プロジェクト別に個別のテーブルを出力") + ap.add_argument("--show-keywords", action="store_true", + help="各skillに抽出されたトリガーキーワードを出力") + ap.add_argument("--include-fallback", action="store_true", + help="Triggers欄が無いskillでも description から語彙抽出してマッチ (ノイズ多)") + args = ap.parse_args() + + plugin_root = pathlib.Path(args.plugin_root) if args.plugin_root else plugin_root_default() + if not (plugin_root / "skills").is_dir(): + print(f"[skill-stats] plugin root not found: {plugin_root}", file=sys.stderr) + return 2 + + date_from = _parse_date(args.date_from) + date_to = _parse_date(args.date_to) + # When explicit date range is given, days becomes informational only + effective_days = args.days if (date_from is None and date_to is None) else None + + skills = load_skills(plugin_root, include_fallback=args.include_fallback) + if args.skill: + skills = [s for s in skills if args.skill in s["name"]] + + transcripts = list(iter_transcripts(effective_days, date_from, date_to)) + + # Header summary + window = [] + if date_from: + window.append(f"from={date_from:%Y-%m-%d}") + if date_to: + window.append(f"to={date_to:%Y-%m-%d}") + if not window and effective_days: + window.append(f"last {effective_days} days") + print( + f"# NDF Skill 使用統計 ({' / '.join(window) or 'all time'} / " + f"transcript {len(transcripts)}件 / plugin {plugin_root})", + file=sys.stderr, + ) + + per_project = aggregate_by_project(transcripts, skills) + if args.project: + needle = args.project.lower() + per_project = { + k: v for k, v in per_project.items() if needle in k.lower() + } + if not per_project: + print(f"[skill-stats] no projects matched: {args.project}", file=sys.stderr) + return 0 + + if args.format == "json": + projects_json = [] + for project, (inv, trig, hits) in sorted(per_project.items()): + rows, total = build_rows(skills, inv, trig, hits) + projects_json.append({ + "project": project, + "total": total, + "skills": rows, + }) + all_inv, all_trig, all_hits = merge_counters(per_project) + grand_rows, grand_total = build_rows(skills, all_inv, all_trig, all_hits) + out = { + "meta": { + "days": effective_days, + "date_from": args.date_from, + "date_to": args.date_to, + "transcripts": len(transcripts), + "plugin_root": str(plugin_root), + "by_project": args.by_project, + "project_filter": args.project, + }, + "total": grand_total, + "grand_skills": grand_rows, + "projects": projects_json, + } + print(json.dumps(out, ensure_ascii=False, indent=2)) + else: + if args.by_project: + for project, (inv, trig, hits) in sorted(per_project.items()): + rows, total = build_rows(skills, inv, trig, hits) + if total["invocations"] == 0 and total["triggers"] == 0: + continue # skip silent projects + print() + print(format_markdown(rows, total, heading=f"## {project}")) + # grand total + all_inv, all_trig, all_hits = merge_counters(per_project) + grand_rows, grand_total = build_rows(skills, all_inv, all_trig, all_hits) + print() + print(format_markdown(grand_rows, grand_total, heading="## 全プロジェクト合計")) + else: + all_inv, all_trig, all_hits = merge_counters(per_project) + rows, total = build_rows(skills, all_inv, all_trig, all_hits) + print(format_markdown(rows, total)) + + if args.show_keywords: + print("\n## 抽出トリガーキーワード") + for s in sorted(skills, key=lambda x: x["name"]): + kw = ", ".join(s["triggers"]) or "-" + print(f"- `ndf:{s['name']}`: {kw}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/ndf/skills/sync-main/SKILL.md b/plugins/ndf/skills/sync-main/SKILL.md new file mode 100644 index 00000000..cb439fe7 --- /dev/null +++ b/plugins/ndf/skills/sync-main/SKILL.md @@ -0,0 +1,48 @@ +--- +name: sync-main +description: "最新のデフォルトブランチ(main/master)を現在のブランチに取り込むワークフロー。feature branchをmainに追従させる際に使用。" +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 <default-branch>` でリモート最新を取得 + +4. **マージ実行** + - `git merge origin/<default-branch> --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/install-kiro.sh b/scripts/install-kiro.sh new file mode 100755 index 00000000..7882ca2c --- /dev/null +++ b/scripts/install-kiro.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# NDF Plugin Installer for Kiro CLI +# Usage: bash scripts/install-kiro.sh [--with-slack] [--with-codex] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PLUGIN_DIR="$PROJECT_ROOT/plugins/ndf" +KIRO_DIR="$PROJECT_ROOT/.kiro" +SKILLS_DIR="$KIRO_DIR/skills" +AGENT_FILE="$KIRO_DIR/agents/default.json" +PLUGIN_JSON="$PLUGIN_DIR/.claude-plugin/plugin.json" + +# Parse options +WITH_SLACK=false +WITH_CODEX=false +for arg in "$@"; do + case "$arg" in + --with-slack) WITH_SLACK=true ;; + --with-codex) WITH_CODEX=true ;; + --help|-h) + echo "Usage: bash scripts/install-kiro.sh [OPTIONS]" + echo "" + echo "Options:" + echo " --with-slack stopフックにSlack通知を追加" + echo " --with-codex Codex CLI MCPサーバーを追加" + echo " -h, --help このヘルプを表示" + exit 0 + ;; + esac +done + +echo "=== NDF Plugin Installer for Kiro CLI ===" + +# Validate plugin.json exists +if [ ! -f "$PLUGIN_JSON" ]; then + echo "ERROR: $PLUGIN_JSON が見つかりません" >&2 + exit 1 +fi + +# --- Step 1: Create symlinks in .kiro/skills/ --- +mkdir -p "$SKILLS_DIR" +echo "Skills シンボリックリンクを作成中..." +SKILL_COUNT=0 +while IFS= read -r skill_path; do + skill_path="${skill_path#./}" + skill_name=$(basename "$skill_path") + src_dir="$PLUGIN_DIR/$skill_path" + + if [ ! -f "$src_dir/SKILL.md" ]; then + echo " SKIP: $skill_name (SKILL.mdなし)" + continue + fi + + # Relative symlink from .kiro/skills/ to plugins/ndf/skills/ + ln -sfn "../../plugins/ndf/$skill_path" "$SKILLS_DIR/$skill_name" + echo " linked: $skill_name" + SKILL_COUNT=$((SKILL_COUNT + 1)) +done < <(grep -oP '"\.\/skills\/[^"]+' "$PLUGIN_JSON" | sed 's/"//g') + +# --- Step 2: Create prompts in .kiro/prompts/ for workflow skills --- +PROMPTS_DIR="$KIRO_DIR/prompts" +mkdir -p "$PROMPTS_DIR" +echo "ワークフロープロンプトを作成中..." + +declare -A PROMPT_DESCS=( + [pr]="commit, push, PR作成を一括実行してください。" + [pr-tests]="PRのTest Planを自動実行し、結果をPRコメントに反映してください。" + [fix]="PRのレビューコメントを確認し、修正対応を実行してください。" + [review]="PRを専門家としてレビューし、Approve/Request Changesを判定してください。" + [merged]="PRマージ後のクリーンアップを実行してください(main更新、ブランチ削除)。" + [clean]="mainマージ済みブランチをローカル/リモート一括削除してください。" +) + +for name in "${!PROMPT_DESCS[@]}"; do + cat > "$PROMPTS_DIR/$name.md" << PROMPT_EOF +${PROMPT_DESCS[$name]} + +${name}スキルの手順に従って実行してください。引数があればそのまま使用します。 +PROMPT_EOF + echo " prompt: $name" +done + +# --- Step 3: Generate agent config --- +mkdir -p "$KIRO_DIR/agents" + +if [ "$WITH_SLACK" = true ]; then echo "Slack通知: 有効"; else echo "Slack通知: 無効 (--with-slack で有効化)"; fi +if [ "$WITH_CODEX" = true ]; then echo "Codex MCP: 有効"; else echo "Codex MCP: 無効 (--with-codex で有効化)"; fi + +# Backup existing config +if [ -f "$AGENT_FILE" ]; then + cp "$AGENT_FILE" "${AGENT_FILE}.bak" + echo "既存設定をバックアップ: ${AGENT_FILE}.bak" +fi + +# Write agent JSON +python3 -c " +import json, sys +config = { + 'name': 'default', + 'description': 'NDF統合開発エージェント(Kiro CLI用)', + 'resources': [ + 'file://AGENTS.md', + 'file://README.md', + 'file://.kiro/skills/ndf-policies/SKILL.md', + 'skill://.kiro/skills/**/SKILL.md' + ], + 'hooks': { + 'agentSpawn': [{'command': 'if [ -f \"\${PWD}/CLAUDE.ndf.md\" ] || [ -f \"\$HOME/.claude/CLAUDE.ndf.md\" ]; then echo \"[NDF] CLAUDE.ndf.md が検出されました。廃止済みです。cleanup を実行して削除してください。\"; fi'}] + } +} +if sys.argv[1] == 'true': + config['hooks']['stop'] = [{'command': 'node plugins/ndf/scripts/slack-notify.js session_end', 'timeout_ms': 70000}] +if sys.argv[2] == 'true': + config['mcpServers'] = {'codex': {'command': 'codex', 'args': ['mcp-server'], 'env': {}}} +json.dump(config, open(sys.argv[3], 'w'), indent=2, ensure_ascii=False) +" "$WITH_SLACK" "$WITH_CODEX" "$AGENT_FILE" + +echo "" +echo "=== インストール完了 ===" +echo " エージェント設定: $AGENT_FILE" +echo " Skills数: $SKILL_COUNT (シンボリックリンク: .kiro/skills/)" +echo "" +echo "Kiro CLIを起動して動作確認してください:" +echo " kiro-cli chat"