diff --git a/design/integration.md b/design/integration.md index b110bbafca..5dff2f996d 100644 --- a/design/integration.md +++ b/design/integration.md @@ -78,6 +78,22 @@ adding per-agent wrapper scripts. For `generic`, extension registration resolves the persisted `--commands-dir` rather than the static registry placeholder; `--skills` emits skills into that same directory. +Core command templates also use `{PRE_HOOK_SCRIPT}` and `{POST_HOOK_SCRIPT}`. +Rendering selects the same `sh`, `ps`, or `py` variant as the command's main +script; extension installation provisions the native pre/post entry points +for each hook-bearing command's selected variant, including fallback variants +when the project preference is unavailable. The CLI validates hook configuration on write and +materializes ordered per-event JSON with a snapshot of `.specify/extensions.yml`. +CLI writers serialize publication, and each variant checks the snapshot, +event-index digest, and generated response digest after normalizing CRLF line +endings to LF, so tracked projections survive Git checkout conversion. Each +reads the projection in its own runtime, without depending on another variant +or on runtime PyYAML; it rejects stale, corrupt, or missing projections. +The legacy YAML resolver remains for projects not yet refreshed. The resolver +returns ordered hook metadata as JSON; agent commands themselves remain the +responsibility of the agent. The command/skill registrar resolves these +placeholders too when a preset wraps a core command. + ## External adapter package contract A standalone ZIP, tar.gz, or tgz archive needs only these root files (a single diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index e39ff9d697..f59ada3c24 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -295,7 +295,7 @@ Spec Kit stores project-level extension registration and hook configuration in: .specify/extensions.yml ``` -The file contains installed extensions, global settings, and hooks that are surfaced before or after Spec Kit commands. +The file contains installed extensions, global settings, and hooks that are resolved by pre/post scripts before or after Spec Kit commands. ```yaml installed: @@ -337,17 +337,27 @@ Each hook entry supports the following fields: | `extension` | ID of the extension that registered the hook. | | `command` | Extension command associated with the hook. | | `enabled` | Whether the hook is active. Hooks with `enabled: false` are skipped. | -| `optional` | Whether the hook is optional. If `true`, the hook is presented with its `prompt` and can be skipped; if `false`, the hook is emitted as an automatic hook (includes `EXECUTE_COMMAND` markers). | -| `priority` | Priority metadata for the hook. Registered hook entries use integer values >= 1; entries installed from manifests default to `10` when no priority is declared. Current command templates surface hooks in their configured YAML order and do not sort them by `priority`. | +| `optional` | Whether the hook is optional. If `true`, the hook is presented with its `prompt` and can be skipped; if `false`, the agent invokes it and waits for completion. | +| `priority` | Registered hook entries use integer values >= 1; entries installed from manifests default to `10` when no priority is declared. Core command dispatchers sort by ascending priority, preserving file order for ties. | | `prompt` | Message shown when asking whether to run an optional hook. | | `description` | Human-readable explanation of what the hook does. | -| `condition` | Optional expression evaluated by `HookExecutor` (using `config.` or `env.` with `is set`, `==`, or `!=`). Current command templates do not evaluate conditions and skip hooks with a non-empty condition. | +| `condition` | Optional expression evaluated by `HookExecutor` (using `config.` or `env.` with `is set`, `==`, or `!=`). Core command dispatchers preserve the existing prompt behavior: skip hooks with a non-empty condition rather than evaluating it. | Hook event names identify when a hook is invoked. They generally use `before_` or `after_`, such as `before_implement`, `after_implement`, `before_tasks`, and `after_tasks`. Extension manifests reject invalid hook priorities during installation. For existing `.specify/extensions.yml` entries, `HookExecutor.get_hooks_for_event()` sorts with `normalize_priority()`: missing values, booleans, non-numeric values rejected by `int()`, and values less than `1` fall back to `10`; numeric strings and finite floats are coerced with `int()`, while non-finite floats are unsupported and may fail instead of falling back. -`HookExecutor.get_hooks_for_event()` returns hooks ordered by `priority`, with lower values first. However, current command templates read hook lists directly and surface them in their configured YAML order rather than using priority ordering. +Core commands run two hook dispatchers, one before and one after their main work, using the selected `sh`, `ps`, or `py` variant of each command (which may fall back from the project's preferred script type). Installing a hook-bearing extension command provisions its selected dispatchers, including when the extension is updated. Each variant returns JSON containing the event and an ordered `hooks` array (`extension`, `command`, `optional`, `description`, `prompt`, `priority`). An absent configuration or event returns an empty array. An unreadable or invalid configuration returns an `error` and a nonzero exit code; agents must report that no hooks were checked, including mandatory hooks, and then continue the core command. The dispatchers determine the order but do not execute agent commands. + +`specify extension add` and other CLI operations that save `.specify/extensions.yml` also materialize validated, ordered JSON responses under `.specify/hook-dispatch/`. All three script variants use this projection without parsing YAML; the Python script needs only the standard library (but still requires Python 3.11+ on the execution host), and Bash and PowerShell do not invoke Python. CLI saves serialize publication, and the scripts compare the saved YAML snapshot and verify the event index and each response against their generated SHA-256 digests. Readers normalize CRLF to LF before comparison and hashing, so Git line-ending conversion of tracked projection files does not invalidate the generated digests; standalone CR bytes are not normalized. They recheck the snapshot before returning, so a concurrent save cannot silently return a mixed response; a reader caught mid-save fails and can be retried. They fail with refresh guidance if the configuration was manually edited, a projected response was corrupted, the projection is incomplete, or its directory is missing. Bash requires `sed` and either `sha256sum` or `shasum` on the execution host to verify projected responses. Reinstall the affected extension with `specify extension add --force` or refresh the project to regenerate it. Existing projects without a projection continue using their installed YAML resolvers until refreshed; their Python resolver still requires PyYAML. + +Direct CLI configuration saves refuse a symlinked `.specify/extensions.yml` or `.specify` directory before changing the projection, rather than following the link outside the project. + +The CLI validates hook fields across every configured event when materializing the projection. Extension installation checks projected hook event names (`before_...` or `after_...`) before modifying the project; manifest inspection still accepts other event names. Priorities are truncated to integers in the range `1` through `2147483647`; values outside this range, booleans, and non-integer strings fall back to `10`. Quoted integer strings are accepted, but quoted decimal strings are not. + +Native dispatchers also recognize the numeric YAML forms accepted by the Python resolver, including digit separators, hex/binary/octal integers, sexagesimal integers, and decimal floats with exponents. The canonical writer quotes strings that would otherwise be ambiguous to the native parsers and preserves YAML control-character escapes. A NUL character in hook configuration is rejected explicitly because Bash cannot represent it. + +Legacy Bash and PowerShell resolvers (before projection generation) support the CLI's pretty-printed `.specify/extensions.yml` layout: a `hooks:` mapping, `before_...` or `after_...` event names indented two spaces, list items under each event, and single-line scalar fields (including quoted strings and common escapes). All three legacy resolvers reject unsupported event names, including names for events other than the one being resolved. An inline empty `hooks: {}` mapping cannot contain nested entries. Other YAML constructs such as anchors, flow-style hook lists, and block scalars are not supported by the legacy path. The CLI writes hook configuration in this canonical layout without wrapping long scalar values; installing or upgrading shared infrastructure rewrites existing valid YAML to this layout and generates its projection (YAML comments and formatting are not retained). The Python legacy path accepts general YAML through PyYAML. A hook `condition` must be a string or null in all variants. ## FAQ diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 2be2f43123..9403821087 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -663,26 +663,27 @@ hooks: condition: null ``` -### Hook Message Format +### Core Command Hook Dispatch -```markdown -## Extension Hooks - -**Optional Hook**: {extension} -Command: `/{command}` -Description: {description} +Core commands call a pre-hook script before their main work and a post-hook script afterward. The scripts return one JSON result for each event: -Prompt: {prompt} -To execute: `/{command}` +```json +{ + "event": "after_tasks", + "hooks": [ + { + "extension": "jira", + "command": "speckit.jira.specstoissues", + "optional": true, + "priority": 10, + "prompt": "Create Jira issues from tasks?", + "description": "Create issues" + } + ] +} ``` -Or for mandatory hooks: - -```markdown -**Automatic Hook**: {extension} -Executing: `/{command}` -EXECUTE_COMMAND: {command} -``` +Enabled, unconditional hooks are returned in ascending priority order (ties retain configuration order). The agent invokes mandatory commands and offers optional commands without automatically running them. Conditions remain unsupported in core-command dispatch and hooks with non-empty conditions are skipped. Invalid or unreadable configuration produces a nonzero exit and JSON with an `error` and empty `hooks` array; the agent reports that no hooks were checked. --- diff --git a/extensions/catalog.json b/extensions/catalog.json index 30e6dd92ac..c20cec03e6 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -66,7 +66,7 @@ "github": { "name": "GitHub Integration", "id": "github", - "version": "1.0.2", + "version": "1.0.3", "description": "GitHub platform integration for Spec Kit - create GitHub issues from a feature's task list", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", diff --git a/extensions/github/README.md b/extensions/github/README.md index 8aaf46a5c3..44e438f1bc 100644 --- a/extensions/github/README.md +++ b/extensions/github/README.md @@ -53,12 +53,14 @@ specify extension remove github This extension consumes the existing `before_taskstoissues` and `after_taskstoissues` hook points, which are read from `.specify/extensions.yml` at run time. The hook keys are unchanged from the core command, so hooks registered by other extensions — for example the `git` extension's auto-commit hooks — keep firing exactly as before. +Installing or updating the extension also installs the selected shared pre/post +hook dispatchers under `.specify/scripts/` if an older project lacks them. +Unmodified managed copies are refreshed; customized scripts are preserved. + ## Requirements -- Spec Kit **0.12.17 or newer**. Extension-local `scripts/...` path rewriting arrived in - 0.12.6, but auto-registered skills did not resolve `__SPECKIT_COMMAND_*__` references - until 0.12.17. Earlier releases cannot render this command correctly in every supported - layout, so `specify extension add github` refuses to install below 0.12.17. +- Spec Kit **1.1.3 or newer**. Older releases cannot resolve the hook + placeholders or install the shared pre/post dispatchers used by this command. - A Git remote pointing at GitHub. - The **GitHub MCP server** available to your coding agent, providing the `list_issues` and `issue_write` tools. diff --git a/extensions/github/commands/speckit.github.taskstoissues.md b/extensions/github/commands/speckit.github.taskstoissues.md index 6e587abe75..9c6d166405 100644 --- a/extensions/github/commands/speckit.github.taskstoissues.md +++ b/extensions/github/commands/speckit.github.taskstoissues.md @@ -17,38 +17,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before tasks-to-issues conversion)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} taskstoissues` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -74,33 +43,4 @@ git config --get remote.origin.url ## Post-Execution Checks -**Check for extension hooks (after tasks-to-issues conversion)**: -Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{POST_HOOK_SCRIPT} taskstoissues` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. diff --git a/extensions/github/extension.yml b/extensions/github/extension.yml index c6df8f19a1..03ec75a617 100644 --- a/extensions/github/extension.yml +++ b/extensions/github/extension.yml @@ -3,18 +3,16 @@ schema_version: "1.0" extension: id: github name: "GitHub Integration" - version: "1.0.2" + version: "1.0.3" description: "GitHub platform integration for Spec Kit - create GitHub issues from a feature's task list" author: spec-kit-core repository: https://github.com/github/spec-kit license: MIT requires: - # 0.12.17 is the first release that both rewrites extension-local - # "scripts/..." paths (#3364) and resolves __SPECKIT_COMMAND_*__ tokens in - # auto-registered skills (#3544). Earlier versions cannot render this - # command correctly in every registerable layout. - speckit_version: ">=0.12.17" + # Hook placeholders and shared dispatcher installation first ship in 1.1.3. + # Allow its development build when testing this bundled extension. + speckit_version: ">=1.1.3.dev0" tools: - name: git required: true diff --git a/scripts/bash/post-hooks.sh b/scripts/bash/post-hooks.sh new file mode 100755 index 0000000000..583d4f58d3 --- /dev/null +++ b/scripts/bash/post-hooks.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +SCRIPT_DIR=${BASH_SOURCE[0]%/*} +source "$SCRIPT_DIR/pre-hooks.sh" +resolve_hooks after "$1" diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh new file mode 100755 index 0000000000..31e6483b8d --- /dev/null +++ b/scripts/bash/pre-hooks.sh @@ -0,0 +1,508 @@ +#!/usr/bin/env bash + +hook_json_string() { + local value=$1 code hex char + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//$'\n'/\\n} + value=${value//$'\r'/\\r} + value=${value//$'\t'/\\t} + value=${value//$'\b'/\\b} + value=${value//$'\f'/\\f} + for ((code=1; code<32; code++)); do + printf -v hex '%02X' "$code" + printf -v char '%b' "\\x$hex" + value=${value//$char/\\u00$hex} + done + printf '"%s"' "$value" +} + +hook_unicode() { + local number=$1 bytes + HOOK_UTF8="" + if (( number == 0 || number > 0x10ffff || (number >= 0xd800 && number <= 0xdfff) )); then + HOOK_ERROR="Unsupported YAML escape" + return + fi + if (( number < 0x80 )); then + printf -v bytes '\\x%02X' "$number" + elif (( number < 0x800 )); then + printf -v bytes '\\x%02X\\x%02X' "$((0xc0 | (number >> 6)))" "$((0x80 | (number & 0x3f)))" + elif (( number < 0x10000 )); then + printf -v bytes '\\x%02X\\x%02X\\x%02X' "$((0xe0 | (number >> 12)))" "$((0x80 | ((number >> 6) & 0x3f)))" "$((0x80 | (number & 0x3f)))" + else + printf -v bytes '\\x%02X\\x%02X\\x%02X\\x%02X' "$((0xf0 | (number >> 18)))" "$((0x80 | ((number >> 12) & 0x3f)))" "$((0x80 | ((number >> 6) & 0x3f)))" "$((0x80 | (number & 0x3f)))" + fi + printf -v HOOK_UTF8 '%b' "$bytes" +} + +hook_error() { + printf '{"event":' + hook_json_string "$1" + printf ',"hooks":[],"error":' + hook_json_string "$2" + printf '}\n' + return 1 +} + +hook_scalar() { + local raw=$1 inner c escaped i digits count + while [[ $raw == ' '* ]]; do raw=${raw# }; done + while [[ $raw == *' ' ]]; do raw=${raw% }; done + HOOK_QUOTED=false + if [[ $raw == \'* ]]; then + HOOK_QUOTED=true + if (( ${#raw} < 2 )) || [[ $raw != *\' ]]; then + HOOK_ERROR="Unsupported YAML scalar" + return + fi + inner=${raw:1:${#raw}-2} + if [[ ${inner//\'\'/} == *\'* ]]; then HOOK_ERROR="Unsupported YAML scalar"; return; fi + HOOK_SCALAR=${inner//\'\'/\'} + return + fi + if [[ $raw == \"* ]]; then + HOOK_QUOTED=true + if (( ${#raw} < 2 )) || [[ $raw != *\" ]]; then + HOOK_ERROR="Unsupported YAML scalar" + return + fi + inner=${raw:1:${#raw}-2} + HOOK_SCALAR="" + for ((i=0; i<${#inner}; i++)); do + c=${inner:i:1} + if [[ $c != '\' ]]; then HOOK_SCALAR+=$c; continue; fi + ((i++)) + escaped=${inner:i:1} + case $escaped in + n) HOOK_SCALAR+=$'\n' ;; + t) HOOK_SCALAR+=$'\t' ;; + r) HOOK_SCALAR+=$'\r' ;; + b) HOOK_SCALAR+=$'\b' ;; + f) HOOK_SCALAR+=$'\f' ;; + a) HOOK_SCALAR+=$'\a' ;; + v) HOOK_SCALAR+=$'\v' ;; + e) HOOK_SCALAR+=$'\033' ;; + N) hook_unicode 0x85; HOOK_SCALAR+=$HOOK_UTF8 ;; + _) hook_unicode 0xa0; HOOK_SCALAR+=$HOOK_UTF8 ;; + L) hook_unicode 0x2028; HOOK_SCALAR+=$HOOK_UTF8 ;; + P) hook_unicode 0x2029; HOOK_SCALAR+=$HOOK_UTF8 ;; + x|u|U) + count=2 + [[ $escaped == u ]] && count=4 + [[ $escaped == U ]] && count=8 + digits=${inner:i+1:count} + if (( ${#digits} != count )) || [[ ! $digits =~ ^[[:xdigit:]]+$ ]]; then + HOOK_ERROR="Unsupported YAML escape"; return + fi + hook_unicode "$((16#$digits))" + [[ -n $HOOK_ERROR ]] && return + HOOK_SCALAR+=$HOOK_UTF8 + i=$((i+count)) + ;; + '"'|'\'|'/') HOOK_SCALAR+=$escaped ;; + *) HOOK_ERROR="Unsupported YAML escape"; return ;; + esac + done + return + fi + raw=${raw%%' #'*} + while [[ $raw == *' ' ]]; do raw=${raw% }; done + if [[ $raw == null || $raw == Null || $raw == NULL || $raw == '~' ]]; then raw=""; fi + case $raw in + *'['*|*']'*|*'{'*|*'}'*|*': '*|'&'*|'*'*|'!'*|'|'*|'>'*) + HOOK_ERROR="Unsupported YAML scalar"; return ;; + esac + HOOK_SCALAR=$raw +} + +hook_typed_scalar() { + case $1 in + [Tt][Rr][Uu][Ee]|[Ff][Aa][Ll][Ss][Ee]|[Yy][Ee][Ss]|[Nn][Oo]|[Oo][Nn]|[Oo][Ff][Ff]) + return 0 ;; + esac + [[ $1 =~ ^[+-]?(0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|[0-9][0-9_]*(\.[0-9_]*)?([eE][+-]?[0-9]+)?|\.[0-9_]+([eE][+-]?[0-9]+)?|\.([iI][nN][fF]|[nN][aA][nN]))$ || + $1 =~ ^[0-9]+(:[0-9]+)+$ || + $1 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}([Tt\ ]|$) ]] +} + +hook_field() { + local field=$1 key raw + if [[ ! $field =~ ^([a-z_]+):([[:space:]]|$) ]]; then + HOOK_ERROR="Unsupported YAML hook field" + return + fi + key=${BASH_REMATCH[1]} + raw=${field:${#key}+1} + hook_scalar "$raw" + [[ -n $HOOK_ERROR ]] && return + if [[ $key == extension || $key == command ]]; then + if [[ -z $HOOK_SCALAR ]] || + { [[ $HOOK_QUOTED == false ]] && hook_typed_scalar "$HOOK_SCALAR"; }; then + HOOK_ERROR="hooks.$event needs extension and command" + return + fi + fi + if [[ $key == condition && $HOOK_QUOTED == false && -n $HOOK_SCALAR ]] && + hook_typed_scalar "$HOOK_SCALAR"; then + HOOK_ERROR="condition must be a string or null" + return + fi + if [[ $key == enabled || $key == optional ]]; then + if [[ $HOOK_QUOTED == true || ( $HOOK_SCALAR != true && $HOOK_SCALAR != false ) ]]; then + HOOK_ERROR="$key must be a boolean" + return + fi + fi + if [[ $key == description || $key == prompt ]]; then + if [[ $HOOK_QUOTED == false && -n $HOOK_SCALAR ]] && + hook_typed_scalar "$HOOK_SCALAR"; then + HOOK_ERROR="$key must be a string" + return + fi + fi + case $key in + extension) HOOK_EXT[index]=$HOOK_SCALAR ;; + command) HOOK_CMD[index]=$HOOK_SCALAR ;; + enabled) HOOK_ENABLED[index]=$HOOK_SCALAR ;; + optional) HOOK_OPTIONAL[index]=$HOOK_SCALAR ;; + condition) HOOK_CONDITION[index]=$HOOK_SCALAR ;; + priority) HOOK_PRIORITY[index]=$HOOK_SCALAR; HOOK_PRIORITY_QUOTED[index]=$HOOK_QUOTED ;; + description) HOOK_DESCRIPTION[index]=$HOOK_SCALAR ;; + prompt) HOOK_PROMPT[index]=$HOOK_SCALAR ;; + esac +} + +hook_installed_field() { + local field=$1 key raw + if [[ ! $field =~ ^([a-z_]+):([[:space:]]|$) ]]; then + HOOK_ERROR="Unsupported YAML installed entry" + return + fi + key=${BASH_REMATCH[1]} + raw=${field:${#key}+1} + if [[ $raw =~ ^(.*[\"\'])[[:space:]]+#.*$ ]]; then raw=${BASH_REMATCH[1]}; fi + hook_scalar "$raw" +} + +hook_priority() { + local raw=$1 quoted=$2 whole frac="" exponent=0 shift digits part octal=false + HOOK_RANK=10 + [[ $raw == -* ]] && return + if [[ $quoted == false && $raw =~ ^\+?0x[0-9a-fA-F_]+$ ]]; then + digits=${raw#+} + digits=${digits:2} + digits=${digits//_/} + while [[ $digits == 0* && ${#digits} -gt 1 ]]; do digits=${digits#0}; done + (( ${#digits} <= 8 )) || return + HOOK_RANK=$((16#$digits)) + elif [[ $quoted == false && $raw =~ ^\+?0b[01_]+$ ]]; then + digits=${raw#+} + digits=${digits:2} + digits=${digits//_/} + while [[ $digits == 0* && ${#digits} -gt 1 ]]; do digits=${digits#0}; done + (( ${#digits} <= 31 )) || return + HOOK_RANK=$((2#$digits)) + elif [[ $quoted == false && $raw =~ ^\+?[1-9][0-9_]*(:[0-5]?[0-9])+$ ]]; then + digits=${raw#+} + digits=${digits//_/} + HOOK_RANK=0 + while [[ $digits == *:* ]]; do + part=${digits%%:*} + (( ${#part} <= 10 )) || { HOOK_RANK=10; return; } + HOOK_RANK=$((HOOK_RANK * 60 + 10#$part)) + (( HOOK_RANK <= 2147483647 )) || { HOOK_RANK=10; return; } + digits=${digits#*:} + done + HOOK_RANK=$((HOOK_RANK * 60 + 10#$digits)) + else + if [[ $raw =~ ^\+?[0-9][0-9_]*$ ]]; then + digits=${raw#+} + [[ $quoted == false && $digits =~ ^0[0-7_]+$ ]] && octal=true + digits=${digits//_/} + if [[ $octal == true ]]; then + while [[ $digits == 0* && ${#digits} -gt 1 ]]; do digits=${digits#0}; done + (( ${#digits} <= 11 )) || return + HOOK_RANK=$((8#$digits)) + (( HOOK_RANK >= 1 && HOOK_RANK <= 2147483647 )) || HOOK_RANK=10 + return + fi + elif [[ $quoted == false && $raw =~ ^[+]?[0-9][0-9_]*\.[0-9_]*([eE][+-][0-9]+)?$ ]]; then + digits=${raw#+} + digits=${digits//_/} + if [[ $digits == *[eE]* ]]; then + exponent=${digits##*[eE]} + (( ${#exponent} <= 4 )) || return + digits=${digits%[eE]*} + fi + whole=${digits%%.*} + frac=${digits#*.} + shift=$((exponent - ${#frac})) + digits=$whole$frac + if (( shift >= 0 )); then + (( shift <= 10 )) || return + while (( shift > 0 )); do digits+=0; shift=$((shift-1)); done + else + (( ${#digits} + shift > 0 )) || return + digits=${digits:0:${#digits}+shift} + fi + else + return + fi + while [[ $digits == 0* && ${#digits} -gt 1 ]]; do digits=${digits#0}; done + (( ${#digits} <= 10 )) || return + HOOK_RANK=$((10#$digits)) + fi + (( HOOK_RANK >= 1 && HOOK_RANK <= 2147483647 )) || HOOK_RANK=10 +} + +hook_canonical_file() { + local cr=$'\r' + LC_ALL=C sed "s/${cr}\$//" < "$1" +} + +hook_file_digest() { + local output + if command -v sha256sum >/dev/null 2>&1; then + output=$(set -o pipefail; hook_canonical_file "$1" | sha256sum) || return 1 + elif command -v shasum >/dev/null 2>&1; then + output=$(set -o pipefail; hook_canonical_file "$1" | shasum -a 256) || return 1 + else + return 1 + fi + printf '%s' "${output%% *}" +} + +hook_digest_valid() { + local path=$1 digest=$2 expected actual + [[ -f $path && -f $digest ]] || return 1 + IFS= read -r expected < "$digest" + expected=${expected%$'\r'} + if ! command -v sha256sum >/dev/null 2>&1 && + ! command -v shasum >/dev/null 2>&1; then + HOOK_ERROR="Cannot validate hook projection: sha256sum or shasum is required" + return 1 + fi + actual=$(hook_file_digest "$path") || return 1 + [[ $expected =~ ^[0-9a-f]{64}$ && $actual == "$expected" ]] +} + +hook_projection_shape_valid() { + local response=$1 event=$2 + local string='"([^"\\[:cntrl:]]|\\(["\\/bfnrt]|u[[:xdigit:]]{4}))*"' + local required='"([^"\\[:cntrl:]]|\\(["\\/bfnrt]|u[[:xdigit:]]{4}))+"' + local entry='\{"extension":'"$required"',"command":'"$required"',"optional":(true|false),"description":'"$string"',"prompt":'"$string"',"priority":[1-9][0-9]*\}' + local shape='^\{"event":"'"$event"'","hooks":\[('"$entry"'(,'"$entry"')*)?\]\}$' + [[ $response =~ $shape ]] +} + +resolve_hooks() { + local phase=$1 command=$2 event config line spaces indent text key value + local section="" target=false empty=false hooks_empty=false item_indent=-1 installed_indent=-1 index=0 seen=false seen_hooks=false seen_event=false + local i n chosen first=true + local -a HOOK_EXT HOOK_CMD HOOK_ENABLED HOOK_OPTIONAL HOOK_CONDITION + local -a HOOK_PRIORITY HOOK_PRIORITY_QUOTED HOOK_DESCRIPTION HOOK_PROMPT HOOK_RANKS HOOK_USED HOOK_EVENTS HOOK_TARGETS + event="${phase}_${command}" + if [[ ! $event =~ ^(before|after)_[a-z][a-z0-9_]*$ ]]; then + hook_error "$event" "Invalid hook event: $event" + return 1 + fi + config=.specify/extensions.yml + if [[ -f $config && ! -d .specify/hook-dispatch ]]; then + IFS= read -r line < "$config" + if [[ $line == '# Hook projection: .specify/hook-dispatch' ]]; then + hook_error "$event" "Hook projection is missing; reinstall the extension or refresh the project" + return 1 + fi + fi + if [[ -d .specify/hook-dispatch ]]; then + if [[ ! -f $config || ! -f .specify/hook-dispatch/source.yml || + ! -f .specify/hook-dispatch/events.txt ]]; then + hook_error "$event" "Hook projection is stale; reinstall the extension or refresh the project" + return 1 + fi + HOOK_ERROR="" + if ! hook_digest_valid .specify/hook-dispatch/events.txt .specify/hook-dispatch/events.txt.sha256; then + hook_error "$event" "${HOOK_ERROR:-Hook projection index is invalid; reinstall the extension or refresh the project}" + return 1 + fi + local source_digest snapshot_digest current_digest result + if ! source_digest=$(hook_file_digest "$config") || + ! snapshot_digest=$(hook_file_digest .specify/hook-dispatch/source.yml) || + [[ $source_digest != "$snapshot_digest" ]]; then + hook_error "$event" "Hook projection is stale; reinstall the extension or refresh the project" + return 1 + fi + if [[ -f .specify/hook-dispatch/$event.json ]]; then + local projection=".specify/hook-dispatch/$event.json" response + if ! hook_digest_valid "$projection" ".specify/hook-dispatch/$event.sha256"; then + hook_error "$event" "${HOOK_ERROR:-Hook projection is invalid; reinstall the extension or refresh the project}" + return 1 + fi + if ! IFS= read -r response < "$projection" || + ! hook_projection_shape_valid "${response%$'\r'}" "$event"; then + hook_error "$event" "Invalid hook projection; reinstall the extension or refresh the project" + return 1 + fi + result=${response%$'\r'} + elif grep -Fxq -- "$event" <(hook_canonical_file .specify/hook-dispatch/events.txt); then + hook_error "$event" "Hook projection is incomplete; reinstall the extension or refresh the project" + return 1 + else + result="{\"event\":\"$event\",\"hooks\":[]}" + fi + if [[ ! -f .specify/hook-dispatch/source.yml ]] || + ! current_digest=$(hook_file_digest "$config") || + ! snapshot_digest=$(hook_file_digest .specify/hook-dispatch/source.yml) || + [[ $current_digest != "$source_digest" || $snapshot_digest != "$source_digest" ]]; then + hook_error "$event" "Hook projection changed during resolution; retry the command" + return 1 + fi + printf '%s\n' "$result" + return + fi + if [[ ! -e $config ]]; then + printf '{"event":"%s","hooks":[]}\n' "$event" + return + fi + if [[ ! -r $config ]]; then + hook_error "$event" "Could not read .specify/extensions.yml" + return 1 + fi + HOOK_ERROR="" + while IFS= read -r line || [[ -n $line ]]; do + line=${line%$'\r'} + [[ $line =~ ^[[:space:]]*(#|$) ]] && continue + seen=true + if [[ $line == *$'\t'* ]]; then HOOK_ERROR="Tabs are not supported in canonical YAML"; break; fi + spaces=${line%%[! ]*} + indent=${#spaces} + text=${line:indent} + if [[ $section == installed && $text == '- '* && ( $indent == 0 || $indent == 2 ) ]]; then + if [[ ${text:2} =~ ^[a-z_]+:([[:space:]]|$) ]]; then + installed_indent=$indent + hook_installed_field "${text:2}" + else + installed_indent=-1 + hook_scalar "${text:2}" + fi + [[ -n $HOOK_ERROR ]] && break + continue + fi + if (( indent == 0 )); then + target=false + item_indent=-1 + installed_indent=-1 + if [[ $text == 'hooks:' || $text == 'hooks: {}' ]]; then + if [[ $seen_hooks == true ]]; then HOOK_ERROR="Duplicate hooks mapping"; break; fi + seen_hooks=true + section=hooks + empty=false + hooks_empty=false + [[ $text == 'hooks: {}' ]] && hooks_empty=true + elif [[ $text == hooks:* ]]; then + HOOK_ERROR="Invalid .specify/extensions.yml: expected a hooks mapping" + elif [[ $text == 'installed:' || $text == 'installed: []' ]]; then + section=installed + elif [[ $text == 'settings:' || $text == 'settings: {}' ]]; then + section=settings + else + HOOK_ERROR="Unsupported YAML top-level layout" + fi + [[ -z $HOOK_ERROR ]] || break + continue + fi + if [[ $section != hooks ]]; then + if [[ $section == installed && $installed_indent -ge 0 && + $indent -eq $((installed_indent+2)) ]]; then + hook_installed_field "$text" + [[ -n $HOOK_ERROR ]] && break + continue + fi + if [[ $section != settings || $indent != 2 || ! $text =~ ^[a-z_]+:([[:space:]]|$) ]]; then + HOOK_ERROR="Unsupported YAML top-level layout"; break + fi + key=${text%%:*} + hook_scalar "${text:${#key}+1}" + [[ -n $HOOK_ERROR ]] && break + continue + fi + if [[ $hooks_empty == true ]]; then HOOK_ERROR="Unsupported YAML hook layout"; break; fi + if (( indent == 2 )) && [[ $text != '- '* && $text =~ ^([^:]+):([[:space:]]|$) ]]; then + key=${BASH_REMATCH[1]} + if [[ ! $key =~ ^(before|after)_[a-z][a-z0-9_]*$ ]]; then + HOOK_ERROR="Invalid hook event: $key"; break + fi + value=${text:${#key}+1} + while [[ $value == ' '* ]]; do value=${value# }; done + target=false + if [[ $key == "$event" ]]; then + if [[ $seen_event == true ]]; then HOOK_ERROR="Duplicate hook event"; break; fi + seen_event=true + target=true + fi + if [[ -n $value && $value != '[]' ]]; then + HOOK_ERROR="hooks.$key must be a list"; break + fi + empty=false + [[ $value == '[]' ]] && empty=true + item_indent=-1 + continue + fi + if [[ $text == '- '* && ( $indent == 2 || $indent == 4 ) && $empty == false ]]; then + item_indent=$indent + index=$((index+1)) + HOOK_EVENTS[index]=$key + HOOK_TARGETS[index]=$target + HOOK_ENABLED[index]=true + HOOK_OPTIONAL[index]=true + HOOK_PRIORITY[index]=10 + hook_field "${text:2}" + elif (( item_indent >= 0 && indent == item_indent + 2 )); then + hook_field "$text" + else + HOOK_ERROR="Unsupported YAML hook layout" + fi + [[ -z $HOOK_ERROR ]] || break + done < "$config" + [[ $seen == true ]] || HOOK_ERROR="Invalid .specify/extensions.yml: expected a hooks mapping" + if [[ -z $HOOK_ERROR ]]; then + for ((i=1; i<=index; i++)); do + if [[ -z ${HOOK_EXT[i]} || -z ${HOOK_CMD[i]} ]]; then + HOOK_ERROR="hooks.${HOOK_EVENTS[i]} needs extension and command"; break + fi + [[ ${HOOK_TARGETS[i]} != true || ${HOOK_ENABLED[i]} == false || -n ${HOOK_CONDITION[i]} ]] && continue + hook_priority "${HOOK_PRIORITY[i]}" "${HOOK_PRIORITY_QUOTED[i]}" + HOOK_RANKS[i]=$HOOK_RANK + done + fi + if [[ -n $HOOK_ERROR ]]; then + hook_error "$event" "$HOOK_ERROR" + return 1 + fi + printf '{"event":"%s","hooks":[' "$event" + for ((n=1; n<=index; n++)); do + chosen=0 + for ((i=1; i<=index; i++)); do + if [[ -n ${HOOK_RANKS[i]} && -z ${HOOK_USED[i]} ]] && + (( chosen == 0 || HOOK_RANKS[i] < HOOK_RANKS[chosen] )); then + chosen=$i + fi + done + (( chosen )) || break + HOOK_USED[chosen]=true + if [[ $first == false ]]; then printf ','; fi + first=false + printf '{"extension":'; hook_json_string "${HOOK_EXT[chosen]}" + printf ',"command":'; hook_json_string "${HOOK_CMD[chosen]}" + printf ',"optional":%s,"description":' "${HOOK_OPTIONAL[chosen]}" + hook_json_string "${HOOK_DESCRIPTION[chosen]}" + printf ',"prompt":'; hook_json_string "${HOOK_PROMPT[chosen]}" + printf ',"priority":%s}' "${HOOK_RANKS[chosen]}" + done + printf ']}\n' +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + resolve_hooks before "$1" +fi diff --git a/scripts/powershell/post-hooks.ps1 b/scripts/powershell/post-hooks.ps1 new file mode 100644 index 0000000000..9464dae497 --- /dev/null +++ b/scripts/powershell/post-hooks.ps1 @@ -0,0 +1,4 @@ +#!/usr/bin/env pwsh + +. "$PSScriptRoot/pre-hooks.ps1" +Invoke-HookResolver after $args[0] diff --git a/scripts/powershell/pre-hooks.ps1 b/scripts/powershell/pre-hooks.ps1 new file mode 100644 index 0000000000..4085263b71 --- /dev/null +++ b/scripts/powershell/pre-hooks.ps1 @@ -0,0 +1,374 @@ +#!/usr/bin/env pwsh + +function Convert-HookScalar { + param([string]$Raw) + $raw = $Raw.Trim() + if ($raw.StartsWith("'")) { + if ($raw.Length -lt 2 -or -not $raw.EndsWith("'") -or $raw.Substring(1, $raw.Length - 2) -replace "''", "" -match "'") { + throw "Unsupported YAML scalar" + } + return @{ Value = $raw.Substring(1, $raw.Length - 2).Replace("''", "'"); Quoted = $true } + } + if ($raw.StartsWith('"')) { + if ($raw.Length -lt 2 -or -not $raw.EndsWith('"')) { throw "Unsupported YAML scalar" } + $json = [Text.StringBuilder]::new() + for ($i = 0; $i -lt $raw.Length; $i++) { + if ($raw[$i] -ne '\') { + $null = $json.Append($raw[$i]) + continue + } + $i++ + if ($i -ge $raw.Length) { throw "Unsupported YAML escape" } + $escaped = [string]$raw[$i] + if ($escaped -ceq 'x' -or $escaped -ceq 'U') { + $count = if ($escaped -ceq 'x') { 2 } else { 8 } + if ($i + $count -ge $raw.Length) { throw "Unsupported YAML escape" } + $hex = $raw.Substring($i + 1, $count) + if ($hex -notmatch '^[0-9a-fA-F]+$') { throw "Unsupported YAML escape" } + $number = [Convert]::ToInt32($hex, 16) + if ($number -eq 0 -or $number -gt 0x10ffff -or + ($number -ge 0xd800 -and $number -le 0xdfff)) { + throw "Unsupported YAML escape" + } + if ($escaped -ceq 'x') { + $null = $json.Append('\u00').Append($hex) + } else { + $null = $json.Append([char]::ConvertFromUtf32($number)) + } + $i += $count + continue + } + $replacement = switch -CaseSensitive ($escaped) { + 'a' { '\u0007' } + 'v' { '\u000B' } + 'e' { '\u001B' } + 'N' { '\u0085' } + '_' { '\u00A0' } + 'L' { '\u2028' } + 'P' { '\u2029' } + default { '\' + $escaped } + } + $null = $json.Append($replacement) + } + try { $value = ConvertFrom-Json -InputObject $json.ToString() -ErrorAction Stop } + catch { throw "Unsupported YAML escape: $($_.Exception.Message)" } + if ($value -isnot [string]) { throw "Unsupported YAML scalar" } + if ($value.Contains([char]0)) { throw "Unsupported YAML NUL character" } + return @{ Value = $value; Quoted = $true } + } + $raw = ($raw -replace '\s+#.*$', '').TrimEnd() + if ($raw -cmatch '^(~|null|Null|NULL)$') { $raw = '' } + if ($raw -match '[\[\]{}]|: |^[&*!|>]') { throw "Unsupported YAML scalar" } + return @{ Value = $raw; Quoted = $false } +} + +function Add-HookField { + param([hashtable]$Hook, [string]$Text, [string]$Event) + if ($Text -notmatch '^([a-z_]+):(?:\s|$)(.*)$') { throw "Unsupported YAML hook field" } + $key = $Matches[1] + $parsed = Convert-HookScalar $Matches[2] + $value = $parsed.Value + $typed = -not $parsed.Quoted -and (Test-HookTypedScalar $value) + if ($key -in @('extension', 'command') -and (-not $value -or $typed)) { + throw "hooks.$Event needs extension and command" + } + if ($key -eq 'condition' -and $typed) { + throw "condition must be a string or null" + } + if ($key -in @('enabled', 'optional') -and ($parsed.Quoted -or $value -notin @('true', 'false'))) { + throw "$key must be a boolean" + } + if ($key -in @('prompt', 'description') -and -not $parsed.Quoted -and + (Test-HookTypedScalar $value)) { + throw "$key must be a string" + } + $Hook[$key] = $value + if ($key -eq 'priority') { $Hook.priority_quoted = $parsed.Quoted } +} + +function Test-InstalledField { + param([string]$Text) + if ($Text -notmatch '^([a-z_]+):(?:\s|$)(.*)$') { + throw "Unsupported YAML installed entry" + } + $raw = $Matches[2] + if ($raw -match '^(.*["''])\s+#.*$') { $raw = $Matches[1] } + $null = Convert-HookScalar $raw +} + +function Test-HookTypedScalar { + param([string]$Value) + return $Value -match '^(true|false|yes|no|on|off)$' -or + $Value -match '^[+-]?(0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|[0-9][0-9_]*(\.[0-9_]*)?([eE][+-]?[0-9]+)?|\.[0-9_]+([eE][+-]?[0-9]+)?|\.([iI][nN][fF]|[nN][aA][nN]))$' -or + $Value -match '^[0-9]+(:[0-9]+)+$' -or + $Value -match '^[0-9]{4}-[0-9]{2}-[0-9]{2}([Tt ]|$)' +} + +function Get-HookPriority { + param([string]$Raw, [bool]$Quoted) + $digits = $Raw.Replace('_', '') + if ($Raw -match '^-') { return 10 } + try { + if (-not $Quoted -and $Raw -cmatch '^\+?0x[0-9a-fA-F_]+$') { + $number = [Convert]::ToInt64($digits.TrimStart('+').Substring(2), 16) + } elseif (-not $Quoted -and $Raw -cmatch '^\+?0b[01_]+$') { + $number = [Convert]::ToInt64($digits.TrimStart('+').Substring(2), 2) + } elseif (-not $Quoted -and $Raw -match '^\+?0[0-7_]+$') { + $number = [Convert]::ToInt64($digits.TrimStart('+'), 8) + } elseif (-not $Quoted -and $Raw -match '^\+?[1-9][0-9_]*(:[0-5]?[0-9])+$') { + $number = [long]0 + foreach ($part in ($digits.TrimStart('+') -split ':')) { + $number = $number * 60 + [long]::Parse($part) + if ($number -gt [int]::MaxValue) { return 10 } + } + } elseif ($Raw -match '^\+?[0-9][0-9_]*$') { + $number = [long]::Parse($digits.TrimStart('+'), [Globalization.CultureInfo]::InvariantCulture) + } elseif (-not $Quoted -and $Raw -match '^\+?[0-9][0-9_]*\.[0-9_]*([eE][+-][0-9]+)?$') { + $number = [double]::Parse( + $digits, [Globalization.NumberStyles]::Float, + [Globalization.CultureInfo]::InvariantCulture + ) + } else { + return 10 + } + if ($number -lt 1 -or $number -ge ([double][int]::MaxValue + 1)) { return 10 } + return [int][Math]::Truncate($number) + } catch [FormatException], [OverflowException] { + return 10 + } +} + +function Resolve-HookConfig { + param([string]$Event, [string]$Path) + $text = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction Stop + if (-not $text.Trim()) { throw "Invalid .specify/extensions.yml: expected a hooks mapping" } + $hooks = [Collections.Generic.List[hashtable]]::new() + $inHooks = $false + $section = '' + $hooksEmpty = $false + $seenHooks = $false + $seenEvent = $false + $target = $false + $empty = $false + $itemIndent = -1 + $installedIndent = -1 + $hook = $null + foreach ($line in ($text -split '\r?\n')) { + if ($line -match '^\s*(#|$)') { continue } + if ($line.Contains("`t")) { throw "Tabs are not supported in canonical YAML" } + if ($line -notmatch '^( *)(.*)$') { throw "Unsupported YAML hook layout" } + $indent = $Matches[1].Length + $body = $Matches[2] + if ($section -eq 'installed' -and $indent -in @(0, 2) -and $body -match '^- \S') { + if ($body.Substring(2) -match '^[a-z_]+:(?:\s|$)') { + $installedIndent = $indent + Test-InstalledField $body.Substring(2) + } else { + $installedIndent = -1 + $null = Convert-HookScalar $body.Substring(2) + } + continue + } + if ($indent -eq 0) { + $target = $false + $installedIndent = -1 + if ($body -in @('hooks:', 'hooks: {}')) { + if ($seenHooks) { throw "Duplicate hooks mapping" } + $seenHooks = $true + $inHooks = $true + $section = 'hooks' + $hooksEmpty = $body -eq 'hooks: {}' + } elseif ($body -match '^hooks:') { + throw "Invalid .specify/extensions.yml: expected a hooks mapping" + } elseif ($body -in @('installed:', 'installed: []')) { + $inHooks = $false + $section = 'installed' + } elseif ($body -in @('settings:', 'settings: {}')) { + $inHooks = $false + $section = 'settings' + } else { + throw "Unsupported YAML top-level layout" + } + continue + } + if (-not $inHooks) { + if ($section -eq 'installed' -and $installedIndent -ge 0 -and + $indent -eq $installedIndent + 2) { + Test-InstalledField $body + continue + } + if ($section -eq 'settings' -and $indent -eq 2 -and $body -match '^([a-z_]+):(?:\s|$)(.*)$') { + $null = Convert-HookScalar $Matches[2] + continue + } + throw "Unsupported YAML top-level layout" + } + if ($hooksEmpty) { throw "Unsupported YAML hook layout" } + if ($indent -eq 2 -and -not $body.StartsWith('- ') -and + $body -match '^([^:]+):(?:\s|$)(.*)$') { + $key = $Matches[1] + $value = $Matches[2].Trim() + if ($key -cnotmatch '^(before|after)_[a-z][a-z0-9_]*$') { + throw "Invalid hook event: $key" + } + $target = $key -eq $Event + if ($target) { + if ($seenEvent) { throw "Duplicate hook event" } + $seenEvent = $true + } + if ($value -notin @('', '[]')) { throw "hooks.$key must be a list" } + $empty = $value -eq '[]' + $itemIndent = -1 + continue + } + if ($indent -in @(2, 4) -and $body.StartsWith('- ') -and -not $empty) { + $itemIndent = $indent + $hook = @{ enabled = 'true'; optional = 'true'; priority = '10'; priority_quoted = $false + target = $target; hook_event = $key } + $hooks.Add($hook) + Add-HookField $hook $body.Substring(2) $Event + continue + } + if ($itemIndent -ge 0 -and $indent -eq $itemIndent + 2) { + Add-HookField $hook $body $Event + continue + } + throw "Unsupported YAML hook layout" + } + $ordered = [Collections.Generic.List[hashtable]]::new() + for ($i = 0; $i -lt $hooks.Count; $i++) { + $hook = $hooks[$i] + if (-not $hook.extension -or -not $hook.command) { + throw "hooks.$($hook.hook_event) needs extension and command" + } + if (-not $hook.target -or $hook.enabled -eq 'false' -or $hook.condition) { continue } + $ordered.Add(@{ + extension = $hook.extension + command = $hook.command + optional = $hook.optional -eq 'true' + description = if ($hook.description) { $hook.description } else { '' } + prompt = if ($hook.prompt) { $hook.prompt } else { '' } + priority = Get-HookPriority $hook.priority $hook.priority_quoted + index = $i + }) + } + $result = @($ordered | Sort-Object priority, index | ForEach-Object { + @{ extension = $_.extension; command = $_.command; optional = $_.optional + description = $_.description; prompt = $_.prompt; priority = $_.priority } + }) + return $result +} + +function ConvertTo-HookLfBytes { + param([byte[]]$Bytes) + $stream = [IO.MemoryStream]::new($Bytes.Length) + try { + for ($i = 0; $i -lt $Bytes.Length; $i++) { + if ($Bytes[$i] -eq 13 -and $i + 1 -lt $Bytes.Length -and $Bytes[$i + 1] -eq 10) { + continue + } + $stream.WriteByte($Bytes[$i]) + } + return ,$stream.ToArray() + } finally { + $stream.Dispose() + } +} + +function Invoke-HookResolver { + param([string]$Phase, [string]$Command) + $event = "${Phase}_${Command}" + try { + if ($event -cnotmatch '^(before|after)_[a-z][a-z0-9_]*$') { + throw "Invalid hook event: $event" + } + $config = Join-Path (Get-Location) '.specify/extensions.yml' + $cache = Join-Path (Get-Location) '.specify/hook-dispatch' + if ((Test-Path -LiteralPath $config -PathType Leaf) -and + -not (Test-Path -LiteralPath $cache -PathType Container)) { + $firstLine = Get-Content -LiteralPath $config -TotalCount 1 -Encoding UTF8 -ErrorAction Stop + if ($firstLine -ceq '# Hook projection: .specify/hook-dispatch') { + throw "Hook projection is missing; reinstall the extension or refresh the project" + } + } + if (Test-Path -LiteralPath $cache -PathType Container) { + $snapshot = Join-Path $cache 'source.yml' + $events = Join-Path $cache 'events.txt' + if (-not (Test-Path -LiteralPath $config -PathType Leaf) -or + -not (Test-Path -LiteralPath $snapshot -PathType Leaf) -or + -not (Test-Path -LiteralPath $events -PathType Leaf)) { + throw "Hook projection is stale; reinstall the extension or refresh the project" + } + $source = [Convert]::ToBase64String((ConvertTo-HookLfBytes ([IO.File]::ReadAllBytes($config)))) + if ($source -cne [Convert]::ToBase64String((ConvertTo-HookLfBytes ([IO.File]::ReadAllBytes($snapshot))))) { + throw "Hook projection is stale; reinstall the extension or refresh the project" + } + $indexDigest = Join-Path $cache 'events.txt.sha256' + if (-not (Test-Path -LiteralPath $indexDigest -PathType Leaf)) { + throw "Hook projection index is incomplete; reinstall the extension or refresh the project" + } + $eventBytes = ConvertTo-HookLfBytes ([IO.File]::ReadAllBytes($events)) + $eventHash = [IO.File]::ReadAllText($indexDigest, [Text.Encoding]::UTF8).Trim() + if ($eventHash -cnotmatch '^[0-9a-f]{64}$' -or + [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($eventBytes)) -cne $eventHash.ToUpperInvariant()) { + throw "Hook projection index is invalid; reinstall the extension or refresh the project" + } + $projected = Join-Path $cache "$event.json" + if (Test-Path -LiteralPath $projected -PathType Leaf) { + $digest = Join-Path $cache "$event.sha256" + if (-not (Test-Path -LiteralPath $digest -PathType Leaf)) { + throw "Hook projection is incomplete; reinstall the extension or refresh the project" + } + $bytes = ConvertTo-HookLfBytes ([IO.File]::ReadAllBytes($projected)) + $expected = [IO.File]::ReadAllText($digest, [Text.Encoding]::UTF8).Trim() + if ($expected -cnotmatch '^[0-9a-f]{64}$' -or + [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) -cne $expected.ToUpperInvariant()) { + throw "Hook projection is invalid; reinstall the extension or refresh the project" + } + $text = [Text.Encoding]::UTF8.GetString($bytes) + try { $data = ConvertFrom-Json -InputObject $text -AsHashtable -ErrorAction Stop } + catch { throw "Invalid hook projection: $($_.Exception.Message)" } + if ($data -isnot [Collections.IDictionary] -or $data.event -cne $event -or + $data.hooks -isnot [array]) { + throw "Invalid hook projection; reinstall the extension or refresh the project" + } + foreach ($entry in $data.hooks) { + if ($entry -isnot [Collections.IDictionary] -or + $entry.extension -isnot [string] -or -not $entry.extension -or + $entry.command -isnot [string] -or -not $entry.command -or + $entry.optional -isnot [bool] -or + $entry.description -isnot [string] -or + $entry.prompt -isnot [string] -or + $entry.priority -isnot [long] -or $entry.priority -lt 1) { + throw "Invalid hook projection; reinstall the extension or refresh the project" + } + } + $result = $text.TrimEnd("`r", "`n") + } elseif (@([Text.Encoding]::UTF8.GetString($eventBytes) -split "`n") -ccontains $event) { + throw "Hook projection is incomplete; reinstall the extension or refresh the project" + } else { + $result = @{ event = $event; hooks = @() } | ConvertTo-Json -Depth 5 -Compress + } + if ($source -cne [Convert]::ToBase64String((ConvertTo-HookLfBytes ([IO.File]::ReadAllBytes($config)))) -or + $source -cne [Convert]::ToBase64String((ConvertTo-HookLfBytes ([IO.File]::ReadAllBytes($snapshot))))) { + throw "Hook projection changed during resolution; retry the command" + } + [Console]::WriteLine($result) + exit 0 + } + $hooks = @() + if (Test-Path -LiteralPath $config) { + $hooks = @(Resolve-HookConfig $event $config) + } + @{ event = $event; hooks = $hooks } | ConvertTo-Json -Depth 5 -Compress + exit 0 + } catch { + @{ event = $event; hooks = @(); error = $_.Exception.Message } | + ConvertTo-Json -Depth 5 -Compress + exit 1 + } +} + +if ($MyInvocation.InvocationName -ne '.') { + Invoke-HookResolver before $args[0] +} diff --git a/scripts/python/post_hooks.py b/scripts/python/post_hooks.py new file mode 100644 index 0000000000..d069f4b2f6 --- /dev/null +++ b/scripts/python/post_hooks.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""Resolve extension hooks for a core command's after event.""" + +from pre_hooks import main + + +if __name__ == "__main__": + raise SystemExit(main("after")) diff --git a/scripts/python/pre_hooks.py b/scripts/python/pre_hooks.py new file mode 100644 index 0000000000..69a215a0a3 --- /dev/null +++ b/scripts/python/pre_hooks.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Resolve extension hooks for a core command's before event.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path + +_EVENT = re.compile(r"^(before|after)_[a-z][a-z0-9_]*$") + + +def _lf_bytes(data: bytes) -> bytes: + return data.replace(b"\r\n", b"\n") + + +def resolve(event: str, project_root: Path) -> dict: + """Return enabled, unconditional hooks in priority order.""" + if not _EVENT.fullmatch(event): + raise ValueError(f"Invalid hook event: {event}") + + config_file = project_root / ".specify" / "extensions.yml" + cache = project_root / ".specify" / "hook-dispatch" + if cache.is_dir(): + try: + source = _lf_bytes(config_file.read_bytes()) + if source != _lf_bytes((cache / "source.yml").read_bytes()): + raise ValueError("Hook projection is stale; reinstall the extension or refresh the project") + event_bytes = _lf_bytes((cache / "events.txt").read_bytes()) + index_digest = (cache / "events.txt.sha256").read_text(encoding="utf-8").strip() + if not re.fullmatch("[0-9a-f]{64}", index_digest) or hashlib.sha256(event_bytes).hexdigest() != index_digest: + raise ValueError("Hook projection index is invalid; reinstall the extension or refresh the project") + events = event_bytes.decode("utf-8").splitlines() + projection = cache / f"{event}.json" + if not projection.exists(): + if event in events: + raise ValueError("Hook projection is incomplete; reinstall the extension or refresh the project") + result = {"event": event, "hooks": []} + else: + expected = (cache / f"{event}.sha256").read_text(encoding="utf-8").strip() + payload = _lf_bytes(projection.read_bytes()) + if not re.fullmatch("[0-9a-f]{64}", expected) or hashlib.sha256(payload).hexdigest() != expected: + raise ValueError("Hook projection is invalid; reinstall the extension or refresh the project") + result = json.loads(payload.decode("utf-8")) + if source != _lf_bytes(config_file.read_bytes()) or source != _lf_bytes((cache / "source.yml").read_bytes()): + raise ValueError("Hook projection changed during resolution; retry the command") + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValueError(f"Could not read hook projection: {exc}") from exc + if not isinstance(result, dict) or result.get("event") != event or not isinstance(result.get("hooks"), list): + raise ValueError("Invalid hook projection; reinstall the extension or refresh the project") + for hook in result["hooks"]: + if (not isinstance(hook, dict) + or any(not isinstance(hook.get(field), str) or not hook[field] + for field in ("extension", "command")) + or not isinstance(hook.get("optional"), bool) + or not isinstance(hook.get("description"), str) + or not isinstance(hook.get("prompt"), str) + or not isinstance(hook.get("priority"), int) + or isinstance(hook["priority"], bool) + or hook["priority"] < 1): + raise ValueError("Invalid hook projection; reinstall the extension or refresh the project") + return result + if config_file.exists(): + try: + with config_file.open(encoding="utf-8") as handle: + if handle.readline().strip() == "# Hook projection: .specify/hook-dispatch": + raise ValueError("Hook projection is missing; reinstall the extension or refresh the project") + except (OSError, UnicodeError) as exc: + raise ValueError(f"Could not read .specify/extensions.yml: {exc}") from exc + if not config_file.exists(): + return {"event": event, "hooks": []} + + try: + import yaml + except ImportError as exc: + raise RuntimeError("PyYAML is required to read .specify/extensions.yml") from exc + + try: + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise ValueError(f"Could not read .specify/extensions.yml: {exc}") from exc + if not isinstance(config, dict) or not isinstance(config.get("hooks", {}), dict): + raise ValueError("Invalid .specify/extensions.yml: expected a hooks mapping") + + hooks = [] + for hook_event, entries in config.get("hooks", {}).items(): + if not isinstance(hook_event, str) or not _EVENT.fullmatch(hook_event): + raise ValueError(f"Invalid hook event: {hook_event}") + if not isinstance(entries, list): + raise ValueError(f"Invalid .specify/extensions.yml: hooks.{hook_event} must be a list") + for index, entry in enumerate(entries): + label = f"hooks.{hook_event}[{index}]" + if not isinstance(entry, dict): + raise ValueError(f"Invalid .specify/extensions.yml: {label} must be a mapping") + if any(isinstance(value, str) and "\0" in value for value in entry.values()): + raise ValueError(f"Invalid .specify/extensions.yml: {label} contains a NUL character") + for field in ("enabled", "optional"): + if field in entry and not isinstance(entry[field], bool): + raise ValueError(f"Invalid .specify/extensions.yml: {label}.{field} must be a boolean") + for field in ("condition", "description", "prompt"): + if entry.get(field) is not None and not isinstance(entry[field], str): + raise ValueError(f"Invalid .specify/extensions.yml: {label}.{field} must be a string or null") + for field in ("extension", "command"): + if field in entry and (not isinstance(entry[field], str) or not entry[field]): + raise ValueError(f"Invalid .specify/extensions.yml: {label} needs extension and command") + extension, command = entry.get("extension"), entry.get("command") + if not extension or not command: + raise ValueError(f"Invalid .specify/extensions.yml: {label} needs extension and command") + if hook_event != event or entry.get("enabled") is False or entry.get("condition"): + continue + priority = entry.get("priority", 10) + try: + priority = int(priority) if not isinstance(priority, bool) else 10 + except (TypeError, ValueError, OverflowError): + priority = 10 + if priority < 1 or priority > 2147483647: + priority = 10 + hooks.append({ + "extension": extension, + "command": command, + "optional": entry.get("optional", True), + "description": entry.get("description") or "", + "prompt": entry.get("prompt") or "", + "priority": priority, + }) + hooks.sort(key=lambda hook: hook["priority"]) + return {"event": event, "hooks": hooks} + + +def main(phase: str = "before") -> int: + parser = argparse.ArgumentParser(description=f"Resolve {phase} extension hooks") + parser.add_argument("command", help="Core command name, e.g. plan") + args = parser.parse_args() + event = f"{phase}_{args.command}" + try: + result = resolve(event, Path.cwd()) + except (ValueError, RuntimeError) as exc: + print(json.dumps({"event": event, "hooks": [], "error": str(exc)})) + return 1 + print(json.dumps(result)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 544ef1cfef..ba65ea3b6a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -140,6 +140,7 @@ def _install_shared_infra( invoke_prefix: str = "/", refresh_managed: bool = False, refresh_hint: str | None = None, + hook_dispatchers_only: bool = False, ) -> bool: """Install shared infrastructure files into *project_path*. @@ -162,6 +163,9 @@ def _install_shared_infra( treated as user customizations and preserved with a warning. * Default — only add missing files; existing ones are skipped. + * ``hook_dispatchers_only=True`` — update only pre/post hook resolvers, + leaving unrelated shared infrastructure unchanged. + *refresh_hint* — caller-supplied rich-text fragment shown after the "Preserved customized files" warning to tell the user which flag/command they should re-run with to overwrite their customizations. Each caller @@ -172,7 +176,7 @@ def _install_shared_infra( Returns ``True`` on success. """ - return _install_shared_infra_impl( + installed = _install_shared_infra_impl( project_path, script_type, version=get_speckit_version(), @@ -184,7 +188,12 @@ def _install_shared_infra( invoke_prefix=invoke_prefix, refresh_managed=refresh_managed, refresh_hint=refresh_hint, + hook_dispatchers_only=hook_dispatchers_only, ) + from .extensions import HookExecutor + + HookExecutor(project_path).migrate_project_config() + return installed def _install_shared_infra_or_exit( diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 72966998a5..59a14596c3 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -556,6 +556,13 @@ def resolve_skill_placeholders( script_command = script_command.replace("{ARGS}", "$ARGUMENTS") body = body.replace("{SCRIPT}", script_command) + if "{PRE_HOOK_SCRIPT}" in body or "{POST_HOOK_SCRIPT}" in body: + from specify_cli.integrations.base import IntegrationBase + + body = IntegrationBase.resolve_hook_script_refs( + body, script_variant or "", project_root + ) + body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) return CommandRegistrar.rewrite_project_relative_paths( diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index d03cb85770..ac1784b3cf 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -20,6 +20,7 @@ import re import shutil import stat +import sys import tempfile from dataclasses import dataclass from datetime import datetime, timezone @@ -432,10 +433,22 @@ def _validate(self): f"Invalid hook '{hook_name}': " "expected a mapping or list of mappings" ) - if not entry.get("command"): + if not isinstance(entry.get("command"), str) or not entry["command"]: raise ValidationError( f"Hook '{hook_name}' missing required 'command' field" ) + for field in ("enabled", "optional"): + if field in entry and not isinstance(entry[field], bool): + raise ValidationError( + f"Hook '{hook_name}' has invalid '{field}': must be a boolean" + ) + for field in ("condition", "description", "prompt"): + if entry.get(field) is not None and not isinstance(entry[field], str): + raise ValidationError( + f"Hook '{hook_name}' has invalid '{field}': must be a string or null" + ) + if any(isinstance(value, str) and "\0" in value for value in entry.values()): + raise ValidationError(f"Hook '{hook_name}' contains a NUL character") if "priority" in entry: priority = entry["priority"] if not isinstance(priority, int) or isinstance(priority, bool): @@ -2461,6 +2474,11 @@ def install_from_directory( # Load and validate manifest manifest_path = source_dir / "extension.yml" manifest = ExtensionManifest(manifest_path) + for event in manifest.hooks: + if not isinstance(event, str) or not re.fullmatch( + r"(before|after)_[a-z][a-z0-9_]*", event + ): + raise ValidationError(f"Invalid hook event for hook projection: {event}") # Check compatibility self.check_compatibility(manifest, speckit_version) @@ -2579,6 +2597,73 @@ def install_from_directory( f"extension. Install from a copy in a different location instead." ) + from ..integrations.base import IntegrationBase + + requested_script = ( + active_options.get("script") if isinstance(active_options, dict) else None + ) or ("ps" if os.name == "nt" else "sh") + hook_variants: set[str] = set() + for command in manifest.commands: + source_file = (source_dir / command["file"]).resolve() + if not source_file.is_relative_to(source_dir.resolve()) or not source_file.is_file(): + continue + try: + command_source = source_file.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise ExtensionError(f"Cannot read extension command '{command['file']}': {exc}") from exc + if "{PRE_HOOK_SCRIPT}" not in command_source and "{POST_HOOK_SCRIPT}" not in command_source: + continue + if requested_script not in ("sh", "ps", "py"): + raise ExtensionError( + f"Unsupported hook dispatcher script type: {requested_script}" + ) + frontmatter, _ = CommandRegistrar.parse_frontmatter(command_source) + scripts = frontmatter.get("scripts") or {} + if not isinstance(scripts, dict): + scripts = {} + try: + selected = ( + IntegrationBase.select_script_variant(requested_script, scripts) + if scripts else requested_script + ) + except ValueError as exc: + raise ExtensionError(f"Cannot select hook dispatcher: {exc}") from exc + if selected not in ("sh", "ps", "py"): + raise ExtensionError(f"Unsupported hook dispatcher script type: {selected}") + hook_variants.add(selected) + + if not hook_variants: + try: + HookExecutor(self.project_root).migrate_project_config() + except (OSError, ValueError) as exc: + raise ExtensionError(f"Cannot install extension with invalid hooks: {exc}") from exc + + if hook_variants: + from .. import _install_shared_infra + from ..integrations.base import get_invocation_prefix + + options = active_options if isinstance(active_options, dict) else {} + skills = is_ai_skills_enabled(options) + for script in sorted(hook_variants): + try: + _install_shared_infra( + self.project_root, + script, + invoke_separator="-" if skills else ".", + invoke_prefix=get_invocation_prefix(options.get("ai"), skills), + refresh_managed=True, + hook_dispatchers_only=True, + ) + except (OSError, ValueError) as exc: + raise ExtensionError(f"Cannot install hook dispatchers: {exc}") from exc + variant = {"sh": "bash", "ps": "powershell", "py": "python"}[script] + for phase in ("pre", "post"): + name = f"{phase}_hooks.py" if script == "py" else f"{phase}-hooks.{script if script == 'sh' else 'ps1'}" + dispatcher = self.project_root / ".specify" / "scripts" / variant / name + if dispatcher.is_symlink() or not dispatcher.is_file(): + raise ExtensionError( + f"Cannot register extension commands: missing hook dispatcher '{dispatcher}'" + ) from ..integrations._file_changes import after_file_change, before_file_change, changing_file package_paths = ( @@ -5759,16 +5844,120 @@ def save_project_config(self, config: Dict[str, Any]): Args: config: Configuration dictionary to save """ - from ..integrations._file_changes import write_text + class CanonicalHookDumper(yaml.SafeDumper): + pass - self.config_file.parent.mkdir(parents=True, exist_ok=True) - write_text( - self.config_file, - yaml.dump( - config, default_flow_style=False, sort_keys=False, allow_unicode=True - ), - encoding="utf-8", + def represent_string(dumper: yaml.SafeDumper, value: str): + if "\0" in value: + raise ValueError("Cannot serialize NUL in native extension hook configuration") + style = '"' if any(char in value for char in "\n\r\x85\u2028\u2029{}[]") else None + return dumper.represent_scalar("tag:yaml.org,2002:str", value, style=style) + + CanonicalHookDumper.add_representer(str, represent_string) + from ..integrations._file_changes import changing_file, unlink, write_bytes, write_text + + hooks = config.get("hooks", {}) + if not isinstance(hooks, dict): + raise ValueError("Invalid .specify/extensions.yml: expected a hooks mapping") + projected: dict[str, bytes] = {} + for event, entries in hooks.items(): + if not isinstance(event, str) or not re.fullmatch(r"(before|after)_[a-z][a-z0-9_]*", event): + raise ValueError(f"Invalid hook event: {event}") + if not isinstance(entries, list): + raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event} must be a list") + selected = [] + for index, entry in enumerate(entries): + label = f"hooks.{event}[{index}]" + if not isinstance(entry, dict): + raise ValueError(f"Invalid .specify/extensions.yml: {label} must be a mapping") + if any(isinstance(value, str) and "\0" in value for value in entry.values()): + raise ValueError(f"Invalid .specify/extensions.yml: {label} contains a NUL character") + for field in ("enabled", "optional"): + if field in entry and not isinstance(entry[field], bool): + raise ValueError(f"Invalid .specify/extensions.yml: {label}.{field} must be a boolean") + for field in ("condition", "description", "prompt"): + if entry.get(field) is not None and not isinstance(entry[field], str): + raise ValueError(f"Invalid .specify/extensions.yml: {label}.{field} must be a string or null") + for field in ("extension", "command"): + if not isinstance(entry.get(field), str) or not entry[field]: + raise ValueError(f"Invalid .specify/extensions.yml: {label} needs extension and command") + if entry.get("enabled", True) is False or entry.get("condition"): + continue + priority = entry.get("priority", DEFAULT_HOOK_PRIORITY) + try: + priority = int(priority) if not isinstance(priority, bool) else DEFAULT_HOOK_PRIORITY + except (TypeError, ValueError, OverflowError): + priority = DEFAULT_HOOK_PRIORITY + if not 1 <= priority <= 2147483647: + priority = DEFAULT_HOOK_PRIORITY + selected.append({ + "extension": entry["extension"], "command": entry["command"], + "optional": entry.get("optional", True), + "description": entry.get("description") or "", + "prompt": entry.get("prompt") or "", "priority": priority, + }) + selected.sort(key=lambda hook: hook["priority"]) + projected[event] = ( + json.dumps( + {"event": event, "hooks": selected}, ensure_ascii=True, + separators=(",", ":"), + ) + "\n" + ).encode("utf-8") + + rendered = "# Hook projection: .specify/hook-dispatch\n" + yaml.dump( + config, default_flow_style=False, sort_keys=False, + allow_unicode=True, width=sys.maxsize, Dumper=CanonicalHookDumper, ) + from ..shared_infra import _exclusive_project_lock + + lock = self.config_file.parent / ".hook-dispatch.lock" + with changing_file(lock): + with _exclusive_project_lock(self.project_root, lock.name, context="hook dispatch"): + if self.config_file.parent.is_symlink() or self.config_file.is_symlink(): + raise ValueError("Refusing to save symlinked .specify/extensions.yml") + cache = self.config_file.parent / "hook-dispatch" + if cache.is_symlink(): + raise ValueError("Refusing to write symlinked hook dispatch cache") + if not cache.exists(): + with changing_file(cache): + cache.mkdir(parents=True) + for path in cache.iterdir(): + if path.is_symlink(): + raise ValueError("Refusing to write symlinked hook dispatch cache entry") + snapshot = cache / "source.yml" + unlink(snapshot, missing_ok=True) + for pattern in ("*.json", "*.sha256"): + for path in cache.glob(pattern): + unlink(path) + for event, payload in projected.items(): + write_bytes(cache / f"{event}.json", payload) + write_bytes( + cache / f"{event}.sha256", + (hashlib.sha256(payload).hexdigest() + "\n").encode("ascii"), + ) + events_payload = "".join(f"{event}\n" for event in projected).encode("utf-8") + write_bytes(cache / "events.txt", events_payload) + write_bytes( + cache / "events.txt.sha256", + (hashlib.sha256(events_payload).hexdigest() + "\n").encode("ascii"), + ) + write_text(self.config_file, rendered, encoding="utf-8") + write_bytes(snapshot, self.config_file.read_bytes()) + + def migrate_project_config(self) -> None: + """Validate and project existing hook configuration during project refresh.""" + if self.config_file.parent.is_symlink() or self.config_file.is_symlink(): + raise ValueError("Refusing to migrate symlinked .specify/extensions.yml") + if not self.config_file.exists(): + return + try: + config = yaml.safe_load(self.config_file.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise ValueError(f"Could not read .specify/extensions.yml: {exc}") from exc + if not isinstance(config, dict) or not isinstance(config.get("hooks", {}), dict): + raise ValueError("Invalid .specify/extensions.yml: expected a hooks mapping") + config.setdefault("hooks", {}) + self.save_project_config(config) def register_extension(self, extension_id: str): """Add extension to the installed list in project config. diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 6eee0513d6..03cfd00eac 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -42,6 +42,13 @@ "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" ) +_HOOK_INSTRUCTION_PATTERN = ( + r"(?m)^([ \t]*)(" + r"- For each executable hook, output the following[^\r\n]*" + r"|[^\r\n]*For each returned hook in order: invoke mandatory commands[^\r\n]*" + r")(\r\n|\n|$)" +) + _CORE_COMMAND_TEMPLATE_ORDER = ( "analyze", "clarify", @@ -803,6 +810,28 @@ def build_python_invocation( interpreter = shlex.quote(interpreter) return f"{interpreter} {script_command}" + @staticmethod + def resolve_hook_script_refs( + content: str, script_type: str, project_root: Path | None = None + ) -> str: + """Replace hook script placeholders using the command's script variant.""" + if "{PRE_HOOK_SCRIPT}" not in content and "{POST_HOOK_SCRIPT}" not in content: + return content + variant = script_type if script_type in ("sh", "ps", "py") else ( + "ps" if os.name == "nt" else "sh" + ) + for phase, name in (("PRE", "pre"), ("POST", "post")): + if variant == "py": + command = IntegrationBase.build_python_invocation( + f".specify/scripts/python/{name}_hooks.py", project_root + ) + elif variant == "ps": + command = f".specify/scripts/powershell/{name}-hooks.ps1" + else: + command = f".specify/scripts/bash/{name}-hooks.sh" + content = content.replace(f"{{{phase}_HOOK_SCRIPT}}", command) + return content + @staticmethod def select_script_variant( requested: object, script_commands: dict[str, str] @@ -916,6 +945,10 @@ def process_template( ) content = content.replace("{SCRIPT}", script_command) + content = IntegrationBase.resolve_hook_script_refs( + content, selected_script_type or script_type, project_root + ) + # 3. Strip scripts: section from frontmatter lines = content.splitlines(keepends=True) output_lines: list[str] = [] @@ -1731,8 +1764,8 @@ def _inject_hook_command_note( ) -> str: """Insert a dot-to-hyphen note before each hook output instruction. - Targets the line ``- For each executable hook, output the following`` - and inserts the note on the line before it, matching its indentation. + Targets legacy hook-output instructions and scripted hook-dispatch + instructions, matching their indentation. Skips individual instructions that already have the note immediately above them. """ @@ -1764,7 +1797,7 @@ def repl(m: re.Match[str]) -> str: ) return re.sub( - r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + _HOOK_INSTRUCTION_PATTERN, repl, content, ) diff --git a/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py index db6961b5eb..694073d5a1 100644 --- a/src/specify_cli/integrations/cline/__init__.py +++ b/src/specify_cli/integrations/cline/__init__.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any -from ..base import MarkdownIntegration +from ..base import MarkdownIntegration, _HOOK_INSTRUCTION_PATTERN from ..manifest import IntegrationManifest @@ -140,7 +140,7 @@ def repl(m: re.Match[str]) -> str: # instruction. This also matches the shared base helper, without which # the per-instruction check above cannot line up. return re.sub( - r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + _HOOK_INSTRUCTION_PATTERN, repl, content, ) diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index 21ecac1f83..f112060799 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -1,6 +1,6 @@ """Junie integration (JetBrains).""" -from ..base import MarkdownIntegration +from ..base import MarkdownIntegration, _HOOK_INSTRUCTION_PATTERN from ..manifest import IntegrationManifest @@ -94,16 +94,15 @@ def process_template(self, *args, **kwargs): def _inject_hook_command_note(content: str) -> str: """Insert a dot-to-hyphen note before each hook output instruction. - Targets the line ``- For each executable hook, output the following`` - and inserts the note on the line before it, matching its indentation. + Targets legacy and scripted hook instructions, matching indentation. Skips if the note is already present. """ - if "replace dots" in content: - return content - def repl(m: re.Match[str]) -> str: indent = m.group(1) instruction = m.group(2) + previous_lines = content[:m.start()].splitlines() + if previous_lines and previous_lines[-1] == indent + _HOOK_COMMAND_NOTE.rstrip("\n"): + return m.group(0) # ``eol`` is empty when the regex matched via ``$`` because the # instruction was the final line of a file with no trailing # newline. Default to ``\n`` so the note never collapses onto @@ -119,7 +118,7 @@ def repl(m: re.Match[str]) -> str: ) return re.sub( - r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + _HOOK_INSTRUCTION_PATTERN, repl, content, ) diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 134e329758..503d04aff7 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -477,6 +477,7 @@ def install_shared_infra( invoke_prefix: str = "/", refresh_managed: bool = False, refresh_hint: str | None = None, + hook_dispatchers_only: bool = False, ) -> bool: """Install shared scripts and templates into *project_path*. @@ -488,7 +489,8 @@ def install_shared_infra( warning — the safe-destination check refuses to follow them so writes cannot escape the project root). ``refresh_hint`` is shown after the customization warning to tell the user which flag would overwrite their - customizations. + customizations. ``hook_dispatchers_only`` updates only the selected hook + resolvers without touching templates, gitignore, or unrelated scripts. """ from .integrations.manifest import _sha256, _validate_rel_path @@ -585,6 +587,11 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: for src_path in variant_src.rglob("*"): if not src_path.is_file(): continue + if hook_dispatchers_only and src_path.name not in { + "pre-hooks.sh", "post-hooks.sh", "pre-hooks.ps1", + "post-hooks.ps1", "pre_hooks.py", "post_hooks.py", + }: + continue # Python bytecode caches are local artifacts, not # workflow scripts — never install them. if "__pycache__" in src_path.parts: @@ -644,7 +651,7 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: ) templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root) - if templates_src.is_dir(): + if not hook_dispatchers_only and templates_src.is_dir(): dest_templates = project_path / ".specify" / "templates" if _ensure_or_bucket_dir(dest_templates): for src in templates_src.iterdir(): @@ -695,7 +702,7 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: # tracked in ``speckit.manifest.json`` (not the per-integration manifest) and # is therefore intentionally left in place by ``integration uninstall``. specify_dir = project_path / ".specify" - if _ensure_or_bucket_dir(specify_dir): + if not hook_dispatchers_only and _ensure_or_bucket_dir(specify_dir): gitignore_dst = specify_dir / ".gitignore" gitignore_rel = gitignore_dst.relative_to(project_path).as_posix() seen_rels.add(gitignore_rel) @@ -774,7 +781,7 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: # deletion), scoped to the selected variants, and only for *managed* copies — # a user-customized file (hash diverges), a symlink, or a recovered entry is # preserved by ``_is_managed``. - if scanned_variant_dirs: + if scanned_variant_dirs and not hook_dispatchers_only: stale_removed: list[str] = [] script_prefixes = tuple( f".specify/scripts/{variant_dir}/" for variant_dir in scanned_variant_dirs diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md index 7de2d28f0d..0ecb8dd0dd 100644 --- a/templates/commands/analyze.md +++ b/templates/commands/analyze.md @@ -16,38 +16,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before analysis)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_analyze` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Goal. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} analyze` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Goal @@ -203,35 +172,7 @@ Ask the user: "Would you like me to suggest concrete remediation edits for the t ### 9. Check for extension hooks -After reporting, check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.after_analyze` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{POST_HOOK_SCRIPT} analyze` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Operating Principles diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index 6e33f5d422..c40d042607 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -46,38 +46,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before checklist generation)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_checklist` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Execution Steps. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} checklist` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Execution Steps @@ -347,33 +316,4 @@ Sample items: ## Post-Execution Checks -**Check for extension hooks (after checklist generation)**: -Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.after_checklist` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{POST_HOOK_SCRIPT} checklist` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index f48d8bd703..423b787dec 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -20,38 +20,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before clarification)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_clarify` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} clarify` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -240,37 +209,7 @@ Context for prioritization: {ARGS} ## Mandatory Post-Execution Hooks -**You MUST complete this section before reporting completion to the user.** - -Check if `.specify/extensions.yml` exists in the project root. -- If it does not exist, or no hooks are registered under `hooks.after_clarify`, skip to the Completion Report. -- If it exists, read it and look for entries under the `hooks.after_clarify` key. -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` +Run `{POST_HOOK_SCRIPT} clarify` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Completion Report diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index f2c107a158..6c1c9bbe46 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -39,38 +39,7 @@ and commands read the constitution at runtime and are not modified here. ## Pre-Execution Checks -**Check for extension hooks (before constitution update)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_constitution` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} constitution` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -147,33 +116,4 @@ Write only `.specify/memory/constitution.md`; do not create or modify template s ## Post-Execution Checks -**Check for extension hooks (after constitution update)**: -Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.after_constitution` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{POST_HOOK_SCRIPT} constitution` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. diff --git a/templates/commands/converge.md b/templates/commands/converge.md index f6a44c3e91..5707972c37 100644 --- a/templates/commands/converge.md +++ b/templates/commands/converge.md @@ -16,43 +16,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before convergence)**: - -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_converge` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - - ```text - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - - **Mandatory hook** (`optional: false`): - - ```text - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Goal. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} converge` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Goal @@ -241,39 +205,6 @@ Append to the **end** of `tasks.md`, per the append contract: ### 9. Check for extension hooks -After producing the result, check if `.specify/extensions.yml` exists in the project root. - -- If it exists, read it and look for entries under the `hooks.after_converge` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- Report the convergence outcome (`converged` or `tasks_appended`) in-session before listing - any hooks, so users can decide whether to run optional follow-up commands. -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - - ```text - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - - **Mandatory hook** (`optional: false`): - - ```text - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. +Report the convergence outcome before offering optional follow-up hooks. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{POST_HOOK_SCRIPT} converge` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. diff --git a/templates/commands/implement.md b/templates/commands/implement.md index f98ba525de..8a1614f706 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -16,38 +16,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before implementation)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_implement` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} implement` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -178,37 +147,7 @@ Note: This command assumes a complete task breakdown exists in tasks.md. If task ## Mandatory Post-Execution Hooks -**You MUST complete this section before reporting completion to the user.** - -Check if `.specify/extensions.yml` exists in the project root. -- If it does not exist, or no hooks are registered under `hooks.after_implement`, skip to the Completion Report. -- If it exists, read it and look for entries under the `hooks.after_implement` key. -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` +Run `{POST_HOOK_SCRIPT} implement` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Completion Report diff --git a/templates/commands/plan.md b/templates/commands/plan.md index d559e517ae..4bf4c6398a 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -24,38 +24,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before planning)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_plan` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} plan` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -73,37 +42,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Mandatory Post-Execution Hooks -**You MUST complete this section before reporting completion to the user.** - -Check if `.specify/extensions.yml` exists in the project root. -- If it does not exist, or no hooks are registered under `hooks.after_plan`, skip to the Completion Report. -- If it exists, read it and look for entries under the `hooks.after_plan` key. -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` +Run `{POST_HOOK_SCRIPT} plan` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Completion Report diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 52b2b24565..09edb43e8c 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -20,38 +20,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before specification)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_specify` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} specify` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -235,37 +204,7 @@ Given that feature description, do this: ## Mandatory Post-Execution Hooks -**You MUST complete this section before reporting completion to the user.** - -Check if `.specify/extensions.yml` exists in the project root. -- If it does not exist, or no hooks are registered under `hooks.after_specify`, skip to the Completion Report. -- If it exists, read it and look for entries under the `hooks.after_specify` key. -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` +Run `{POST_HOOK_SCRIPT} specify` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Completion Report diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 1b9c4255fe..6bc4e47941 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -25,38 +25,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before tasks generation)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_tasks` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} tasks` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -94,37 +63,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Mandatory Post-Execution Hooks -**You MUST complete this section before reporting completion to the user.** - -Check if `.specify/extensions.yml` exists in the project root. -- If it does not exist, or no hooks are registered under `hooks.after_tasks`, skip to the Completion Report. -- If it exists, read it and look for entries under the `hooks.after_tasks` key. -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` +Run `{POST_HOOK_SCRIPT} tasks` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Completion Report diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index dea7e7cfe5..81c5ad13c3 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -25,38 +25,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before tasks-to-issues conversion)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} taskstoissues` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -82,33 +51,4 @@ git config --get remote.origin.url ## Post-Execution Checks -**Check for extension hooks (after tasks-to-issues conversion)**: -Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{POST_HOOK_SCRIPT} taskstoissues` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. diff --git a/tests/contract/test_bundled_extension_update_delivery.py b/tests/contract/test_bundled_extension_update_delivery.py index f85e745829..b7d4babceb 100644 --- a/tests/contract/test_bundled_extension_update_delivery.py +++ b/tests/contract/test_bundled_extension_update_delivery.py @@ -17,7 +17,9 @@ from __future__ import annotations import json +import os import shutil +import tomllib from pathlib import Path from unittest.mock import patch @@ -40,6 +42,10 @@ def _catalog_entries() -> dict[str, dict]: catalog = json.loads((EXTENSIONS_ROOT / "catalog.json").read_text(encoding="utf-8")) return catalog["extensions"] +def _checkout_version() -> str: + """Use this checkout's version, not possibly stale editable-install metadata.""" + return tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] + def _bundled_ids() -> list[str]: return sorted( @@ -72,7 +78,7 @@ def _stale_copy(tmp_path: Path, ext_id: str, version: str) -> Path: def _run_update(project_dir: Path, ext_id: str): from specify_cli import app - from specify_cli.extensions import ExtensionCatalog + from specify_cli.extensions import ExtensionCatalog, _commands catalog_info = dict(_catalog_entries()[ext_id]) catalog_info.setdefault("_install_allowed", True) @@ -80,6 +86,7 @@ def _run_update(project_dir: Path, ext_id: str): # catalog is replaced, and downloading must never be attempted for a # bundled extension. with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(_commands, "get_speckit_version", return_value=_checkout_version()), \ patch.object(ExtensionCatalog, "get_extension_info", return_value=catalog_info), \ patch.object( ExtensionCatalog, @@ -109,7 +116,6 @@ def test_drifted_extensions_are_covered(): @pytest.mark.parametrize("ext_id", _bundled_ids()) def test_stale_bundled_install_is_updated_to_catalog_version(tmp_path: Path, ext_id: str): - from specify_cli._assets import get_speckit_version from specify_cli.extensions import ExtensionManager catalog_version = Version(_catalog_entries()[ext_id]["version"]) @@ -124,8 +130,14 @@ def test_stale_bundled_install_is_updated_to_catalog_version(tmp_path: Path, ext project_dir = _make_project(tmp_path) stale_source = _stale_copy(tmp_path, ext_id, PRE_BUMP_VERSION) manager = ExtensionManager(project_dir) - manager.install_from_directory(stale_source, get_speckit_version()) + manager.install_from_directory(stale_source, _checkout_version()) assert manager.registry.get(ext_id)["version"] == PRE_BUMP_VERSION + if ext_id == "github": + script_dir, suffix = ("powershell", "ps1") if os.name == "nt" else ("bash", "sh") + for phase in ("pre", "post"): + dispatcher = project_dir / ".specify" / "scripts" / script_dir / f"{phase}-hooks.{suffix}" + assert dispatcher.is_file() + dispatcher.unlink() result = _run_update(project_dir, ext_id) @@ -134,19 +146,21 @@ def test_stale_bundled_install_is_updated_to_catalog_version(tmp_path: Path, ext assert f"Updated to v{catalog_version}" in flat, flat assert "Up to date" not in flat, flat assert ExtensionManager(project_dir).registry.get(ext_id)["version"] == str(catalog_version) + if ext_id == "github": + for phase in ("pre", "post"): + assert (project_dir / ".specify" / "scripts" / script_dir / f"{phase}-hooks.{suffix}").is_file() @pytest.mark.parametrize("ext_id", _bundled_ids()) def test_current_bundled_install_is_up_to_date(tmp_path: Path, ext_id: str): """The bumped catalog must not re-offer an update to an install that already carries the bundled version, or every fresh install would loop.""" - from specify_cli._assets import get_speckit_version from specify_cli.extensions import ExtensionManager catalog_version = _catalog_entries()[ext_id]["version"] project_dir = _make_project(tmp_path) ExtensionManager(project_dir).install_from_directory( - EXTENSIONS_ROOT / ext_id, get_speckit_version() + EXTENSIONS_ROOT / ext_id, _checkout_version() ) result = _run_update(project_dir, ext_id) diff --git a/tests/extensions/github/test_github_extension.py b/tests/extensions/github/test_github_extension.py index 718c1c8421..e3e57e3957 100644 --- a/tests/extensions/github/test_github_extension.py +++ b/tests/extensions/github/test_github_extension.py @@ -40,10 +40,9 @@ # A released Spec Kit version that satisfies the manifest floor. Installs are # refused below it, so this cannot be an arbitrary synthetic value. -INSTALL_SPECKIT_VERSION = "1.0.12" -# The floor exists because auto-registered skills did not resolve command -# reference tokens until 0.12.17 (#3544). -MIN_SPECKIT_VERSION = "0.12.17" +INSTALL_SPECKIT_VERSION = "1.1.3.dev0" +# Hook placeholders and dispatcher installation first ship with this CLI release. +MIN_SPECKIT_VERSION = "1.1.3.dev0" COMMAND_NAME = "speckit.github.taskstoissues" COMMAND_FILE = EXT_DIR / "commands" / f"{COMMAND_NAME}.md" @@ -158,7 +157,7 @@ def test_manifest_validates(self): m = ExtensionManifest(EXT_DIR / "extension.yml") assert m.id == "github" - assert m.version == "1.0.2" + assert m.version == "1.0.3" assert [c["name"] for c in m.commands] == [COMMAND_NAME] def test_manifest_command_files_exist(self): @@ -194,6 +193,8 @@ def test_version_floor_covers_all_rendering_prerequisites(self): but auto-registered skills did not resolve ``__SPECKIT_COMMAND_*__`` tokens until 0.12.17 (#3544). Versions through 0.12.16 therefore accept the extension but leak a raw command token in skills mode. + Hook placeholders and dispatcher installation require 1.1.3 or later; + 1.1.2 still accepts the extension but cannot run its hook commands. """ from packaging.specifiers import SpecifierSet @@ -201,6 +202,7 @@ def test_version_floor_covers_all_rendering_prerequisites(self): spec = SpecifierSet(floor) assert "0.12.5" not in spec, floor assert "0.12.16" not in spec, floor + assert "1.1.2" not in spec, floor assert MIN_SPECKIT_VERSION in spec, floor # And the version the install tests use must satisfy it. assert INSTALL_SPECKIT_VERSION in spec, floor @@ -297,6 +299,78 @@ def test_install_copies_command_and_scripts(self, tmp_path: Path): for rel_path in SCRIPT_TWINS.values(): assert (installed / rel_path).is_file(), f"Missing script: {rel_path}" + @pytest.mark.parametrize("variant,folder,extension", [ + ("sh", "bash", "sh"), + ("ps", "powershell", "ps1"), + ("py", "python", "py"), + ]) + def test_install_and_upgrade_restore_shared_hook_dispatchers( + self, tmp_path: Path, variant: str, folder: str, extension: str, + ): + from specify_cli.extensions import ExtensionManager + + specify = tmp_path / ".specify" + specify.mkdir() + (specify / "init-options.json").write_text( + json.dumps({"ai": "copilot", "script": variant}), encoding="utf-8" + ) + core = tmp_path / ".github/agents/speckit.taskstoissues.agent.md" + core.parent.mkdir(parents=True) + core.write_text("core taskstoissues\n", encoding="utf-8") + scripts = specify / "scripts" / folder + pre = scripts / (f"pre_hooks.{extension}" if variant == "py" else f"pre-hooks.{extension}") + post = scripts / (f"post_hooks.{extension}" if variant == "py" else f"post-hooks.{extension}") + assert not pre.exists() and not post.exists() + + manager = ExtensionManager(tmp_path) + manager.install_from_directory(EXT_DIR, INSTALL_SPECKIT_VERSION) + assert pre.is_file() and post.is_file() + command = tmp_path / ".github" / "agents" / f"{COMMAND_NAME}.agent.md" + assert f".specify/scripts/{folder}/{pre.name}" in command.read_text(encoding="utf-8") + + pre.write_text(pre.read_text(encoding="utf-8") + "\n# customized\n", encoding="utf-8") + post.unlink() + manager.install_from_directory(EXT_DIR, INSTALL_SPECKIT_VERSION, force=True) + assert pre.read_text(encoding="utf-8").endswith("# customized\n") + assert post.is_file() + assert f".specify/scripts/{folder}/{post.name}" in command.read_text(encoding="utf-8") + + def test_install_rejects_symlinked_hook_dispatcher(self, tmp_path: Path): + from specify_cli.extensions import ExtensionError, ExtensionManager + + specify = tmp_path / ".specify" + specify.mkdir() + (specify / "init-options.json").write_text( + json.dumps({"ai": "copilot", "script": "sh"}), encoding="utf-8" + ) + scripts = tmp_path / ".specify/scripts/bash" + scripts.mkdir(parents=True) + outside = tmp_path / "outside.sh" + outside.write_text("keep\n", encoding="utf-8") + try: + (scripts / "pre-hooks.sh").symlink_to(outside) + except (OSError, NotImplementedError): + pytest.skip("symlinks unavailable") + manager = ExtensionManager(tmp_path) + with pytest.raises(ExtensionError, match="missing hook dispatcher"): + manager.install_from_directory(EXT_DIR, INSTALL_SPECKIT_VERSION) + assert outside.read_text(encoding="utf-8") == "keep\n" + assert not manager.registry.is_installed("github") + + def test_install_rejects_unsupported_hook_runtime_before_writing(self, tmp_path: Path): + from specify_cli.extensions import ExtensionError, ExtensionManager + + specify = tmp_path / ".specify" + specify.mkdir() + (specify / "init-options.json").write_text( + json.dumps({"ai": "copilot", "script": "invalid"}), encoding="utf-8" + ) + manager = ExtensionManager(tmp_path) + with pytest.raises(ExtensionError, match="Unsupported hook dispatcher script type"): + manager.install_from_directory(EXT_DIR, INSTALL_SPECKIT_VERSION) + assert not (specify / "scripts").exists() + assert not manager.registry.is_installed("github") + def test_remove_uninstalls_cleanly(self, tmp_path: Path): from specify_cli.extensions import ExtensionManager @@ -429,8 +503,15 @@ def test_rendered_command_points_at_a_script_that_ships( assert expected in content # The rendered path must resolve to a file the extension ships. assert (project / expected).is_file() - # And it must not have been rewritten into the core script tree. - assert ".specify/scripts/" not in content + # The extension's own script stays local; hook dispatchers are shared. + for phase in ("pre", "post"): + hook_path = { + "sh": f".specify/scripts/bash/{phase}-hooks.sh", + "ps": f".specify/scripts/powershell/{phase}-hooks.ps1", + "py": f".specify/scripts/python/{phase}_hooks.py", + }[variant] + assert hook_path in content + assert hook_path.replace(".specify/scripts/", ".specify/extensions/github/scripts/") not in content @pytest.mark.parametrize("agent", EXTENSION_REGISTRAR_AGENTS) def test_every_extension_registrar_integration_renders_the_command( @@ -522,6 +603,9 @@ def test_rendered_skill_points_at_a_script_that_ships(self, tmp_path: Path): expected = f".specify/extensions/github/{SCRIPT_TWINS['sh']}" assert expected in content assert (project / expected).is_file() + for phase in ("pre", "post"): + assert f".specify/scripts/bash/{phase}-hooks.sh" in content + assert f".specify/extensions/github/scripts/bash/{phase}-hooks.sh" not in content # -- Behaviour parity with the core command ----------------------------------- @@ -529,10 +613,10 @@ def test_rendered_skill_points_at_a_script_that_ships(self, tmp_path: Path): class TestCommandBody: def test_preserves_the_hook_contract(self): - """The hook keys are literal strings read out of extensions.yml.""" + """The extension dispatches the same before/after events as core.""" body = COMMAND_FILE.read_text(encoding="utf-8") - assert "hooks.before_taskstoissues" in body - assert "hooks.after_taskstoissues" in body + assert "{PRE_HOOK_SCRIPT} taskstoissues" in body + assert "{POST_HOOK_SCRIPT} taskstoissues" in body def test_git_extension_hooks_still_target_those_keys(self): """The live consumers of the hook contract keep firing.""" diff --git a/tests/extensions/github/test_taskstoissues_deprecation.py b/tests/extensions/github/test_taskstoissues_deprecation.py index b18324863b..bde771f709 100644 --- a/tests/extensions/github/test_taskstoissues_deprecation.py +++ b/tests/extensions/github/test_taskstoissues_deprecation.py @@ -56,8 +56,10 @@ def test_recommended_extension_is_not_deprecated(): @pytest.mark.parametrize("command", [CORE, EXTENSION], ids=["core", "github"]) def test_taskstoissues_hooks_and_safety_contracts_remain(command): text = command.read_text(encoding="utf-8") - assert "hooks.before_taskstoissues" in text - assert "hooks.after_taskstoissues" in text + assert "{PRE_HOOK_SCRIPT} taskstoissues" in text + assert "{POST_HOOK_SCRIPT} taskstoissues" in text + assert text.index("{PRE_HOOK_SCRIPT}") < text.index("{POST_HOOK_SCRIPT}") + assert text.count("including mandatory hooks") == 2 assert "ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL" in text assert "**Fetch existing issues for deduplication**" in text assert "**Skip** any task whose ID is already present" in text diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index b3681ee8fc..c3da61ee00 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -341,9 +341,8 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md" assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") - assert "replace dots" in content, ( - "speckit-specify should have dot-to-hyphen hook note" - ) + assert ".specify/scripts/bash/pre-hooks.sh specify" in content + assert ".specify/scripts/bash/post-hooks.sh specify" in content rel_key = ".agents/skills/speckit-specify/SKILL.md" assert rel_key in m.files assert ( diff --git a/tests/integrations/test_integration_alquimia.py b/tests/integrations/test_integration_alquimia.py index 9e25e14cc1..45b4ecab39 100644 --- a/tests/integrations/test_integration_alquimia.py +++ b/tests/integrations/test_integration_alquimia.py @@ -789,9 +789,8 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") # specify.md has hook sections - assert "replace dots" in content, ( - "speckit-specify should have dot-to-hyphen hook note" - ) + assert ".specify/scripts/bash/pre-hooks.sh specify" in content + assert ".specify/scripts/bash/post-hooks.sh specify" in content def test_hook_note_not_in_skills_without_hooks(self, tmp_path): """Skills without hook sections should not get the note.""" diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index e94f58c6ff..605599b595 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -248,6 +248,10 @@ def _expected_files(self, script_variant: str) -> list[str]: for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", "resolve-template.ps1", "setup-plan.ps1", "setup-tasks.ps1"]: files.append(f".specify/scripts/powershell/{name}") + files += [ + f".specify/scripts/{'bash' if script_variant == 'sh' else 'powershell'}/{phase}-hooks.{'sh' if script_variant == 'sh' else 'ps1'}" + for phase in ("pre", "post") + ] for name in ["checklist-template.md", "constitution-template.md", "plan-template.md", diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 015152be01..6eaf75b5b6 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -256,19 +256,19 @@ def test_command_refs_use_hyphen_separator(self, tmp_path): ) def test_hook_sections_explain_dotted_command_conversion(self, tmp_path): - """Generated skills with hook sections must explain dotted command conversion.""" + """Generated skills delegate hook selection to the shared scripts.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) specify_skill = i.skills_dest(tmp_path) / "speckit-specify" / "SKILL.md" assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") - assert "replace dots" in content, ( - "speckit-specify should explain dotted hook command conversion" - ) - assert content.count("replace dots") == content.count( - "- For each executable hook, output the following" - ) + assert ".specify/scripts/bash/pre-hooks.sh specify" in content + assert ".specify/scripts/bash/post-hooks.sh specify" in content + assert content.count("replace dots (`.`) with hyphens (`-`)") == 2 + from specify_cli.integrations.base import get_invocation_prefix + prefix = get_invocation_prefix(self.KEY, True) + assert f"`{prefix}speckit-git-commit`" in content def test_hook_note_injected_for_each_instruction_independently(self): """Existing hook notes should not suppress later missing notes.""" @@ -507,6 +507,10 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/powershell/setup-plan.ps1", ".specify/scripts/powershell/setup-tasks.ps1", ] + files += [ + f".specify/scripts/{'bash' if script_variant == 'sh' else 'powershell'}/{phase}-hooks.{'sh' if script_variant == 'sh' else 'ps1'}" + for phase in ("pre", "post") + ] # Templates files += [ ".specify/templates/checklist-template.md", diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index 1b1bb18807..a8357a14b4 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -510,6 +510,10 @@ def _expected_files(self, script_variant: str) -> list[str]: "setup-tasks.ps1", ]: files.append(f".specify/scripts/powershell/{name}") + files += [ + f".specify/scripts/{'bash' if script_variant == 'sh' else 'powershell'}/{phase}-hooks.{'sh' if script_variant == 'sh' else 'ps1'}" + for phase in ("pre", "post") + ] for name in [ "checklist-template.md", diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index 01914c5988..99c9e544ab 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -424,6 +424,10 @@ def _expected_files(self, script_variant: str) -> list[str]: "setup-tasks.ps1", ]: files.append(f".specify/scripts/powershell/{name}") + files += [ + f".specify/scripts/{'bash' if script_variant == 'sh' else 'powershell'}/{phase}-hooks.{'sh' if script_variant == 'sh' else 'ps1'}" + for phase in ("pre", "post") + ] for name in [ "checklist-template.md", diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 8671b7c445..c20168d432 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -736,9 +736,8 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") # specify.md has hook sections - assert "replace dots" in content, ( - "speckit-specify should have dot-to-hyphen hook note" - ) + assert ".specify/scripts/bash/pre-hooks.sh specify" in content + assert ".specify/scripts/bash/post-hooks.sh specify" in content def test_hook_note_not_in_skills_without_hooks(self, tmp_path): """Skills without hook sections should not get the note.""" diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index d7350a9633..2714f281f1 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -90,6 +90,17 @@ def test_cline_hook_instruction_injection(self): assert "replace dots (`.`) with hyphens (`-`)" in injected assert "- For each executable hook, output the following:" in injected + def test_cline_scripted_hook_instruction_injection(self): + cline = get_integration("cline") + instruction = ( + "Run `.specify/scripts/bash/pre-hooks.sh plan` from the project root. " + "For each returned hook in order: invoke mandatory commands in this agent.\n" + ) + injected = cline._inject_hook_command_note(instruction) + assert "`/speckit-git-commit`" in injected + assert injected.count("replace dots (`.`)") == 1 + assert cline._inject_hook_command_note(injected) == injected + def test_cline_hook_instruction_injection_no_trailing_newline(self): """Note must not collapse onto the instruction line when the instruction is the final line with no trailing newline. @@ -186,6 +197,8 @@ def test_setup_creates_files(self, tmp_path): specify_contents = specify_file.read_text(encoding="utf-8") assert "/speckit-plan" in specify_contents assert "/speckit.plan" not in specify_contents + assert specify_contents.count("replace dots (`.`) with hyphens (`-`)") == 2 + assert "`/speckit-git-commit`" in specify_contents def test_integration_flag_creates_files(self, tmp_path): from typer.testing import CliRunner @@ -260,6 +273,10 @@ def _expected_files(self, script_variant: str) -> list[str]: "setup-tasks.ps1", ]: files.append(f".specify/scripts/powershell/{name}") + files += [ + f".specify/scripts/{'bash' if script_variant == 'sh' else 'powershell'}/{phase}-hooks.{'sh' if script_variant == 'sh' else 'ps1'}" + for phase in ("pre", "post") + ] for name in [ "checklist-template.md", diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py index 9a4dc5442b..0a5818ca02 100644 --- a/tests/integrations/test_integration_codex.py +++ b/tests/integrations/test_integration_codex.py @@ -94,10 +94,8 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md" assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") - assert "replace dots" in content, ( - "speckit-specify should have dot-to-hyphen hook note" - ) - assert "constructing command invocations" in content + assert ".specify/scripts/bash/pre-hooks.sh specify" in content + assert ".specify/scripts/bash/post-hooks.sh specify" in content assert "constructing slash commands" not in content def test_hook_note_not_in_skills_without_hooks(self): diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index cb882a71a8..3df7600db4 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -309,6 +309,8 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/post-hooks.sh", + ".specify/scripts/bash/pre-hooks.sh", ".specify/scripts/bash/resolve-template.sh", ".specify/scripts/bash/setup-plan.sh", ".specify/scripts/bash/setup-tasks.sh", @@ -374,6 +376,8 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/post-hooks.ps1", + ".specify/scripts/powershell/pre-hooks.ps1", ".specify/scripts/powershell/resolve-template.ps1", ".specify/scripts/powershell/setup-plan.ps1", ".specify/scripts/powershell/setup-tasks.ps1", @@ -685,13 +689,14 @@ def test_skills_do_not_have_mode_in_frontmatter(self, tmp_path): fm = yaml.safe_load(parts[1]) assert "mode" not in fm, f"{f} frontmatter must not contain unsupported 'mode' field" - def test_skills_hook_sections_explain_dotted_command_conversion(self, tmp_path): - """Generated skills with hook sections should include shared hook guidance.""" + def test_skills_hook_sections_use_script_dispatch(self, tmp_path): + """Generated skills use the installed hook dispatcher instead of YAML instructions.""" copilot = self._make_copilot() self._setup_skills(copilot, tmp_path) specify_skill = tmp_path / ".github" / "skills" / "speckit-specify" / "SKILL.md" content = specify_skill.read_text(encoding="utf-8") - assert "replace dots" in content + assert ".specify/scripts/bash/pre-hooks.sh specify" in content + assert ".specify/scripts/bash/post-hooks.sh specify" in content # -- Template processing ---------------------------------------------- @@ -887,6 +892,8 @@ def test_complete_file_inventory_skills_sh(self, tmp_path): ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/post-hooks.sh", + ".specify/scripts/bash/pre-hooks.sh", ".specify/scripts/bash/resolve-template.sh", ".specify/scripts/bash/setup-plan.sh", ".specify/scripts/bash/setup-tasks.sh", diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index c2eabfcaa7..a1ce7be199 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -2432,10 +2432,8 @@ def test_skill_content_has_hook_command_note(self, tmp_path): ) assert constitution_skill.exists() content = constitution_skill.read_text(encoding="utf-8") - assert ( - "replace dots (`.`) with hyphens (`-`)" in content - ), "generic --skills output is missing the hook-invocation note" - assert "`speckit.git.commit` → `/speckit-git-commit`" in content + assert ".specify/scripts/bash/pre-hooks.sh constitution" in content + assert ".specify/scripts/bash/post-hooks.sh constitution" in content def test_skills_flag_false_keeps_flat_markdown(self, tmp_path): """Without --skills, behavior is unchanged: flat speckit..md files.""" @@ -2615,6 +2613,8 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/post-hooks.sh", + ".specify/scripts/bash/pre-hooks.sh", ".specify/scripts/bash/resolve-template.sh", ".specify/scripts/bash/setup-plan.sh", ".specify/scripts/bash/setup-tasks.sh", @@ -2763,6 +2763,8 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/post-hooks.ps1", + ".specify/scripts/powershell/pre-hooks.ps1", ".specify/scripts/powershell/resolve-template.ps1", ".specify/scripts/powershell/setup-plan.ps1", ".specify/scripts/powershell/setup-tasks.ps1", diff --git a/tests/integrations/test_integration_hermes.py b/tests/integrations/test_integration_hermes.py index b2106050b7..0b618957b7 100644 --- a/tests/integrations/test_integration_hermes.py +++ b/tests/integrations/test_integration_hermes.py @@ -203,12 +203,8 @@ def test_hook_sections_explain_dotted_command_conversion(self, tmp_path, monkeyp specify_skill = home / ".hermes" / "skills" / "speckit-specify" / "SKILL.md" assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") - assert "replace dots" in content, ( - "speckit-specify should explain dotted hook command conversion" - ) - assert content.count("replace dots") == content.count( - "- For each executable hook, output the following" - ) + assert ".specify/scripts/bash/pre-hooks.sh specify" in content + assert ".specify/scripts/bash/post-hooks.sh specify" in content def test_complete_file_inventory_sh(self, tmp_path, monkeypatch): """Override: Hermes init produces no local SKILL.md files, diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py index a6234ba734..b55c736045 100644 --- a/tests/integrations/test_integration_junie.py +++ b/tests/integrations/test_integration_junie.py @@ -93,6 +93,29 @@ def test_junie_hook_instruction_injection(self): assert "replace dots (`.`) with hyphens (`-`)" in injected assert "- For each executable hook, output the following:" in injected + def test_junie_scripted_hook_instruction_injection(self): + junie = get_integration("junie") + instruction = ( + "Run `.specify/scripts/bash/pre-hooks.sh plan` from the project root. " + "For each returned hook in order: invoke mandatory commands in this agent.\n" + ) + injected = junie._inject_hook_command_note(instruction) + assert "`/speckit-git-commit`" in injected + assert injected.count("replace dots (`.`)") == 1 + assert junie._inject_hook_command_note(injected) == injected + + def test_junie_scripted_hook_note_not_suppressed_by_earlier_note(self): + junie = get_integration("junie") + first = junie._inject_hook_command_note( + "- For each executable hook, output the following:\n" + ) + second = ( + "Run `.specify/scripts/bash/post-hooks.sh plan` from the project root. " + "For each returned hook in order: invoke mandatory commands in this agent.\n" + ) + injected = junie._inject_hook_command_note(first + second) + assert injected.count("replace dots (`.`)") == 2 + def test_junie_hook_instruction_injection_no_trailing_newline(self): """Note must not collapse onto the instruction line when the instruction is the final line with no trailing newline. @@ -136,6 +159,8 @@ def test_setup_creates_files(self, tmp_path): specify_contents = specify_file.read_text(encoding="utf-8") assert "/speckit-plan" in specify_contents assert "/speckit.plan" not in specify_contents + assert specify_contents.count("replace dots (`.`) with hyphens (`-`)") == 2 + assert "`/speckit-git-commit`" in specify_contents def test_integration_flag_creates_files(self, tmp_path): from typer.testing import CliRunner @@ -210,6 +235,10 @@ def _expected_files(self, script_variant: str) -> list[str]: "setup-tasks.ps1", ]: files.append(f".specify/scripts/powershell/{name}") + files += [ + f".specify/scripts/{'bash' if script_variant == 'sh' else 'powershell'}/{phase}-hooks.{'sh' if script_variant == 'sh' else 'ps1'}" + for phase in ("pre", "post") + ] for name in [ "checklist-template.md", diff --git a/tests/specify_cli/extensions/test_command_add.py b/tests/specify_cli/extensions/test_command_add.py index cddcd6bf21..6a0f208a97 100644 --- a/tests/specify_cli/extensions/test_command_add.py +++ b/tests/specify_cli/extensions/test_command_add.py @@ -38,6 +38,191 @@ class TestExtensionAddCLI: """CLI tests for ``specify extension add``.""" + def test_add_git_materializes_hook_projection(self, project_dir): + git_source = Path(__file__).parents[3] / "extensions" / "git" + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["extension", "add", str(git_source), "--dev"], catch_exceptions=True + ) + assert result.exit_code == 0, result.output + cache = project_dir / ".specify" / "hook-dispatch" + assert (cache / "source.yml").read_bytes() == ( + project_dir / ".specify" / "extensions.yml" + ).read_bytes() + projected = json.loads((cache / "before_plan.json").read_text(encoding="utf-8")) + assert projected["event"] == "before_plan" + assert [hook["extension"] for hook in projected["hooks"]] == ["git"] + + @pytest.mark.parametrize("preferred,available", [ + ("sh", "py"), ("py", "sh"), ("sh", "ps"), + ]) + def test_add_installs_dispatchers_for_selected_command_variant( + self, extension_dir, project_dir, preferred, available + ): + (project_dir / ".specify/init-options.json").write_text( + json.dumps({"ai": "copilot", "script": preferred}), encoding="utf-8" + ) + (project_dir / ".github/agents").mkdir(parents=True) + (extension_dir / "commands/hello.md").write_text( + "---\n" + "description: Test variant fallback\n" + "scripts:\n" + f" {available}: .specify/scripts/{available}/some-script {{ARGS}}\n" + "---\n\n" + "{PRE_HOOK_SCRIPT}\n" + "{POST_HOOK_SCRIPT}\n", + encoding="utf-8", + ) + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["extension", "add", str(extension_dir), "--dev"], catch_exceptions=True + ) + + assert result.exit_code == 0, result.output + directory = {"sh": "bash", "ps": "powershell", "py": "python"}[available] + suffix = {"sh": "-hooks.sh", "ps": "-hooks.ps1", "py": "_hooks.py"}[available] + rendered = (project_dir / ".github/agents/speckit.test-ext.hello.agent.md").read_text( + encoding="utf-8" + ) + for phase in ("pre", "post"): + relative = f".specify/scripts/{directory}/{phase}{suffix}" + assert relative in rendered + assert (project_dir / relative).is_file() + + def test_add_installs_dispatchers_for_multiple_command_variants( + self, extension_dir, project_dir + ): + (project_dir / ".specify/init-options.json").write_text( + json.dumps({"ai": "copilot", "script": "sh"}), encoding="utf-8" + ) + (project_dir / ".github/agents").mkdir(parents=True) + manifest = extension_dir / "extension.yml" + payload = yaml.safe_load(manifest.read_text(encoding="utf-8")) + payload["provides"]["commands"].append({ + "name": "speckit.test-ext.other", + "file": "commands/other.md", + "description": "Second command", + }) + manifest.write_text(yaml.safe_dump(payload), encoding="utf-8") + for name, variant in (("hello", "py"), ("other", "ps")): + (extension_dir / f"commands/{name}.md").write_text( + f"---\nscripts:\n {variant}: run {{ARGS}}\n---\n\n" + "{PRE_HOOK_SCRIPT}\n{POST_HOOK_SCRIPT}\n", encoding="utf-8" + ) + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["extension", "add", str(extension_dir), "--dev"], catch_exceptions=True + ) + + assert result.exit_code == 0, result.output + for name, variant, directory, suffix in ( + ("hello", "py", "python", "_hooks.py"), + ("other", "ps", "powershell", "-hooks.ps1"), + ): + rendered = (project_dir / f".github/agents/speckit.test-ext.{name}.agent.md").read_text( + encoding="utf-8" + ) + for phase in ("pre", "post"): + relative = f".specify/scripts/{directory}/{phase}{suffix}" + assert relative in rendered + assert (project_dir / relative).is_file() + + def test_add_rejects_unrunnable_hook_variant_before_install( + self, extension_dir, project_dir + ): + (project_dir / ".specify/init-options.json").write_text( + json.dumps({"ai": "copilot", "script": "sh"}), encoding="utf-8" + ) + (extension_dir / "commands/hello.md").write_text( + "---\nscripts:\n unsupported: run\n---\n\n{PRE_HOOK_SCRIPT}\n", + encoding="utf-8", + ) + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["extension", "add", str(extension_dir), "--dev"], catch_exceptions=True + ) + + assert result.exit_code != 0 + assert "No runnable script variant" in result.output + assert not ExtensionManager(project_dir).registry.is_installed("test-ext") + assert not (project_dir / ".specify/extensions/test-ext").exists() + assert not (project_dir / ".specify/extensions.yml").exists() + + @pytest.mark.parametrize("existing", [ + "hooks: [broken]\n", + "hooks:\n before_plan:\n - extension: orphan\n", + ]) + def test_add_git_rejects_invalid_existing_hooks(self, project_dir, existing): + git_source = Path(__file__).parents[3] / "extensions" / "git" + config = project_dir / ".specify" / "extensions.yml" + config.write_text(existing, encoding="utf-8") + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["extension", "add", str(git_source), "--dev"], catch_exceptions=True + ) + assert result.exit_code != 0 + assert "invalid hooks" in result.output.lower() + assert config.read_text(encoding="utf-8") == existing + assert not ExtensionManager(project_dir).registry.is_installed("git") + + @pytest.mark.parametrize("invalid", [ + {"optional": "false"}, + {"condition": False}, + {"prompt": False}, + {"description": False}, + {"enabled": "true"}, + {"prompt": "invalid\0prompt"}, + ]) + def test_add_rejects_invalid_manifest_hooks_before_install( + self, extension_dir, project_dir, invalid + ): + from specify_cli.extensions import HookExecutor + + executor = HookExecutor(project_dir) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "existing", "command": "speckit.existing.run"}, + ]}}) + existing = executor.config_file.read_bytes() + manifest = extension_dir / "extension.yml" + payload = yaml.safe_load(manifest.read_text(encoding="utf-8")) + payload["hooks"]["after_tasks"].update(invalid) + manifest.write_text(yaml.safe_dump(payload), encoding="utf-8") + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["extension", "add", str(extension_dir), "--dev"], catch_exceptions=True + ) + + assert result.exit_code != 0 + assert executor.config_file.read_bytes() == existing + assert not ExtensionManager(project_dir).registry.is_installed("test-ext") + assert not (project_dir / ".specify/extensions/test-ext").exists() + assert not (project_dir / ".github/agents/speckit.test-ext.hello.agent.md").exists() + assert json.loads( + (project_dir / ".specify/hook-dispatch/before_plan.json").read_text(encoding="utf-8") + )["hooks"][0]["extension"] == "existing" + + def test_add_rejects_invalid_manifest_hook_event_before_install( + self, extension_dir, project_dir + ): + manifest = extension_dir / "extension.yml" + payload = yaml.safe_load(manifest.read_text(encoding="utf-8")) + payload["hooks"]["invalid-event"] = payload["hooks"].pop("after_tasks") + manifest.write_text(yaml.safe_dump(payload), encoding="utf-8") + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["extension", "add", str(extension_dir), "--dev"], catch_exceptions=True + ) + + assert result.exit_code != 0 + assert not ExtensionManager(project_dir).registry.is_installed("test-ext") + assert not (project_dir / ".specify/extensions/test-ext").exists() + assert not (project_dir / ".specify/extensions.yml").exists() + def test_add_dev_links_copilot_agent_when_supported( self, extension_dir, project_dir, temp_dir ): diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 42d5ea0a6c..f388771270 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -6,9 +6,11 @@ import json # noqa: F401 import os # noqa: F401 import shutil # noqa: F401 +import subprocess from pathlib import Path # noqa: F401 import pytest # noqa: F401 +import yaml from specify_cli import app # noqa: F401 from tests.conftest import strip_ansi # noqa: F401 @@ -27,6 +29,85 @@ ) class TestIntegrationUpgradeDetailed: + @pytest.mark.parametrize("variant", ["sh", "ps"]) + def test_upgrade_migrates_wrapped_legacy_hook_config(self, tmp_path, variant): + project = _init_project(tmp_path, "copilot", integration_options="--commands") + config_file = project / ".specify" / "extensions.yml" + legacy = { + "installed": ["git"], + "settings": {"auto_execute_hooks": True}, + "hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", + "prompt": "A long legacy hook prompt containing multiple words " * 5} + ]}, + } + old_text = yaml.safe_dump(legacy, sort_keys=False) + assert "\n prompt containing" in old_text + config_file.write_text(old_text, encoding="utf-8") + + result = _run_in_project(project, [ + "integration", "upgrade", "copilot", "--script", variant, + ]) + assert result.exit_code == 0, result.output + assert yaml.safe_load(config_file.read_text(encoding="utf-8")) == legacy + + command = { + "sh": ["/bin/bash", str(project / ".specify/scripts/bash/pre-hooks.sh")], + "ps": ["pwsh", "-NoProfile", "-File", + str(project / ".specify/scripts/powershell/pre-hooks.ps1")], + }[variant] + if variant == "sh" and os.name == "nt": + from tests.conftest import _has_working_bash + + if not _has_working_bash(): + pytest.skip("working Git Bash not available on Windows") + command[0] = "bash" + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + run = subprocess.run(command + ["plan"], cwd=project, capture_output=True, text=True) + assert run.returncode == 0, run.stderr + assert json.loads(run.stdout)["hooks"][0]["prompt"] == legacy["hooks"]["before_plan"][0]["prompt"] + + def test_upgrade_does_not_overwrite_invalid_hook_config(self, tmp_path): + project = _init_project(tmp_path, "copilot", integration_options="--commands") + config_file = project / ".specify" / "extensions.yml" + invalid = "hooks: [\n" + config_file.write_text(invalid, encoding="utf-8") + + result = _run_in_project(project, ["integration", "upgrade", "copilot", "--script", "sh"]) + assert result.exit_code != 0 + assert "extensions.yml" in result.output + assert config_file.read_text(encoding="utf-8") == invalid + + def test_upgrade_does_not_write_symlinked_hook_config(self, tmp_path): + project = _init_project(tmp_path, "copilot", integration_options="--commands") + config_file = project / ".specify" / "extensions.yml" + external = tmp_path / "external.yml" + legacy = "hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n" + external.write_text(legacy, encoding="utf-8") + config_file.symlink_to(external) + + result = _run_in_project(project, ["integration", "upgrade", "copilot", "--script", "sh"]) + assert result.exit_code != 0 + assert "Refusing to migrate symlinked" in result.output + assert external.read_text(encoding="utf-8") == legacy + + def test_hook_config_migration_rejects_symlinked_parent(self, tmp_path): + from specify_cli.extensions import HookExecutor + + project = tmp_path / "project" + project.mkdir() + external = tmp_path / "external" + external.mkdir() + config = external / "extensions.yml" + legacy = "hooks:\n before_plan: []\n" + config.write_text(legacy, encoding="utf-8") + (project / ".specify").symlink_to(external, target_is_directory=True) + + with pytest.raises(ValueError, match="symlinked"): + HookExecutor(project).migrate_project_config() + assert config.read_text(encoding="utf-8") == legacy + def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path): project = _init_project(tmp_path, "claude") _write_invalid_manifest(project, "claude") diff --git a/tests/test_command_template_hooks.py b/tests/test_command_template_hooks.py index d1c7677bae..8594e961ce 100644 --- a/tests/test_command_template_hooks.py +++ b/tests/test_command_template_hooks.py @@ -1,80 +1,75 @@ -"""Command templates must not tell the agent to skip hook checking silently. +"""Core command hook dispatch is present in every rendered script variant.""" -Every core command template reads ``.specify/extensions.yml`` before and after -its main work, looking for ``hooks.before_*`` / ``hooks.after_*`` entries. A -manifest that could not be parsed used to be treated exactly like a manifest -with no hooks: the agent was told to "skip hook checking silently and continue -normally". A mandatory hook (``optional: false``, the kind the bundled ``git`` -extension registers) could therefore be disabled by a single malformed line, -and nothing would say so. - -These tests pin the replacement wording: an unreadable manifest is reported to -the user (the parser error, and the fact that no hooks were checked) before -the command continues. They read the templates as text on purpose: the -behaviour lives in the prompt, so the prompt is what must be checked. -""" - -import re from pathlib import Path import pytest -REPO_ROOT = Path(__file__).parent.parent -TEMPLATES_DIR = REPO_ROOT / "templates" / "commands" +from specify_cli.integrations.base import IntegrationBase +from specify_cli.agents import CommandRegistrar -_HOOK_KEY = re.compile(r"`hooks\.(before|after)_[a-z_]+`") -_PARSE_FAILURE_LINE = re.compile( - r"^.*If the YAML cannot be parsed or is invalid.*$", re.MULTILINE -) -_SILENT = "skip hook checking silently" -# Every clause of the replacement instruction, so that dropping any one of -# them from the templates fails the test: the manifest could not be read, the -# parser error is shown, no hooks were checked, mandatory hooks are named, and -# the command still continues afterwards. -_REPORTED = ( - "could not be read", - "include the parser error", - "no hooks were checked", - "including any mandatory (`optional: false`) hooks", - "then continue", +ROOT = Path(__file__).parent.parent +TEMPLATES = ROOT / "templates" / "commands" +NAMES = ( + "analyze", "checklist", "clarify", "constitution", "converge", + "implement", "plan", "specify", "tasks", "taskstoissues", ) -HOOK_TEMPLATES = sorted( - p.name - for p in TEMPLATES_DIR.glob("*.md") - if _HOOK_KEY.search(p.read_text(encoding="utf-8")) -) +def test_all_core_hook_templates_discovered(): + assert set(NAMES) <= {path.stem for path in TEMPLATES.glob("*.md")} -def test_hook_templates_discovered(): - # Guard: the glob must find the templates that read extensions.yml, - # otherwise the parametrized tests below would pass by vacuity. - assert {"specify.md", "plan.md", "tasks.md", "implement.md"} <= set( - HOOK_TEMPLATES - ) +@pytest.mark.parametrize("name", NAMES) +def test_both_boundaries_and_error_handling(name): + text = (TEMPLATES / f"{name}.md").read_text(encoding="utf-8") + assert f"{{PRE_HOOK_SCRIPT}} {name}" in text + assert f"{{POST_HOOK_SCRIPT}} {name}" in text + assert text.index("{PRE_HOOK_SCRIPT}") < text.index("{POST_HOOK_SCRIPT}") + assert text.count("no hooks were checked") == 2 + assert text.count("including mandatory hooks") == 2 + assert text.count("wait for completion") == 2 + assert text.count("without executing them automatically") == 2 + + +@pytest.mark.parametrize("name", NAMES) +@pytest.mark.parametrize("variant,dir,ext", [ + ("sh", "bash", "sh"), ("ps", "powershell", "ps1"), ("py", "python", "py"), +]) +def test_hook_scripts_render_and_exist(name, variant, dir, ext): + text = (TEMPLATES / f"{name}.md").read_text(encoding="utf-8") + result = IntegrationBase.process_template(text, "copilot", variant) + assert "{PRE_HOOK_SCRIPT}" not in result + assert "{POST_HOOK_SCRIPT}" not in result + for phase in ("pre", "post"): + path = f".specify/scripts/{dir}/{phase}{'-' if variant != 'py' else '_'}hooks.{ext}" + assert path in result + assert (ROOT / "scripts" / dir / f"{phase}{'-' if variant != 'py' else '_'}hooks.{ext}").is_file() -@pytest.mark.parametrize("name", HOOK_TEMPLATES) -def test_unreadable_manifest_is_never_skipped_silently(name: str): - text = (TEMPLATES_DIR / name).read_text(encoding="utf-8") - assert _SILENT not in text, ( - f"{name}: an unreadable .specify/extensions.yml may still be skipped " - "silently, which disables mandatory hooks without saying so" - ) +@pytest.mark.parametrize("name", NAMES) +def test_no_repeated_yaml_hook_instructions(name): + text = (TEMPLATES / f"{name}.md").read_text(encoding="utf-8") + assert "hooks.before_" not in text + assert "hooks.after_" not in text + assert "If the YAML cannot be parsed" not in text -@pytest.mark.parametrize("name", HOOK_TEMPLATES) -def test_every_parse_failure_line_reports_before_continuing(name: str): - text = (TEMPLATES_DIR / name).read_text(encoding="utf-8") - lines = _PARSE_FAILURE_LINE.findall(text) - # One line for the before-hook check, one for the after-hook check. - assert len(lines) >= 2, ( - f"{name}: expected a parse-failure instruction at both hook sites, " - f"found {len(lines)}" + +@pytest.mark.parametrize("variant,dir,ext", [ + ("sh", "bash", "sh"), ("ps", "powershell", "ps1"), ("py", "python", "py"), +]) +def test_preset_wrap_and_skill_registrar_resolve_hook_scripts(tmp_path, variant, dir, ext): + config = tmp_path / ".specify" + config.mkdir() + (config / "init-options.json").write_text( + f'{{"script": "{variant}"}}', encoding="utf-8" + ) + body = "{PRE_HOOK_SCRIPT} plan\n{POST_HOOK_SCRIPT} plan" + rendered = CommandRegistrar.resolve_skill_placeholders( + "copilot", + {"scripts": {variant: "scripts/python/setup_plan.py --json"}}, + body, tmp_path, ) - for line in lines: - for phrase in _REPORTED: - assert phrase in line, ( - f"{name}: parse-failure instruction does not tell the user " - f"{phrase!r}: {line.strip()}" - ) + assert "{PRE_HOOK_SCRIPT}" not in rendered + assert "{POST_HOOK_SCRIPT}" not in rendered + assert f".specify/scripts/{dir}/" in rendered + assert f"hooks.{ext}" in rendered diff --git a/tests/test_extensions.py b/tests/test_extensions.py index b512742389..dbdc7a4165 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -8165,7 +8165,7 @@ def test_register_hooks_empty_hooks_purge_survives_corrupt_entry(self, project_d ) config = executor.get_project_config() config["hooks"]["after_tasks"].append("corrupt-non-dict-entry") - executor.save_project_config(config) + executor.config_file.write_text(yaml.safe_dump(config), encoding="utf-8") executor.register_hooks(_StubManifest("ext-a", {})) diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py new file mode 100644 index 0000000000..264ece448c --- /dev/null +++ b/tests/test_hook_dispatch_scripts.py @@ -0,0 +1,1179 @@ +"""Positive and negative coverage for pre/post extension hook resolution.""" + +import json +import shutil +import os +import subprocess +import sys +import hashlib +import threading +from contextlib import contextmanager +from functools import lru_cache +from pathlib import Path + +import pytest +import yaml +from tests.conftest import _has_working_bash + +ROOT = Path(__file__).parent.parent +PYTHON = ROOT / "scripts" / "python" + + +@lru_cache(maxsize=1) +def _bash_available(): + return _has_working_bash() + + +def bash_script(path): + if os.name == "nt": + if not _bash_available(): + pytest.skip("working Git Bash not available on Windows") + return ["bash", str(path)] + return [str(path)] + + +def run_hook(tmp_path, phase="pre", name="plan", variant="py", extra_env=None): + scripts = { + "py": [sys.executable, str(PYTHON / f"{phase}_hooks.py")], + "sh": [str(ROOT / "scripts" / "bash" / f"{phase}-hooks.sh")], + "ps": ["pwsh", "-NoProfile", "-File", str(ROOT / "scripts" / "powershell" / f"{phase}-hooks.ps1")], + } + if variant == "sh": + scripts["sh"] = bash_script(ROOT / "scripts" / "bash" / f"{phase}-hooks.sh") + env = os.environ.copy() + env["SPECKIT_PYTHON_EXECUTABLE"] = sys.executable + if extra_env: + env.update(extra_env) + result = subprocess.run( + scripts[variant] + [name], cwd=tmp_path, env=env, + capture_output=True, text=True, encoding="utf-8", check=False, + ) + return result.returncode, json.loads(result.stdout) + + +def write_config(tmp_path, text): + path = tmp_path / ".specify" / "extensions.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("variant", ["py", "sh", "ps"]) +def test_absent_config_returns_empty(tmp_path, phase, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 0 + assert data == {"event": f"{'before' if phase == 'pre' else 'after'}_plan", "hooks": []} + + +def test_hooks_sorted_and_filtered_with_stable_ties(tmp_path): + write_config(tmp_path, yaml.safe_dump({"hooks": {"before_plan": [ + {"extension": "late", "command": "speckit.late.run", "priority": 20, "optional": False}, + {"extension": "first", "command": "speckit.first.run", "priority": 5, "optional": False}, + {"extension": "second", "command": "speckit.second.run", "priority": "5", "prompt": "Run second?"}, + {"extension": "disabled", "command": "speckit.disabled.run", "enabled": False}, + {"extension": "conditional", "command": "speckit.conditional.run", "condition": "env.CI is set"}, + {"extension": "default", "command": "speckit.default.run"}, + ]}}, sort_keys=False)) + code, data = run_hook(tmp_path) + assert code == 0 + assert [h["extension"] for h in data["hooks"]] == ["first", "second", "default", "late"] + assert [h["priority"] for h in data["hooks"]] == [5, 5, 10, 20] + assert data["hooks"][0]["optional"] is False + assert data["hooks"][1]["prompt"] == "Run second?" + assert data["hooks"][2]["optional"] is True + assert data["hooks"][2]["description"] == "" + + +def test_post_rereads_config_and_only_returns_matching_event(tmp_path): + config = write_config(tmp_path, "hooks:\n after_plan: []\n") + assert run_hook(tmp_path, "post")[1]["hooks"] == [] + config.write_text("hooks:\n after_plan:\n - extension: git\n command: speckit.git.commit\n", encoding="utf-8") + assert run_hook(tmp_path, "post")[1]["hooks"][0]["command"] == "speckit.git.commit" + assert run_hook(tmp_path, "pre")[1]["hooks"] == [] + + +@pytest.mark.parametrize("text,part", [ + ("hooks: [wrong]", "hooks mapping"), + ("[]", "hooks mapping"), + ("hooks:\n before_plan: wrong\n", "must be a list"), + ("hooks:\n before_plan:\n - wrong\n", "must be a mapping"), + ("hooks:\n before_plan:\n - extension: git\n", "needs extension and command"), + ("hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n enabled: 'false'\n", "enabled must be a boolean"), + ("hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n optional: 'false'\n", "optional must be a boolean"), + ("hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n prompt: [not, text]\n", "prompt must be a string"), + ("hooks: [\n", "Could not read"), +]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_invalid_config_reports_error(tmp_path, text, part, phase): + if phase == "post": + text = text.replace("before_plan", "after_plan") + write_config(tmp_path, text) + code, data = run_hook(tmp_path, phase) + assert code == 1 + assert part in data["error"] + assert data["hooks"] == [] + + +@pytest.mark.parametrize("variant", ["py", "sh", "ps"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_legacy_empty_hooks_map_rejects_nested_entries(tmp_path, variant, phase): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + event = f"{'before' if phase == 'pre' else 'after'}_plan" + write_config(tmp_path, ( + f"hooks: {{}}\n {event}:\n" + " - extension: git\n command: speckit.git.commit\n" + )) + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert data["error"] + + +@pytest.mark.parametrize("variant", ["py", "sh", "ps"]) +def test_legacy_empty_hooks_map_without_entries_is_valid(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, "hooks: {}\nsettings:\n auto_execute_hooks: true\n") + assert run_hook(tmp_path, variant=variant) == ( + 0, {"event": "before_plan", "hooks": []} + ) + + +@pytest.mark.parametrize("variant", ["py", "sh", "ps"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("invalid_event", ["invalid_event", "before_Bad"]) +def test_legacy_rejects_invalid_event_even_when_other_hooks_are_valid( + tmp_path, variant, phase, invalid_event +): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + requested = f"{'before' if phase == 'pre' else 'after'}_plan" + write_config(tmp_path, yaml.safe_dump({"hooks": { + requested: [{"extension": "git", "command": "speckit.git.commit"}], + invalid_event: [], + }}, sort_keys=False)) + + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "hook event" in data["error"] + + +@pytest.mark.parametrize("priority,expected", [ + (False, 10), (0, 10), ("bad", 10), ("2", 2), (2.8, 2), +]) +def test_legacy_priority_normalization(tmp_path, priority, expected): + write_config(tmp_path, yaml.safe_dump({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "priority": priority} + ]}})) + code, data = run_hook(tmp_path) + assert code == 0 + assert data["hooks"][0]["priority"] == expected + + +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_invalid_command_name_does_not_read_config(tmp_path, phase): + write_config(tmp_path, "hooks: [\n") + code, data = run_hook(tmp_path, phase, "../../bad") + assert code == 1 + assert "Invalid hook event" in data["error"] + assert data["event"] == f"{'before' if phase == 'pre' else 'after'}_../../bad" + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_installed_mapping_metadata_does_not_hide_hooks(tmp_path, variant, phase): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + event = f"{'before' if phase == 'pre' else 'after'}_plan" + HookExecutor(tmp_path).save_project_config({ + "installed": [{"id": "git", "version": "1.0.0"}, "agent-context"], + "hooks": {event: [{"extension": "git", "command": "speckit.git.commit"}]}, + }) + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path, phase)[1] + assert data["hooks"][0]["command"] == "speckit.git.commit" + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_documented_indented_installed_mapping(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, ( + 'installed:\n - id: jira\n version: "1.0.0" # Pin to specific version\n' + 'hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n' + )) + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data["hooks"][0]["command"] == "speckit.git.commit" + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_malformed_installed_mapping_rejected(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, ( + "installed:\n- id: jira\n version: [unfinished\n" + "hooks:\n before_plan: []\n" + )) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("missing", ["extension", "command"]) +def test_non_target_event_requires_identifiers(tmp_path, variant, phase, missing): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + field = "command: speckit.git.commit" if missing == "extension" else "extension: git" + write_config(tmp_path, ( + "hooks:\n" + f" {'after' if phase == 'pre' else 'before'}_plan:\n" + f" - {field}\n" + )) + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 1 + assert "needs extension and command" in data["error"] + assert data["hooks"] == [] + + +def test_empty_condition_and_enabled_default_still_execute(tmp_path): + write_config(tmp_path, "hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n condition: ''\n") + assert len(run_hook(tmp_path)[1]["hooks"]) == 1 + + +def test_missing_yaml_dependency_is_reported_not_silently_skipped(tmp_path): + write_config(tmp_path, "hooks:\n before_plan: []\n") + result = subprocess.run( + [sys.executable, "-I", "-S", str(PYTHON / "pre_hooks.py"), "plan"], + cwd=tmp_path, capture_output=True, text=True, + ) + assert result.returncode == 1 + assert json.loads(result.stdout) == { + "event": "before_plan", "hooks": [], + "error": "PyYAML is required to read .specify/extensions.yml", + } + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_materialized_projection_resolves_without_yaml_or_native_parsing(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "late", "command": "speckit.late.run", "priority": 20, "prompt": "😀"}, + {"extension": "first", "command": "speckit.first.run", "priority": 5}, + ]}}) + projected = tmp_path / ".specify/hook-dispatch/before_plan.json" + assert projected.is_file() + assert (tmp_path / ".specify/hook-dispatch/events.txt").read_bytes() == b"before_plan\n" + assert b"\r" not in (tmp_path / ".specify/hook-dispatch/events.txt.sha256").read_bytes() + assert b"\r" not in (tmp_path / ".specify/hook-dispatch/before_plan.sha256").read_bytes() + assert [h["extension"] for h in json.loads(projected.read_text(encoding="utf-8"))["hooks"]] == [ + "first", "late", + ] + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data["hooks"][1]["prompt"] == "😀" + assert data == run_hook(tmp_path)[1] + if variant == "py": + result = subprocess.run( + [sys.executable, "-I", "-S", str(PYTHON / "pre_hooks.py"), "plan"], + cwd=tmp_path, capture_output=True, text=True, encoding="utf-8", check=False, + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == data + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("conversion", ["cache", "all"]) +def test_projection_accepts_git_crlf_checkout(tmp_path, variant, phase, conversion): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + event = f"{'before' if phase == 'pre' else 'after'}_plan" + HookExecutor(tmp_path).save_project_config({"hooks": {event: [ + {"extension": "git", "command": "speckit.git.commit", "optional": False}, + ]}}) + cache = tmp_path / ".specify/hook-dispatch" + converted = list(cache.iterdir()) + if conversion == "all": + converted.append(tmp_path / ".specify/extensions.yml") + for path in converted: + data = path.read_bytes() + path.write_bytes(data.replace(b"\n", b"\r\n")) + + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 0, data + assert [entry["command"] for entry in data["hooks"]] == ["speckit.git.commit"] + assert data["hooks"][0]["optional"] is False + assert run_hook(tmp_path, phase, name="tasks", variant=variant) == ( + 0, {"event": f"{'before' if phase == 'pre' else 'after'}_tasks", "hooks": []} + ) + + (cache / f"{event}.json").unlink() + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "incomplete" in data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("corruption", ["projection", "index"]) +def test_crlf_projection_still_rejects_corrupt_content(tmp_path, variant, corruption): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + cache = tmp_path / ".specify/hook-dispatch" + for path in cache.iterdir(): + path.write_bytes(path.read_bytes().replace(b"\n", b"\r\n")) + if corruption == "projection": + projection = cache / "before_plan.json" + projection.write_bytes(projection.read_bytes().replace( + b"speckit.git.commit", b"speckit.bad.commit" + )) + else: + index = cache / "events.txt" + index.write_bytes(index.read_bytes().replace(b"before_plan", b"after_plan")) + + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "invalid" in data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_projection_rejects_stale_yaml_and_recovers_after_refresh(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + executor = HookExecutor(tmp_path) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "old", "command": "speckit.old.run"}, + ]}}) + config = tmp_path / ".specify/extensions.yml" + config.write_text( + "hooks:\n before_plan:\n - extension: new\n command: speckit.new.run\n", + encoding="utf-8", + ) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "stale" in data["error"] + executor.migrate_project_config() + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert [hook["extension"] for hook in data["hooks"]] == ["new"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_projection_missing_entry_fails_instead_of_skipping_hook(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + (tmp_path / ".specify/hook-dispatch/before_plan.json").unlink() + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "incomplete" in data["error"] + assert run_hook(tmp_path, name="tasks", variant=variant)[1]["hooks"] == [] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_projection_missing_cache_fails_instead_of_using_legacy_yaml(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + (tmp_path / ".specify/hook-dispatch").rename(tmp_path / "removed-cache") + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "missing" in data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_projection_removes_old_event_after_config_save(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + executor = HookExecutor(tmp_path) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + executor.save_project_config({"hooks": {}}) + assert not (tmp_path / ".specify/hook-dispatch/before_plan.json").exists() + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data["hooks"] == [] + + +def test_invalid_projected_hook_cannot_replace_existing_projection(tmp_path): + from specify_cli.extensions import HookExecutor + + executor = HookExecutor(tmp_path) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + before = (tmp_path / ".specify/extensions.yml").read_bytes() + with pytest.raises(ValueError, match="condition must be a string or null"): + executor.save_project_config({"hooks": {"after_plan": [ + {"extension": "other", "command": "speckit.other.run", "condition": False}, + ]}}) + assert (tmp_path / ".specify/extensions.yml").read_bytes() == before + assert run_hook(tmp_path)[1]["hooks"][0]["extension"] == "git" + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("replacement", [ + b'{"event":"before_plan","hooks":[\n', + b'{"event":"before_tasks","hooks":[]}\n', + b'{"event":"before_plan","hooks":{}}\n', +]) +def test_corrupt_projection_reports_error(tmp_path, variant, replacement): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + cache = tmp_path / ".specify/hook-dispatch" + (cache / "before_plan.json").write_bytes(replacement) + (cache / "before_plan.sha256").write_text( + hashlib.sha256(replacement).hexdigest() + "\n", encoding="utf-8", + ) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "projection" in data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_projection_modified_without_new_digest_is_rejected(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + (tmp_path / ".specify/hook-dispatch/before_plan.json").write_text( + '{"event":"before_plan","hooks":[]}\n', encoding="utf-8", + ) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "invalid" in data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_truncated_event_index_cannot_hide_mandatory_hooks(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "optional": False}, + ]}}) + cache = tmp_path / ".specify/hook-dispatch" + (cache / "events.txt").write_bytes(b"") + (cache / "before_plan.json").unlink() + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "index" in data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_semantically_invalid_projected_hook_is_rejected(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + cache = tmp_path / ".specify/hook-dispatch" + payload = b'{"event":"before_plan","hooks":[{"extension":1}]}\n' + (cache / "before_plan.json").write_bytes(payload) + (cache / "before_plan.sha256").write_text( + hashlib.sha256(payload).hexdigest() + "\n", encoding="utf-8", + ) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "projection" in data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_read_during_projection_save_fails_then_recovers(tmp_path, monkeypatch, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + from specify_cli.integrations import _file_changes + + executor = HookExecutor(tmp_path) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "old", "command": "speckit.old.run"}, + ]}}) + writing = threading.Event() + resume = threading.Event() + failures = [] + original_write = _file_changes.write_bytes + + def paused_write(path, content): + if path.name == "before_plan.json": + writing.set() + if not resume.wait(10): + raise TimeoutError("Writer was not released") + return original_write(path, content) + + monkeypatch.setattr(_file_changes, "write_bytes", paused_write) + + def save(): + try: + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "new", "command": "speckit.new.run"}, + ]}}) + except (OSError, ValueError, RuntimeError) as exc: + failures.append(exc) + + writer = threading.Thread(target=save) + writer.start() + try: + assert writing.wait(10) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "projection" in data["error"] + finally: + resume.set() + writer.join(10) + assert not writer.is_alive() and not failures + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data["hooks"][0]["extension"] == "new" + + +def test_python_reader_rejects_save_after_reading_old_projection(tmp_path, monkeypatch): + from scripts.python import pre_hooks + from specify_cli.extensions import HookExecutor + + executor = HookExecutor(tmp_path) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "old", "command": "speckit.old.run"}, + ]}}) + original_read = Path.read_bytes + replaced = False + + def read_and_replace(path): + nonlocal replaced + payload = original_read(path) + if path.name == "before_plan.json" and not replaced: + replaced = True + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "new", "command": "speckit.new.run"}, + ]}}) + return payload + + monkeypatch.setattr(Path, "read_bytes", read_and_replace) + with pytest.raises(ValueError, match="changed during resolution"): + pre_hooks.resolve("before_plan", tmp_path) + assert replaced + + +def test_concurrent_projection_saves_publish_matching_configuration(tmp_path, monkeypatch): + from specify_cli.extensions import HookExecutor + from specify_cli.integrations import _file_changes + from specify_cli import shared_infra + + first_writing = threading.Event() + resume_first = threading.Event() + second_attempted = threading.Event() + second_done = threading.Event() + failures = [] + original_write = _file_changes.write_bytes + original_lock = shared_infra._exclusive_project_lock + + def paused_write(path, content): + if path.name == "before_plan.json" and threading.current_thread().name == "first": + first_writing.set() + if not resume_first.wait(10): + raise TimeoutError("First writer was not released") + return original_write(path, content) + + @contextmanager + def observed_lock(*args, **kwargs): + if threading.current_thread().name == "second": + second_attempted.set() + with original_lock(*args, **kwargs): + yield + + monkeypatch.setattr(_file_changes, "write_bytes", paused_write) + monkeypatch.setattr(shared_infra, "_exclusive_project_lock", observed_lock) + + def save(name): + try: + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": name, "command": f"speckit.{name}.run"}, + ]}}) + except Exception as exc: + failures.append(exc) + finally: + if name == "second": + second_done.set() + + first = threading.Thread(target=save, args=("first",), name="first") + second = threading.Thread(target=save, args=("second",), name="second") + first.start() + try: + assert first_writing.wait(10) + second.start() + assert second_attempted.wait(10) + assert not second_done.wait(0.2) + finally: + resume_first.set() + first.join(10) + if second.ident is not None: + second.join(10) + assert not first.is_alive() and not second.is_alive() + assert not failures + assert HookExecutor(tmp_path).get_project_config()["hooks"]["before_plan"][0]["extension"] == "second" + assert run_hook(tmp_path)[1]["hooks"][0]["extension"] == "second" + + +def test_no_yaml_dependency_needed_for_absent_config(tmp_path): + result = subprocess.run( + [sys.executable, "-I", "-S", str(PYTHON / "pre_hooks.py"), "plan"], + cwd=tmp_path, capture_output=True, text=True, + ) + assert result.returncode == 0 + assert json.loads(result.stdout)["hooks"] == [] + + +@pytest.mark.parametrize("priority", [float("inf"), float("-inf"), float("nan")]) +def test_nonfinite_priority_does_not_crash(tmp_path, priority): + write_config(tmp_path, yaml.safe_dump({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "priority": priority} + ]}})) + code, data = run_hook(tmp_path) + assert code == 0 + assert data["hooks"][0]["priority"] == 10 + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_initialized_project_installs_runnable_dispatchers(tmp_path, variant, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", + "--integration-options", "--commands", "--script", variant, + ], catch_exceptions=False) + assert result.exit_code == 0, result.output + if variant != "py": + assert not (project / ".specify/scripts/python/pre_hooks.py").exists() + assert not (project / ".specify/scripts/python/post_hooks.py").exists() + write_config(project, "hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n") + script_type = variant + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + scripts = { + "sh": [str(project / ".specify/scripts/bash/pre-hooks.sh")], + "ps": ["pwsh", "-NoProfile", "-File", str(project / ".specify/scripts/powershell/pre-hooks.ps1")], + "py": [sys.executable, str(project / ".specify/scripts/python/pre_hooks.py")], + } + if variant == "sh": + scripts["sh"] = bash_script(project / ".specify/scripts/bash/pre-hooks.sh") + env = os.environ.copy() + env["SPECKIT_PYTHON_EXECUTABLE"] = sys.executable + run = subprocess.run(scripts[script_type] + ["plan"], cwd=project, env=env, capture_output=True, text=True) + assert run.returncode == 0, run.stderr + assert json.loads(run.stdout)["hooks"][0]["command"] == "speckit.git.commit" + + +@pytest.mark.parametrize("variant", ["sh", "ps"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_shell_variants_match_python(tmp_path, variant, phase): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, f"hooks:\n {'before' if phase == 'pre' else 'after'}_plan:\n - extension: git\n command: speckit.git.commit\n") + assert run_hook(tmp_path, phase, variant=variant) == run_hook(tmp_path, phase) + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_canonical_cli_output_has_identical_order_and_metadata(tmp_path, variant, phase): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + event = f"{'before' if phase == 'pre' else 'after'}_plan" + HookExecutor(tmp_path).save_project_config({ + "installed": ["git", "agent-context"], + "settings": {"auto_execute_hooks": True}, + "hooks": { + "before_tasks": [{"extension": "elsewhere", "command": "speckit.elsewhere.run"}], + event: [ + {"extension": "late", "command": "speckit.late.run", "priority": 20, + "prompt": "Don\'t commit: yet?", "description": 'Quote "tab\t' + "x" * 85}, + {"extension": "first", "command": "speckit.first.run", "priority": 5, + "optional": False, "condition": None}, + {"extension": "second", "command": "speckit.second.run", "priority": 5}, + {"extension": "disabled", "command": "speckit.disabled.run", "enabled": False}, + {"extension": "condition", "command": "speckit.condition.run", + "condition": "env.CI is set"}, + ], + }, + }) + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path, phase)[1] + assert [item["extension"] for item in data["hooks"]] == ["first", "second", "late"] + assert data["hooks"][-1]["prompt"] == "Don't commit: yet?" + assert data["hooks"][-1]["description"] == 'Quote "tab\t' + "x" * 85 + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_canonical_cli_writer_keeps_multiline_prompt_on_one_yaml_line(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "prompt": "first\nsecond"} + ]}}) + config = (tmp_path / ".specify/extensions.yml").read_text(encoding="utf-8") + assert 'prompt: "first\\nsecond"' in config + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data["hooks"][0]["prompt"] == "first\nsecond" + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_canonical_backslash_escape_matches_python(tmp_path, variant, phase): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + event = f"{'before' if phase == 'pre' else 'after'}_plan" + prompt = "Path C:\\workspace\nkeep \\ literal" + HookExecutor(tmp_path).save_project_config({ + "hooks": {event: [ + {"extension": "git", "command": "speckit.git.commit", "prompt": prompt} + ]} + }) + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path, phase)[1] + assert data["hooks"][0]["prompt"] == prompt + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_legacy_yaml_escaped_slash_matches_python(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, ( + 'hooks:\n before_plan:\n' + ' - extension: git\n command: speckit.git.commit\n' + ' prompt: "a\\/b"\n' + )) + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path)[1] + assert data["hooks"][0]["prompt"] == "a/b" + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("field,value", [ + ("extension", "false"), ("command", "123"), ("extension", "yes"), + ("command", "0x10"), ("extension", "null"), ("command", "1.2"), + ("extension", "2026-10-08"), ("command", ".nan"), +]) +def test_native_identifiers_reject_implicitly_typed_yaml(tmp_path, variant, phase, field, value): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + event = f"{'before' if phase == 'pre' else 'after'}_plan" + write_config( + tmp_path, + f"hooks:\n {event}:\n - extension: git\n" + f" command: speckit.git.commit\n {field}: {value}\n", + ) + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 1 + assert "needs extension and command" in data["error"] + assert data["hooks"] == [] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_quoted_numeric_identifier_remains_string(tmp_path, variant, phase): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + event = f"{'before' if phase == 'pre' else 'after'}_plan" + write_config(tmp_path, f"hooks:\n {event}:\n - extension: '123'\n command: 'false'\n") + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path, phase)[1] + + +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_system_bash_resolves_unquoted_identifiers(tmp_path, phase): + if os.name == "nt": + pytest.skip("system /bin/bash is only available on POSIX") + event = f"{'before' if phase == 'pre' else 'after'}_plan" + write_config(tmp_path, f"hooks:\n {event}:\n - extension: git\n command: speckit.git.commit\n") + result = subprocess.run( + ["/bin/bash", str(ROOT / "scripts/bash" / f"{phase}-hooks.sh"), "plan"], + cwd=tmp_path, capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["hooks"][0]["command"] == "speckit.git.commit" + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("value", ["false", "true", "0"]) +def test_non_string_condition_fails_consistently(tmp_path, variant, phase, value): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + event = f"{'before' if phase == 'pre' else 'after'}_plan" + write_config(tmp_path, f"hooks:\n {event}:\n - extension: git\n" + f" command: speckit.git.commit\n condition: {value}\n") + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 1 + assert "condition must be a string or null" in data["error"] + assert data["hooks"] == [] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_quoted_condition_remains_a_string(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, "hooks:\n before_plan:\n - extension: git\n" + " command: speckit.git.commit\n condition: 'false'\n") + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data["hooks"] == [] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("value", ["null", "Null", "NULL", "~"]) +def test_null_condition_allows_hook(tmp_path, variant, value): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, "hooks:\n before_plan:\n - extension: git\n" + f" command: speckit.git.commit\n condition: {value}\n") + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path)[1] + assert len(data["hooks"]) == 1 + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_mixed_case_null_condition_remains_a_string(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, "hooks:\n before_plan:\n - extension: git\n" + " command: speckit.git.commit\n condition: nUll\n") + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path)[1] + assert data["hooks"] == [] + + +@pytest.mark.parametrize("variant", ["sh", "ps"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_native_resolvers_do_not_invoke_python(tmp_path, variant, phase): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + stub_dir = tmp_path / "stub-bin" + stub_dir.mkdir() + marker = tmp_path / "python-was-called" + for name in ("python", "python3", "py"): + stub = stub_dir / name + stub.write_text(f'#!/bin/sh\nprintf called >> "{marker}"\nexit 97\n', encoding="utf-8") + stub.chmod(0o755) + write_config(tmp_path, f"hooks:\n {'before' if phase == 'pre' else 'after'}_plan:\n - extension: git\n command: speckit.git.commit\n") + code, data = run_hook(tmp_path, phase, variant=variant, extra_env={ + "PATH": f"{stub_dir}{os.pathsep}{os.environ['PATH']}", + "SPECKIT_PYTHON_EXECUTABLE": "python3", + }) + assert code == 0 + assert data["hooks"][0]["command"] == "speckit.git.commit" + assert not marker.exists() + + +@pytest.mark.parametrize("variant", ["sh", "ps"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("text", [ + "hooks:\n before_plan: [wrong]\n", + "hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n prompt: |\n multiline\n", + "hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n optional: 'false'\n", + "hooks: [\n", + "installed: [\nhooks: {}\n", + "settings:\n auto_execute_hooks: [\nhooks: {}\n", + "installed:\n- 'unfinished\nhooks: {}\n", + "hooks: {}\nhooks: {}\n", + "hooks:\n before_plan: []\n before_plan: []\n", + "settings:\n auto_execute_hooks: true\n bad indentation: nope\nhooks: {}\n", +]) +def test_native_parsers_reject_unsupported_or_invalid_yaml(tmp_path, variant, phase, text): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + if phase == "post": + text = text.replace("before_plan", "after_plan") + write_config(tmp_path, text) + code, data = run_hook(tmp_path, phase=phase, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("field", [ + "extension: 'unterminated", "extension: ''", 'command: ""', + "optional: 'false'", "condition: false", +]) +def test_non_target_event_fields_are_validated(tmp_path, variant, phase, field): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + event = f"{'before' if phase == 'pre' else 'after'}_tasks" + write_config(tmp_path, f"hooks:\n {event}:\n - extension: git\n" + f" command: speckit.git.commit\n {field}\n") + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps"]) +def test_native_invalid_event_is_valid_json(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + code, data = run_hook(tmp_path, variant=variant, name='bad"name') + assert code == 1 + assert "Invalid hook event" in data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("priority,expected", [ + ("5", 5), (False, 10), (0, 10), ("invalid", 10), (2.8, 2), +]) +def test_priority_normalization_matches_across_runtimes(tmp_path, variant, priority, expected): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, yaml.safe_dump({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "priority": priority} + ]}})) + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data["hooks"][0]["priority"] == expected + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("priority,expected", [ + (2147483647, 2147483647), + (2147483648, 10), + (2147483647.9, 2147483647), + (10**16, 10), + ("00000000000000000005", 5), + ("2.8", 10), +]) +def test_priority_range_and_coercion_parity(tmp_path, variant, priority, expected): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, yaml.safe_dump({"hooks": {"before_plan": [ + {"extension": "high", "command": "speckit.high.run", "priority": priority}, + {"extension": "default", "command": "speckit.default.run"}, + ]}}, sort_keys=False)) + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path)[1] + assert [hook["extension"] for hook in data["hooks"]] == ( + ["default", "high"] if expected > 10 else ["high", "default"] + ) + assert next(hook for hook in data["hooks"] if hook["extension"] == "high")["priority"] == expected + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("raw,expected", [ + ("2_0", 20), ("0x14", 20), ("2.0e+1", 20), + ("2.", 2), ("2.e+1", 20), ("+2.", 2), + ("0b10100", 20), ("024", 20), ("1:02", 62), + ("+0x14", 20), ("+024", 20), ("1:2", 62), + ("+1:2", 62), ("01:02", 10), ("0:2", 10), + ("0X14", 10), ("0B10100", 10), + ("'0x14'", 10), ("'2_0'", 20), + ("2.0e+99", 10), ("0xGG", 10), +]) +def test_yaml_numeric_priority_parity(tmp_path, variant, raw, expected): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, ( + "hooks:\n before_plan:\n" + " - extension: high\n command: speckit.high.run\n" + f" priority: {raw}\n" + " - extension: default\n command: speckit.default.run\n" + )) + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path)[1] + assert next(hook for hook in data["hooks"] if hook["extension"] == "high")["priority"] == expected + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("value", [ + "\a", "\v", "\x01", "\x1b", "\x85", "\xa0", "\u2028", "\u2029", + "\ufeff", "😀", "😀\nmore", +]) +def test_canonical_writer_control_escape_parity(tmp_path, variant, value): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "prompt": f"before{value}after"}, + ]}}) + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path)[1] + assert data["hooks"][0]["prompt"] == f"before{value}after" + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_nul_in_existing_hook_configuration_is_rejected(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, ( + 'hooks:\n before_plan:\n' + ' - extension: git\n command: speckit.git.commit\n' + ' prompt: "before\\0after"\n' + )) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "NUL" in data["error"] or "Unsupported YAML escape" in data["error"] + + +def test_canonical_writer_rejects_nul_without_touching_config(tmp_path): + from specify_cli.extensions import HookExecutor + + executor = HookExecutor(tmp_path) + config = write_config(tmp_path, "hooks: {}\n") + with pytest.raises(ValueError, match="NUL"): + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "prompt": "before\0after"} + ]}}) + assert config.read_text(encoding="utf-8") == "hooks: {}\n" + + +@pytest.mark.parametrize("symlinked", ["config", "directory"]) +def test_direct_save_rejects_symlinks_without_writing_outside_project( + tmp_path, symlinked +): + from specify_cli.extensions import HookExecutor + + project = tmp_path / "project" + project.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / "extensions.yml").write_text("outside remains unchanged\n", encoding="utf-8") + specify = project / ".specify" + try: + if symlinked == "config": + specify.mkdir() + (specify / "extensions.yml").symlink_to(outside / "extensions.yml") + else: + specify.symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("Symlinks are unavailable") + + with pytest.raises((ValueError, OSError), match="symlink"): + HookExecutor(project).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + + assert (outside / "extensions.yml").read_text(encoding="utf-8") == ( + "outside remains unchanged\n" + ) + assert not (outside / "hook-dispatch").exists() + + +def test_direct_save_symlink_preserves_existing_projection(tmp_path): + from specify_cli.extensions import HookExecutor + + project = tmp_path / "project" + project.mkdir() + executor = HookExecutor(project) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "old", "command": "speckit.old.run"}, + ]}}) + cache = project / ".specify/hook-dispatch" + original = {path.name: path.read_bytes() for path in cache.iterdir()} + outside = tmp_path / "outside.yml" + outside.write_text("untouched\n", encoding="utf-8") + executor.config_file.unlink() + try: + executor.config_file.symlink_to(outside) + except (OSError, NotImplementedError): + pytest.skip("Symlinks are unavailable") + + with pytest.raises(ValueError, match="symlinked"): + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "new", "command": "speckit.new.run"}, + ]}}) + + assert executor.config_file.is_symlink() + assert outside.read_text(encoding="utf-8") == "untouched\n" + assert {path.name: path.read_bytes() for path in cache.iterdir()} == original + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("value", [ + "JSvjEuqRsqs$tn]X", "N1Y'dombB*Afj9e;SO}^0^S'", "prefix{suffix", +]) +def test_canonical_writer_quotes_native_reserved_scalar_chars(tmp_path, variant, value): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "prompt": value}, + ]}}) + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path)[1] + assert data["hooks"][0]["prompt"] == value