_install_workflow_from_catalog is new in this PR and has seven failure
branches after the mkdir/download step, each independently rmtree'ing
workflow_dir: redirect-to-non-HTTPS rejection, a generic download
exception, invalid downloaded YAML, a validate_workflow failure, a
workflow-id/catalog-key mismatch, a version mismatch, and (fixed in the
prior commit) a registry.add() OSError. Only the last one had been
special-cased to spare a prior working install on reinstall; the other
six still unconditionally deleted the whole directory, so re-adding an
already-installed catalog workflow and hitting any of those six earlier
failures destroyed the working install even though nothing about it had
actually changed.
Replaced all seven ad hoc rmtree call sites with a single local
_cleanup_failed_install() helper that closes over the existed_before /
prior_workflow_bytes captured once at the top of the function: restore
the prior workflow.yml for a reinstall, or rmtree only a directory that
this attempt itself created. Every failure branch now calls this one
helper, so the fix is structural rather than duplicated, and every
existing error message/exit code is unchanged -- only the cleanup
performed before each message is different.
Added a parametrized regression test covering the four early-failure
trigger points reachable from plain workflow add (redirect rejection,
download exception, invalid YAML, ID mismatch): each installs a catalog
workflow, re-adds it while forcing that specific failure, and asserts a
clean error plus the original workflow.yml surviving byte-for-byte.
Confirmed red against the unfixed code (all four raised FileNotFoundError
reading the deleted file) before applying the helper, green after.
Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
_install_workflow_from_catalog's final registry.add() failure handler
unconditionally rmtree'd workflow_dir. That's safe for a brand-new
install, but plain `workflow add <catalog-id>` also allows re-adding an
already-installed workflow, downloading the new version over the
existing directory first. If registry.add() then failed to save, the
unconditional rmtree deleted the prior working install while the
registry (after its own rollback) still reported it installed -- data
loss with no way back. workflow_update already avoids this via an outer
backup/restore around this function, but plain add has no such caller.
Fix mirrors _validate_and_install_local's existed-before/backup-aware
handling: capture whether workflow_dir existed and back up its
workflow.yml bytes before any download write, then on a registry.add()
OSError, restore those bytes for a reinstall or rmtree only a
brand-new directory. Only one file (workflow.yml) is ever written by
this path, so no further per-file bookkeeping is needed.
Added a failing-first regression: install a catalog workflow, re-add it
with a simulated registry save OSError, and assert a clean error, the
original workflow.yml restored byte-for-byte, and the registry still
reporting the original version installed. Confirmed red (prior file
deleted) before the fix, green after.
Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Critical: WorkflowRegistry.remove() deleted the in-memory entry then
called save() with no rollback, unlike add(). Combined with
workflow_remove deleting the workflow directory before calling
registry.remove(), a save failure permanently destroyed the workflow's
files, left the on-disk registry still claiming it installed, and
surfaced a raw unhandled OSError with no CLI message.
- WorkflowRegistry.remove() now rolls back the in-memory entry on a
save() OSError, mirroring add()'s existing rollback pattern.
- workflow_remove persists the registry removal (registry.remove(),
wrapped in try/except OSError -> clean escaped message) before
deleting any files, so a save failure never touches the workflow
directory.
Important sibling paths: workflow add (local/--dev/--from and catalog),
enable, and disable all called registry.add() without catching its
deliberate OSError, so a save failure surfaced either an orphaned
install directory (fresh local/catalog installs) or a raw/unhandled
exception with no clean CLI output.
- _validate_and_install_local (backs local/--dev/--from) now removes
the freshly created directory on a fresh install, or restores the
prior workflow.yml bytes on a reinstall-over-existing-local install,
before raising a clean escaped error.
- _install_workflow_from_catalog wraps the final registry.add() using
the function's own established convention (rmtree the just-downloaded
workflow_dir, then a clean escaped error) -- workflow_update's
existing backup/restore around this function is unaffected.
- workflow_enable/workflow_disable catch registry.add()'s OSError and
print a clean escaped message instead of leaking the exception.
Added failing-first tests proving each behavior (registry-unit rollback
test, CLI-level remove/add/enable/disable save-failure tests
parametrized where they share one root cause), all confirmed red before
the fix and green after.
Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- workflow add's plain local-path fallback (no --dev) checked wf_file.exists()
before installing, so a directory literally named workflow.yml passed the
guard and _validate_and_install_local() leaked an uncaught
IsADirectoryError instead of the documented CLI error. Use is_file(),
matching the --dev branch's existing guard.
- WorkflowRegistry._load() treated any OSError while reading an existing
registry the same as corrupted JSON, resetting to an empty in-memory
registry. A later save() would then silently persist that empty state via
os.replace, discarding every previously installed workflow entry. Track a
_load_error flag on OSError-during-read and have save() refuse to write
when it is set, so a transient I/O failure can no longer overwrite intact
data on disk.
- docs/reference/workflows.md: document `--from <url>` with its value
placeholder, matching extensions.md and presets.md.
Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
All three workflow download sites (add --from, catalog install, step
install) passed no redirect_validator to open_url, so an HTTPS URL
redirecting to cleartext HTTP issued the insecure request before the
post-hoc geturl() check reported it. Shared validator now rejects
non-HTTPS redirects (loopback HTTP allowed) pre-follow, matching the
preset download path.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- WorkflowRegistry now mirrors StepRegistry: _load refuses symlinked
parents/registry file and normalizes a non-dict workflows field;
save() rejects symlinked paths before writing.
- workflow add --dev requires workflow.yml to be a regular file so a
directory named workflow.yml gets the documented CLI error instead of
an uncaught IsADirectoryError.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- save() now uses tempfile.mkstemp in the workflows dir (matching the
engine's atomic writer), so a pre-created symlink at a predictable
.tmp path cannot redirect the write and concurrent processes cannot
collide.
- The direct-path disabled guard derives the owning project from the
resolved file path instead of the caller's cwd, so running an
installed workflow's YAML from outside the project still refuses when
disabled.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- save() wrote the registry with open('w'), so a failed dump truncated
the file and the next load reset every entry. Write to a sibling temp
file and os.replace into place.
- workflow update no longer claims all workflows are up to date when
some targets were skipped; it reports checked-only status with a
skipped count.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A corrupted-but-parseable registry entry (e.g. a string value) crashed
WorkflowRegistry.add with AttributeError on existing.get. Guard the
non-dict case while still restoring the original raw value on rollback.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- A truthy non-string catalog url (e.g. 123) reached urlparse and raised
AttributeError, escaping the clean error path; validate it is a string.
- enable/disable mutated the live registry entry before add(), so add's
rollback snapshot captured the already-toggled object; pass a fresh
mapping instead.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- WorkflowRegistry.add now rolls back its in-memory mutation when save()
raises, so a later successful save cannot persist metadata for a
failed update alongside the restored YAML backup.
- workflow run uses the same truthiness check for 'enabled' as list and
disable, so malformed values like 0 or null refuse to run.
- workflow update reports 'No workflows were eligible for update' when
every target was skipped instead of claiming all are up to date.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
'workflow add foo --from ""' fell through 'from_url or ...' to a
catalog install. Distinguish None from empty string so explicit values
stay on the URL-validation path and fail closed.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Running the installed copy's YAML directly (specify workflow run
.specify/workflows/align-wf/workflow.yml) skipped the registry check.
File sources resolving inside .specify/workflows/<id>/ now map back to
the workflow ID and refuse to run while disabled.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Path-equivalent spellings like "align-wf/" previously bypassed the
registry disabled check because the engine normalizes the path while the
registry matches the raw string. workflow run now validates non-file
sources against the workflow ID pattern before lookup.
Also updates docs/reference/workflows.md with --dev/--from install
options, update/enable/disable commands, and the search --author flag.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The update path never verified the downloaded workflow carries the catalog
version that triggered the update, so a stale or misconfigured URL could
report success while leaving the old version installed or downgrading it.
Pass the expected version into the install helper and fail the update when
the downloaded definition does not match.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
urlparse raises ValueError on e.g. an unbalanced IPv6 literal before the
invalid-URL branch is reached; on workflow update that also bypassed the
per-workflow handler and aborted the whole command. Convert the parse
failure into a clean error so add fails cleanly and update skips just the
affected workflow.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Covers the last few review threads not yet addressed:
- Escape yaml.YAMLError text in the local workflow add install path
(matches the already-escaped download/catalog paths).
- Escape the non---dev local directory fallback's "No workflow.yml
found in <path>" message (the --dev branch already escaped it).
- Escape the redirected final_url in the --from non-HTTPS redirect
error (IPv6 literals like http://[::1]/... are legal and contain
brackets).
- Escape the "Downloaded workflow is invalid" exception message in
_install_workflow_from_catalog, matching the sibling catalog-install
exception handler a few lines above it.
Adds regression tests for each in TestWorkflowCliAlignment, following
the existing escaping-test pattern in this class.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Transient FS errors (perms, disk full) from backup read or write no
longer abort the whole update run. The restore is wrapped in its own
try/except so a failed write only warns, and the offending workflow
is reported via 'Failed to update' like other per-workflow failures.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
workflow list now escapes id/name/version/description before printing,
matching how extensions render user-editable fields. The catalog install
helper computes safe_wf_id once and uses it for every early error path
plus the final failure message.
workflow update wraps _safe_workflow_id_dir and the backup read inside
the try/except typer.Exit block so an unsafe id in a corrupted registry
fails that one workflow and the rest continue.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The two id-mismatch error paths interpolated repr() into Rich markup, so
a stray bracket in a user typo could be parsed as markup. Route both
through rich.markup.escape.
`workflow add <source> --from <url>` also validated the source only
after downloading. Validate it up front so a URL/path/typo fails
without a network fetch.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
workflow list now skips non-dict registry entries with a warning instead
of crashing, matching update/enable/disable. The broad except in
_install_workflow_from_catalog no longer swallows typer.Exit, so precise
errors like the non-HTTPS redirect message are not duplicated.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds the missing workflow commands and flags so the workflow CLI
matches the extension/preset pattern: add --dev and --from, search
--author, update, enable and disable. Disabled workflows are blocked
from running and marked in list output.
Fixes#2342
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(agent-context): discover nested plan.md in scoped layouts (#3024)
The agent-context updater only looked for plan.md one level deep
(specs/*/plan.md), so scoped layouts created via SPECIFY_FEATURE_DIRECTORY
(specs/<scope>/<feature>/plan.md) were never picked up and no plan
reference was written into the context file.
Recurse into specs/ in both the bash (rglob) and PowerShell (-Recurse)
scripts. In the PowerShell script, also replace
[System.IO.Path]::GetRelativePath, which is .NET Core 2.1+ only and throws
under Windows PowerShell 5.1 (.NET Framework); the exception was swallowed
by the surrounding try/catch, leaving the plan path empty on 5.1 even when
a plan was found. Compute the project-relative path by stripping the root
prefix instead.
Add regression tests for both scripts covering nested discovery.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(agent-context): guard mtime plan discovery against symlink escape
Address Copilot review feedback on #3301:
- bash updater: the mtime fallback filtered candidates lexically via
relative_to() on the *unresolved* path, so a plan reached through a
specs/ symlink pointing outside the project could be selected and emit
an in-project-looking path. Resolve each candidate and keep only those
whose resolved path stays under root before picking the newest.
- test: the nested-plan PowerShell regression targets a Windows
PowerShell 5.1 (.NET Framework) failure mode, but ran whatever
POWERSHELL resolved to (prefers pwsh). Prefer powershell.exe on Windows
so the 5.1-only compat fix is actually exercised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agent-context): note recursive plan.md discovery in update command
Auto-detection now recurses (`specs/**/plan.md`) to support nested scoped layouts created via SPECIFY_FEATURE_DIRECTORY (#3024). The update command doc still described the old one-level `specs/*/plan.md` glob, which could mislead users troubleshooting plan detection. Addresses Copilot review feedback on PR #3301.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
find_entries_for_url did (urlparse(url).hostname or "").lower() unguarded. a
malformed authority (e.g. an unterminated ipv6 bracket "https://[::1") makes
urlparse/hostname raise ValueError, so instead of the empty list the function
already returns for a host-less url, a raw ValueError leaked out of the shared
http client (build_request / open_url call this before any url validation).
no auth entry can match such a url, so treat it like the host-less case and
return no matches. added a regression test over an unterminated bracket and a
bracketed non-ip host; confirmed it fails on the pre-fix code.
CatalogStackBase._validate_catalog_url read parsed.hostname, which raises
ValueError on a malformed authority (e.g. an unclosed ipv6 bracket
"https://[::1"). every other reject path raises the class catalog error, so
the raw ValueError leaked to the caller. wrap the parse + hostname access and
convert ValueError to the normal error via cls._error.
twin of the bundler fix in adapters._validate_remote_url.
adapters._validate_remote_url reads parsed.hostname, which raises ValueError
on a malformed authority (e.g. an unclosed ipv6 bracket https://[::1). the
function's contract is to raise BundlerError for any bad url - every other
reject path does - so the raw ValueError leaked to the caller and crashed the
fetch instead of failing cleanly. wrap the parse and convert to BundlerError.
bundler sibling of #3369, which fixed the cli extension/preset/workflow add
paths but not this validator. added a regression test that fails pre-fix.
* fix(workflows): validate scalar types before string operations in workflow validation
YAML parses unquoted scalars like version: 1.0 and id: 123 as
float/int, which crashed validate_workflow and workflow add with raw
tracebacks. Type-check id, name, version and step ids before regex
and string operations so these surface as validation errors. Accept
an unquoted schema_version: 1.0 instead of printing a self-identical
rejection message.
Fixes#3420
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(workflows): treat falsey non-strings as type errors, not missing fields
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(workflows): only accept schema_version 1.0 so the error message is accurate
The check also accepted "1" while the error said Expected '1.0'.
Unquoted YAML 1.0 still works via str(); plain 1 is now rejected with
the message that matches.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`-Number` defaults to 0, so the previous `-eq 0` / `-ne 0` checks could
not distinguish an unset flag from an explicit `-Number 0`: a user
requesting branch `000-...` was silently routed into auto-detection.
Switch both checks to `$PSBoundParameters.ContainsKey('Number')`, which
tests whether the flag was actually supplied — mirroring the bash twin's
empty-string sentinel (`[ -z "$BRANCH_NUMBER" ]` / `[ -n ... ]`).
Add a parity regression test to both TestCreateFeatureBash and
TestCreateFeaturePowerShell asserting `--number 0` / `-Number 0` yields
`000-zero`.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test_template_renders_python_invocation monkeypatches shutil.which to
return /usr/bin/python3, but on Windows resolve_python_interpreter guards
the which() result with a real _interpreter_runs subprocess probe (#3304).
The mocked /usr/bin/python3 path does not exist on a Windows runner, so the
probe fails, the resolver falls back to sys.executable (a ...python.exe
path), and the python3-anchored regex assertion fails.
Patch _interpreter_runs to return True in the _pin_interpreter fixture so
the resolved interpreter token stays python3 across all platforms, keeping
the #3304 production guard intact while making the assertion deterministic.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* feat(workflows): make shell step timeout configurable
The shell step hardcoded a 300s subprocess timeout, killing any
legitimate long-running QA command. Read an optional timeout field
(seconds, positive integer, default 300) and validate it.
Fixes#3327
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* style: multi-line timeout validation, assert status in default test
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Guard against unvalidated timeout in ShellStep.execute()
The engine does not auto-validate step config, so a string or null
timeout would reach subprocess.run() and crash the run with a
TypeError. Fall back to the 300s default for malformed values,
mirroring how the engine treats unvalidated continue_on_error.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: find plans in nested spec directories
The agent-context mtime fallback used a one-level specs/*/plan.md
glob, so scoped layouts (specs/<scope>/<feature>/plan.md via
SPECIFY_FEATURE_DIRECTORY) never matched and the SPECKIT block was
written without a plan path. Recurse in both script variants and
update the command doc wording.
Fixes#3024
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* refactor: pick newest plan with max(), align doc wording
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(templates): add py: lines to command templates' scripts frontmatter
Every templates/commands/*.md with a scripts: block now declares a py:
variant so --script py renders a Python invocation via the existing
interpreter-prefixing in process_template.
Fixes#3283
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: install scripts/python for the py script type
install_shared_infra mapped every non-sh script type to powershell, so
--script py rendered invocations pointing at files that were never
installed. Map py to the python variant dir and skip __pycache__
artifacts during the copy.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test: read templates with explicit utf-8 encoding
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: keep py: lines standalone, enforce script existence in tests
Drop the plan/tasks py: lines that referenced scripts shipping in the
core port (#3280); they move to that PR. Tests now assert every py:
line points at a script the repo ships, so a dangling reference can
never merge green.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(integrations): skip Windows Store python3 alias stub in resolve_python_interpreter
On stock Windows, python3 on PATH is the Microsoft Store App Execution
Alias stub: it exists but only prints an installer hint and exits
non-zero, so generated {SCRIPT} invocations for the py script type were
broken. Verify the found interpreter actually runs before accepting it,
on Windows only, mirroring the parse-success-not-availability approach
of #3312/#3320 for the sh scripts. POSIX keeps the plain existence
check. sys.executable remains the fallback and is always live.
Fixes#3383
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(integrations): probe interpreter isolated and without site, discard I/O
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test: pin POSIX platform in PATH-resolution tests
The tests fake shutil.which with POSIX paths; on Windows CI the real
sys.platform made the stub probe run against those fake paths and
fall through to sys.executable.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
yaml.safe_dump with default_style='"' replaces the hand-rolled quote
helpers in base.py and hermes, so newlines and control characters in
template descriptions round-trip instead of producing unparseable YAML.
Fixes#3391
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(workflows): apply chained expression filters left-to-right
The pipe-filter parser in `_evaluate_simple_expression` split the
expression only at the *first* top-level `|` and treated the whole
remainder as a single filter. So a filter chain like
`{{ inputs.rows | map('name') | join(', ') }}` handed
`map('name') | join(', ')` to one filter, where the `(\w+)\((.+)\)`
regex mangled it and raised `ValueError`.
This broke the canonical use of `map`: it returns a list, and `join`
is the only filter that renders a list to a string, so the two are
meant to be chained. Chaining was impossible for every registered
filter.
Split the pipe segments at the top level (quote/bracket aware, so a
literal `|` inside a quoted argument like `join(' | ')` is preserved)
and fold each filter over the running value. The single-filter logic
is extracted verbatim into `_apply_filter`, so all existing strict
handling (`from_json` arity, unsupported-form vs unknown-filter
messages) is unchanged and now applies to every link in the chain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(scripts): resolve invoke_separator by parse success, add awk fallback (#3304)
`get_invoke_separator` in common.sh selected its JSON parser by tool
*availability* (`command -v python3`) rather than parse *success*, and had
no text fallback after the python3 branch. On stock Windows + Git Bash —
no jq, and `python3` resolving to the Microsoft Store App Execution Alias
stub that passes `command -v` but exits 49 at runtime — it silently fell
back to "." even for `-`-separator integrations (e.g. forge, cline). The
observable result was wrong command hints in error messages, such as
`/speckit.plan` instead of `/speckit-plan`, in check-prerequisites.sh and
setup-tasks.sh.
This is the same existence-vs-runtime pattern fixed for the feature.json
parser in #3304's primary report; the reporter explicitly asked that other
python3 call sites be checked. The two remaining sites (resolve_template,
resolve_template_content) already fall through on failure and are unaffected.
- Restructure the jq -> python3 chain to fall through on parse failure,
gated on a `parsed` success flag rather than exclusive elif branches.
- Make the python3 branch signal failure (sys.exit(1)) instead of printing
"." so a stub failure falls through instead of being accepted.
- Add an awk text fallback (portable, no gawk-only whole-file slurp) that
reads the active integration key and its invoke_separator, handling both
pretty-printed (the written form) and compact JSON. Malformed input
safely defaults to ".".
Regression test simulates the broken-stub environment (jq + python3 stubs
that exit 49 on PATH) and asserts the `-` separator is still recovered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(scripts): close awk END block so invoke_separator fallback works
The awk text fallback in get_invoke_separator was missing the closing brace for its END block, so awk aborted with a syntax error on every invocation and the separator silently stayed at the default '.'. On environments with neither jq nor a working python3 (stock Windows + Git Bash, the exact case this fallback exists for) a '-'-separator integration like forge produced a wrong command hint. The bug escaped CI because the test that exercises it is gated on working bash and skips on the Windows dev shell.
Also harden the fallback per review: use the portable '[-.]' character class (a leading '-' inside '[]' can be read as an ill-defined range on some awk builds), and correct the test docstring, which said 'with no jq' though the helper installs a present-but-failing jq stub. Addresses Copilot review feedback on PR #3320.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(shared-infra): refresh_shared_templates preserves recovered user files
refresh_shared_templates skipped a shared template only when it was
untracked or modified, ignoring the manifest's is_recovered marker that
install_shared_infra already honors. So a pre-existing user template
(adopted via record_existing(recovered=True), hence tracked and
hash-unmodified) was silently overwritten with bundled content on
refresh — the exact data-loss class that #2918 fixed for
install_shared_infra. Add the is_recovered check to the skip predicate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(shared-infra): include recovered files in refresh skip warning
The skip predicate now also skips recovered (pre-existing user) files,
so the warning saying only 'modified or untracked' could mislead a user
into thinking they edited a file that was simply recorded as recovered.
Reword to 'modified, untracked, or preserved (recovered)'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(agents): resolve skill placeholders in Goose (yaml) command output
CommandRegistrar.register_commands resolves {SCRIPT}/__AGENT__ and the
$ARGUMENTS placeholder in the markdown and toml branches, but the yaml
branch (Goose recipes) called render_yaml_command directly, skipping
both. So extension/preset command bodies installed for Goose kept literal
{SCRIPT}, __AGENT__, and repo-relative script paths in the generated
.goose/recipes/*.yaml prompt. Mirror the markdown/toml branches: run
resolve_skill_placeholders + _convert_argument_placeholder on the body
before render_yaml_command.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(goose): assert positive placeholder replacements in recipe prompt
Per review: parse the generated recipe with yaml.safe_load and assert the
prompt contains the resolved values (.specify/scripts/, 'agent goose',
{{args}}), not merely that the literal tokens are absent — a wrong output
that happens to omit the exact strings would otherwise pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(bundler): enforce version pin on bundled preset/extension installs
The bundled install branch of _PresetKindManager/_ExtensionKindManager
called install_from_directory and returned before _assert_pinned_version,
so a bundle manifest pinning e.g. 2.0.0 would silently install the
bundled asset's own version (1.0.0) — the pin was only enforced on the
catalog path. _WorkflowKindManager already enforces it unconditionally.
Read the bundled asset's declared version from its manifest (best-effort;
None => cannot enforce, matching the catalog 'advertises no version'
escape hatch) and call _assert_pinned_version before install, in both
bundled branches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bundler): address review on bundled version-pin check
- Make _assert_pinned_version's error source-agnostic ('resolved version'
/ 'the source') so bundled preset.yml/extension.yml mismatches read
correctly, not just catalog ones.
- Type-guard _bundled_manifest_version: only a non-empty string version is
usable; missing/non-string/whitespace -> None ('cannot enforce').
- Add bundled-preset success-path test (matching pin + version=None both
proceed to install_from_directory), mirroring the extension test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* test: isolate integration test home
Assisted-by: Codex (model: GPT-5, autonomous)
* test: assert integration home isolation
Assisted-by: Codex (model: GPT-5, autonomous)
* test: extend integration home isolation to module-scoped setup
Address Copilot review on #3144.
Add a session-scoped autouse fixture so HOME/USERPROFILE/XDG are redirected
for setup that runs outside a test function (e.g. the module-scoped status_*
fixtures in test_integration_subcommand.py that run `specify init` before any
per-test isolation applies). The function-scoped fixture still overrides HOME
per test.
Also assert Path.home() resolves to the isolated home, since most integrations
(Hermes, catalog) read the home via that API rather than the env vars directly.
* feat(extensions): port update-agent-context to Python
Ports the agent-context extension updater to a single Python script,
per #3281 and the check-prerequisites PoC pattern from #3302. The bash
version already ran its core logic through embedded Python heredocs, so
the port lifts that logic into a standalone script. Parity tests run
bash and Python side by side and compare output and resulting
context-file bytes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(extensions): match bash case-insensitivity on MSYS, test unparseable config gate
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(scripts): fall through to grep/sed when python3 is a broken stub in feature.json parser
read_feature_json_feature_directory picked its json parser by availability
(if jq / elif python3 / else grep-sed). on windows `python3` usually resolves
to the microsoft store app execution alias stub: it satisfies `command -v` but
fails at runtime (exit 49). the elif selected it, the runtime failure was
swallowed to _fd='', and the grep/sed last resort was never reached - so a
valid .specify/feature.json read as empty and every setup-plan / setup-tasks /
check-prerequisites call errored with "Feature directory not found" right after
a successful `specify init --script sh`.
change selection from availability to parse success: try jq, then python3 only
if still empty, then grep/sed only if still empty. a parser that exists but
produces nothing now falls through instead of terminating the chain.
the write path (_persist_feature_json) already uses jq-or-printf with no
python3, so it was unaffected; only the read path needed this.
add a regression test that puts a python3 stub (exit 49, like the store alias)
first on PATH and asserts setup-plan.sh still resolves the feature via the
grep/sed fallback.
fixes#3304
* test: shadow jq so the broken-python3 fallback test actually exercises it
the test claimed it dropped jq so the parser chain would reach python3 and
then grep/sed, but it only prepended the python3 stub dir to PATH. on a
runner with jq installed, read_feature_json_feature_directory parses via jq
and never reaches the fallback the test is meant to cover.
add a failing jq stub alongside the python3 stub so the chain is forced
through jq -> python3 -> grep/sed regardless of what the runner has installed.
* fix(toml): escape control characters so generated command files parse
both toml renderers (TomlIntegration._render_toml_string for gemini/tabnine
and CommandRegistrar.render_toml_command for extension/preset commands) wrote
control characters raw into a multiline or basic string. toml forbids literal
control chars (U+0000-U+001F except tab/newline, and U+007F) in every string
form, and a bare CR that is not part of a CRLF pair, so a description or body
containing one produced a .toml file that fails to parse.
route any value with such a character to a fully-escaped basic string that
emits the leftover control chars as \uXXXX. added regression tests that
round-trip through tomllib.
* refactor(toml): centralize control-char escaping in one shared helper
the control-char detection and basic-string escaping added for both toml
renderers were copy-pasted into agents.py and integrations/base.py. move the
two functions into specify_cli/_toml_string.py and have both renderers
delegate to it, so the escaping rules can't drift apart later.
no behavior change; both renderers now reference the same implementation.
* fix(cli): exit cleanly on malformed IPv6 URLs in extension/preset/workflow add
extension add --from, preset add --from, and workflow add <url> parsed
the user-supplied URL with a bare urlparse before their HTTPS/host
validation, so an unclosed IPv6 bracket escaped as a raw ValueError
traceback. Wrap each parse and emit the surrounding validation's clean
error style + typer.Exit(1) instead.
Fixes#3368
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(cli): convert malformed redirect URLs to URLError in shared redirect handler
Parse the redirect target once in _StripAuthOnRedirect.redirect_request
before the validator and stdlib handler run, converting ValueError into
URLError which every download path already catches. Also escape from_url
in the preset install message so IPv6 brackets don't break Rich markup.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
resolve_github_release_asset_api_url's is_ghes branch built the authority
with 'parsed.port', which raises ValueError on a malformed port (e.g.
host:notaport). The function's contract is to resolve or return None,
never raise — every other unresolvable case returns None. An allowlisted
GHES host with a bad port therefore crashed the caller. Read parsed.port
defensively and return None on ValueError.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>