Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/reference/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,12 @@ specify init my-project --integration copilot --branch-numbering timestamp

| Variable | Description |
| ----------------- | ------------------------------------------------------------------------ |
| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory or the Git root. Honored by the core feature scripts and the bundled Git extension (feature-branch creation and auto-commit). When unset, the project is detected by searching upward from the current directory as before. |
| `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (highest priority, above `.specify/feature.json` and branch-name detection). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. |
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. |

> **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` / `SPECIFY_FEATURE` select the **feature** within that project. They are independent — project first, then feature. Feature-axis selection applies to the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …); the agent-context update picks the most recently modified `specs/*/plan.md` within the resolved project regardless of the feature-axis variables.

## Check Installed Tools

```bash
Expand Down
20 changes: 19 additions & 1 deletion extensions/agent-context/scripts/bash/update-agent-context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,25 @@

set -euo pipefail

PROJECT_ROOT="$(pwd)"
resolve_project_root() {
if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then
local init_root
if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then
echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2
exit 1
fi
if [[ ! -d "$init_root/.specify" ]]; then
echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2
exit 1
fi
printf '%s\n' "$init_root"
return
fi

pwd
}

PROJECT_ROOT="$(resolve_project_root)"
EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml"
DEFAULT_START="<!-- SPECKIT START -->"
DEFAULT_END="<!-- SPECKIT END -->"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,32 @@ function Test-ConfigObject {
return $false
}

function Resolve-ProjectRoot {
if ($env:SPECIFY_INIT_DIR) {
$initDir = $env:SPECIFY_INIT_DIR
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
$initDir = Join-Path (Get-Location).Path $initDir
}
$resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue
if (-not $resolved) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
exit 1
}
$initRoot = $resolved.Path
Comment on lines +61 to +66
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
exit 1
}
return $initRoot
}

return (Get-Location).Path
}

$ErrorActionPreference = 'Stop'
$DefaultStart = '<!-- SPECKIT START -->'
$DefaultEnd = '<!-- SPECKIT END -->'
$ProjectRoot = (Get-Location).Path
$ProjectRoot = Resolve-ProjectRoot
$ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml'

if (-not (Test-Path -LiteralPath $ExtConfig)) {
Expand Down
11 changes: 10 additions & 1 deletion extensions/git/scripts/bash/auto-commit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,16 @@ _find_project_root() {
return 1
}

REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
# SPECIFY_INIT_DIR (explicit project override for non-interactive / CI use) wins,
# with the strict contract from resolve_specify_init_dir (git-common.sh): it must
# exist and contain .specify/, else hard error with no silent fallback. Otherwise
# keep the previous script-relative / cwd resolution unchanged.
if [ -n "${SPECIFY_INIT_DIR:-}" ]; then
source "$SCRIPT_DIR/git-common.sh"
REPO_ROOT=$(resolve_specify_init_dir) || exit 1
else
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
fi
cd "$REPO_ROOT"

# Check if git is available
Expand Down
21 changes: 19 additions & 2 deletions extensions/git/scripts/bash/create-new-feature.sh
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,25 @@ if [ "$_common_loaded" != "true" ]; then
exit 1
fi

# Resolve repository root
if type get_repo_root >/dev/null 2>&1; then
# A project may have an older core common.sh while the git extension has been
# updated independently. In that case, fall back to the extension-local helper
# for the SPECIFY_INIT_DIR contract instead of failing with "command not found".
if [ -n "${SPECIFY_INIT_DIR:-}" ] && ! type resolve_specify_init_dir >/dev/null 2>&1; then
if [ -f "$SCRIPT_DIR/git-common.sh" ]; then
source "$SCRIPT_DIR/git-common.sh"
else
echo "Error: SPECIFY_INIT_DIR requires resolve_specify_init_dir, but git-common.sh was not found." >&2
exit 1
fi
fi

# Resolve repository root. SPECIFY_INIT_DIR (explicit project override for
# non-interactive / CI use) wins, with the strict contract from
# resolve_specify_init_dir: it must exist and contain .specify/, else hard error
# with no silent fallback to cwd or the git toplevel.
if [ -n "${SPECIFY_INIT_DIR:-}" ]; then
REPO_ROOT=$(resolve_specify_init_dir) || exit 1
elif type get_repo_root >/dev/null 2>&1; then
REPO_ROOT=$(get_repo_root)
elif git rev-parse --show-toplevel >/dev/null 2>&1; then
REPO_ROOT=$(git rev-parse --show-toplevel)
Expand Down
20 changes: 20 additions & 0 deletions extensions/git/scripts/bash/git-common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@
# Extracted from scripts/bash/common.sh — contains only git-specific
# branch validation and detection logic.

# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
# *contains* .specify/). Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the
# validated absolute project root, or prints an error and returns 1. Strict by
# design: must exist and contain .specify/, with no silent fallback.
#
# Keep this contract aligned with scripts/bash/common.sh. The git extension can
# run without the core scripts installed.
resolve_specify_init_dir() {
local init_root
if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then
echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2
return 1
fi
if [[ ! -d "$init_root/.specify" ]]; then
echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2
return 1
fi
printf '%s\n' "$init_root"
}

# Check if we have git available at the repo root
has_git() {
local repo_root="${1:-$(pwd)}"
Expand Down
13 changes: 11 additions & 2 deletions extensions/git/scripts/powershell/auto-commit.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,17 @@ function Find-ProjectRoot {
}
}

$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
if (-not $repoRoot) { $repoRoot = Get-Location }
# SPECIFY_INIT_DIR (explicit project override for non-interactive / CI use) wins,
# with the strict contract from Resolve-SpecifyInitDir (git-common.ps1): it must
# exist and contain .specify/, else hard error with no silent fallback. Otherwise
# keep the previous script-relative / cwd resolution unchanged.
if ($env:SPECIFY_INIT_DIR) {
. (Join-Path $PSScriptRoot 'git-common.ps1')
$repoRoot = Resolve-SpecifyInitDir
} else {
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
if (-not $repoRoot) { $repoRoot = Get-Location }
}
Set-Location $repoRoot

# Check if git is available
Expand Down
21 changes: 19 additions & 2 deletions extensions/git/scripts/powershell/create-new-feature.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,25 @@ if (-not $commonLoaded) {
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
}

# Resolve repository root
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
# A project may have an older core common.ps1 while the git extension has been
# updated independently. In that case, fall back to the extension-local helper
# for the SPECIFY_INIT_DIR contract instead of failing with "command not found".
if ($env:SPECIFY_INIT_DIR -and -not (Get-Command Resolve-SpecifyInitDir -ErrorAction SilentlyContinue)) {
$gitCommon = Join-Path $PSScriptRoot 'git-common.ps1'
if (Test-Path $gitCommon) {
. $gitCommon
} else {
throw "SPECIFY_INIT_DIR requires Resolve-SpecifyInitDir, but git-common.ps1 was not found."
}
}

# Resolve repository root. SPECIFY_INIT_DIR (explicit project override for
# non-interactive / CI use) wins, with the strict contract from
# Resolve-SpecifyInitDir: it must exist and contain .specify/, else hard error
# with no silent fallback to cwd or the git toplevel.
if ($env:SPECIFY_INIT_DIR) {
$repoRoot = Resolve-SpecifyInitDir
} elseif (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
$repoRoot = Get-RepoRoot
} elseif ($projectRoot) {
$repoRoot = $projectRoot
Expand Down
25 changes: 25 additions & 0 deletions extensions/git/scripts/powershell/git-common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,31 @@
# Extracted from scripts/powershell/common.ps1 -- contains only git-specific
# branch validation and detection logic.

# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
# *contains* .specify/). Precondition: $env:SPECIFY_INIT_DIR is set. Returns the
# validated project root, or writes an error and exits 1. Strict by design: must
# exist and contain .specify/, with no silent fallback.
#
# Keep this contract aligned with scripts/powershell/common.ps1. The git
# extension can run without the core scripts installed.
function Resolve-SpecifyInitDir {
$initDir = $env:SPECIFY_INIT_DIR
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
$initDir = Join-Path (Get-Location).Path $initDir
}
$resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue
if (-not $resolved) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
exit 1
}
$initRoot = $resolved.Path
Comment on lines +18 to +23
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
exit 1
}
return $initRoot
}

function Test-HasGit {
param([string]$RepoRoot = (Get-Location))
try {
Expand Down
51 changes: 47 additions & 4 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,42 @@ find_specify_root() {
return 1
}

# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
# *contains* .specify/), for non-interactive / CI use — e.g. running a Spec Kit
# command against a member project from a monorepo root without cd.
#
# Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the validated absolute
# project root, or prints an error and returns 1. Strict by design: the path
# must exist and contain .specify/, with no silent fallback to cwd or the git
# toplevel (which would silently write to the wrong project's specs/).
#
# Keep this contract aligned with standalone extension helpers: git and
# agent-context can run without the core scripts installed.
resolve_specify_init_dir() {
local init_root
# Normalize: relative paths resolve against $(pwd); a trailing slash collapses.
# CDPATH="" so a relative value cannot be resolved against the caller's CDPATH
# (which would also echo to stdout and corrupt the captured path).
if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then
echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2
return 1
fi
if [[ ! -d "$init_root/.specify" ]]; then
echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2
return 1
fi
printf '%s\n' "$init_root"
}

# Get repository root, prioritizing .specify directory over git
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
get_repo_root() {
# Explicit project override wins (see resolve_specify_init_dir).
if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then
resolve_specify_init_dir
return
fi

# First, look for .specify directory (spec-kit's own marker)
local specify_root
if specify_root=$(find_specify_root); then
Expand Down Expand Up @@ -54,7 +87,10 @@ get_current_branch() {
fi

# Then check git if available at the spec-kit root (not parent)
local repo_root=$(get_repo_root)
# Split decl/assignment so a SPECIFY_INIT_DIR validation failure in
# get_repo_root propagates instead of being masked by `local`.
local repo_root
repo_root=$(get_repo_root) || return 1
if has_git; then
git -C "$repo_root" rev-parse --abbrev-ref HEAD
return
Expand Down Expand Up @@ -107,7 +143,10 @@ get_current_branch() {
has_git() {
# First check if git command is available (before calling get_repo_root which may use git)
command -v git >/dev/null 2>&1 || return 1
local repo_root=$(get_repo_root)
# Split decl/assignment so a SPECIFY_INIT_DIR validation failure in
# get_repo_root propagates instead of being masked by `local`.
local repo_root
repo_root=$(get_repo_root) || return 1
# Check if .git exists (directory or file for worktrees/submodules)
[ -e "$repo_root/.git" ] || return 1
# Verify it's actually a valid git work tree
Expand Down Expand Up @@ -252,8 +291,12 @@ find_feature_dir_by_prefix() {
}

get_feature_paths() {
local repo_root=$(get_repo_root)
local current_branch=$(get_current_branch)
# Split decl/assignment so a SPECIFY_INIT_DIR validation failure in
# get_repo_root propagates as a hard error instead of being masked by `local`.
local repo_root
repo_root=$(get_repo_root) || return 1
local current_branch
current_branch=$(get_current_branch) || return 1
local has_git_repo="false"

if has_git; then
Expand Down
2 changes: 1 addition & 1 deletion scripts/bash/create-new-feature.sh
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ clean_branch_name() {
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"

REPO_ROOT=$(get_repo_root)
REPO_ROOT=$(get_repo_root) || exit 1

# Check if git is available at this repo root (not a parent)
if has_git; then
Expand Down
35 changes: 35 additions & 0 deletions scripts/powershell/common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,44 @@ function Find-SpecifyRoot {
}
}

# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
# *contains* .specify/), for non-interactive / CI use — e.g. running a Spec Kit
# command against a member project from a monorepo root without cd.
#
# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
# or writes an error and exits 1. Strict by design: the path must exist and
# contain .specify/, with no silent fallback. (An empty string is falsy, so the
# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
#
# Keep this contract aligned with standalone extension helpers: git and
# agent-context can run without the core scripts installed.
function Resolve-SpecifyInitDir {
$initDir = $env:SPECIFY_INIT_DIR
# Normalize: relative paths resolve against the current directory.
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
$initDir = Join-Path (Get-Location).Path $initDir
}
$resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue
if (-not $resolved) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
exit 1
}
$initRoot = $resolved.Path
Comment on lines +44 to +49
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
exit 1
}
return $initRoot
}

# Get repository root, prioritizing .specify directory over git
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
function Get-RepoRoot {
# Explicit project override wins (see Resolve-SpecifyInitDir).
if ($env:SPECIFY_INIT_DIR) {
return (Resolve-SpecifyInitDir)
}

# First, look for .specify directory (spec-kit's own marker)
$specifyRoot = Find-SpecifyRoot
if ($specifyRoot) {
Expand Down
Loading