diff --git a/README.md b/README.md index f568db0..2545a95 100644 --- a/README.md +++ b/README.md @@ -78,20 +78,20 @@ A good spec is short, concrete, and verifiable. Start from [`skills/devloop-spec ```sh devloop spec -devloop spec --agent claude --output .specs/chat-retry.md notes.md +devloop spec --agent claude notes.md ``` -By default, generated specs are written under `.specs/`, and the interactive spec picker searches `.specs/`. If your specs live somewhere else, open `devloop`, choose `Settings`, and add one extra spec path. Generated specs will be written there too. +Devloop maintains a global config at `~/.devloop/config`. By default, generated specs are written under `~/Projects/specs/`, and the interactive spec picker searches that directory plus the current repo's `.specs/` fallback. -The custom path is saved in `.devloop/config` and can be repo-relative or absolute: +Change the shared spec directory from `devloop` > `Settings`, or edit `~/.devloop/config`: ```ini spec_dir=/Users/satya/Projects/specs ``` -The Settings menu expands `~/...` before saving. It also lets you change the per-run timeout. `.devloop/` is ignored by git because absolute paths are machine-local. +Repo-local `.devloop/config` is still supported for explicit overrides. Prefer absolute paths there unless the override should be repo-relative. -Runs time out after 30 minutes by default. Change that in `Settings`, in `.devloop/config`, or per command: +Runs time out after 30 minutes by default. Change that in `Settings`, in `~/.devloop/config`, or per command: ```ini timeout_minutes=45 @@ -134,11 +134,11 @@ devloop [options] [max=5] When stdout is a terminal, running `devloop` without arguments opens a menu: -- `Run a spec`: pick a spec from the configured spec path or `.specs/`. +- `Run a spec`: pick a spec from the shared spec directory or `.specs/`. - `Create a spec`: choose the spec agent and provide source context. - `Continue a run`: pick a prior `.codex/tracks/*.md` and continue in that worktree. - `Open reports`: pick a prior report from any registered worktree. -- `Settings`: view spec search paths, add or remove one custom spec path, and set the run timeout. +- `Settings`: view or change the shared spec path and set the run timeout. - `Doctor`: verify required commands, optional UI tools, and installed skills. Nested menu screens keep `Back` as the final option so you can return to the previous menu without exiting Devloop. diff --git a/devloop b/devloop index 3c81367..0c8a7fe 100755 --- a/devloop +++ b/devloop @@ -6,6 +6,7 @@ CODEX_REASONING_ARGS=(-c 'model_reasoning_effort="xhigh"') CLAUDE_MODEL_ARGS=(--model claude-opus-4-8) CLAUDE_EFFORT_ARGS=(--effort max) DEFAULT_TIMEOUT_MINUTES=30 +DEFAULT_SPEC_DIR_SUFFIX="Projects/specs" SCRIPT_PATH="${BASH_SOURCE[0]}" while [ -L "$SCRIPT_PATH" ]; do @@ -286,7 +287,7 @@ devloop_global_config_file() { } devloop_config_file() { - devloop_local_config_file + devloop_global_config_file || devloop_local_config_file } devloop_config_file_for_scope() { @@ -323,9 +324,43 @@ config_file_value() { done < "$file" | tail -n 1 } +devloop_default_spec_dir() { + if [ -n "${HOME:-}" ]; then + printf '%s/%s\n' "${HOME%/}" "$DEFAULT_SPEC_DIR_SUFFIX" + else + printf '%s\n' ".specs" + fi +} + +ensure_global_config() { + local file value spec_dir + file="$(devloop_global_config_file 2>/dev/null)" || return 0 + mkdir -p "$(dirname "$file")" || return 1 + if [ ! -f "$file" ]; then + spec_dir="$(devloop_default_spec_dir)" + mkdir -p "$spec_dir" || return 1 + { + printf 'spec_dir=%s\n' "$spec_dir" + printf 'timeout_minutes=%s\n' "$DEFAULT_TIMEOUT_MINUTES" + } > "$file" + return 0 + fi + value="$(config_file_value spec_dir "$file" || true)" + if [ -z "$value" ]; then + spec_dir="$(devloop_default_spec_dir)" + mkdir -p "$spec_dir" || return 1 + printf 'spec_dir=%s\n' "$spec_dir" >> "$file" + fi + value="$(config_file_value timeout_minutes "$file" || true)" + if [ -z "$value" ]; then + printf 'timeout_minutes=%s\n' "$DEFAULT_TIMEOUT_MINUTES" >> "$file" + fi +} + devloop_config_value() { local key="$1" local file value + ensure_global_config >/dev/null 2>&1 || true file="$(devloop_local_config_file)" if value="$(config_file_value "$key" "$file")" && [ -n "$value" ]; then printf '%s\n' "$value" @@ -406,46 +441,53 @@ expand_spec_dir_input() { } devloop_spec_dir() { - local value normalized - value="$(devloop_config_value spec_dir || true)" - if normalized="$(normalize_spec_dir "$value")"; then - printf '%s\n' "$normalized" - else - printf '%s\n' ".specs" - fi + local scope file value normalized + ensure_global_config >/dev/null 2>&1 || true + for scope in local global; do + file="$(devloop_config_file_for_scope "$scope" 2>/dev/null)" || continue + value="$(config_file_value spec_dir "$file" || true)" + [ -n "$value" ] || continue + if normalized="$(normalize_spec_dir "$value")"; then + printf '%s\n' "$normalized" + return 0 + fi + done + devloop_default_spec_dir } is_default_spec_dir() { - case "$1" in - ".specs") return 0 ;; - *) return 1 ;; - esac + local default_spec_dir + default_spec_dir="$(devloop_default_spec_dir)" + [ "$1" = "$default_spec_dir" ] } configured_spec_dir() { - local value normalized - value="$(devloop_config_value spec_dir || true)" - [ -n "$value" ] || return 1 - normalized="$(normalize_spec_dir "$value")" || return 1 - is_default_spec_dir "$normalized" && return 1 - printf '%s\n' "$normalized" + local scope file value normalized + ensure_global_config >/dev/null 2>&1 || true + for scope in local global; do + file="$(devloop_config_file_for_scope "$scope" 2>/dev/null)" || continue + value="$(config_file_value spec_dir "$file" || true)" + [ -n "$value" ] || continue + normalized="$(normalize_spec_dir "$value")" || continue + is_default_spec_dir "$normalized" && return 1 + printf '%s\n' "$normalized" + return 0 + done + return 1 } configured_spec_dir_scope() { - local file value normalized - file="$(devloop_local_config_file)" - if value="$(config_file_value spec_dir "$file")" && [ -n "$value" ]; then - normalized="$(normalize_spec_dir "$value")" || return 1 - is_default_spec_dir "$normalized" && return 1 - printf '%s\n' "local" - return 0 - fi - if file="$(devloop_global_config_file 2>/dev/null)" && value="$(config_file_value spec_dir "$file")" && [ -n "$value" ]; then - normalized="$(normalize_spec_dir "$value")" || return 1 + local scope file value normalized + ensure_global_config >/dev/null 2>&1 || true + for scope in local global; do + file="$(devloop_config_file_for_scope "$scope" 2>/dev/null)" || continue + value="$(config_file_value spec_dir "$file" || true)" + [ -n "$value" ] || continue + normalized="$(normalize_spec_dir "$value")" || continue is_default_spec_dir "$normalized" && return 1 - printf '%s\n' "global" + printf '%s\n' "$scope" return 0 - fi + done return 1 } @@ -493,7 +535,7 @@ spec_dir_status() { } write_config_spec_dir() { - local scope="local" + local scope="global" local dir local expanded normalized file if [ "$#" -eq 2 ]; then @@ -510,6 +552,12 @@ write_config_spec_dir() { printf '%s\n' "spec directory must be absolute, or relative without '..'" >&2 return 2 } + if [ "$scope" = "global" ]; then + case "$normalized" in + /*) ;; + *) normalized="$(absolute_path "$normalized")" || return 1 ;; + esac + fi file="$(devloop_config_file_for_scope "$scope")" || return $? mkdir -p "$(dirname "$file")" "$normalized" || return 1 write_config_value "$scope" spec_dir "$normalized" || return $? @@ -517,7 +565,7 @@ write_config_spec_dir() { } remove_config_spec_dir() { - local scope="local" + local scope="global" if [ "$#" -eq 1 ]; then scope="$1"; fi remove_config_value "$scope" spec_dir } @@ -531,44 +579,52 @@ normalize_timeout_minutes() { } devloop_timeout_minutes() { - local value normalized - value="$(devloop_config_value timeout_minutes || true)" - if normalized="$(normalize_timeout_minutes "$value")"; then - printf '%s\n' "$normalized" - else - printf '%s\n' "$DEFAULT_TIMEOUT_MINUTES" - fi + local scope file value normalized + ensure_global_config >/dev/null 2>&1 || true + for scope in local global; do + file="$(devloop_config_file_for_scope "$scope" 2>/dev/null)" || continue + value="$(config_file_value timeout_minutes "$file" || true)" + [ -n "$value" ] || continue + if normalized="$(normalize_timeout_minutes "$value")"; then + printf '%s\n' "$normalized" + return 0 + fi + done + printf '%s\n' "$DEFAULT_TIMEOUT_MINUTES" } configured_timeout_minutes() { - local value normalized - value="$(devloop_config_value timeout_minutes || true)" - [ -n "$value" ] || return 1 - normalized="$(normalize_timeout_minutes "$value")" || return 1 - [ "$normalized" = "$DEFAULT_TIMEOUT_MINUTES" ] && return 1 - printf '%s\n' "$normalized" + local scope file value normalized + ensure_global_config >/dev/null 2>&1 || true + for scope in local global; do + file="$(devloop_config_file_for_scope "$scope" 2>/dev/null)" || continue + value="$(config_file_value timeout_minutes "$file" || true)" + [ -n "$value" ] || continue + normalized="$(normalize_timeout_minutes "$value")" || continue + [ "$normalized" = "$DEFAULT_TIMEOUT_MINUTES" ] && return 1 + printf '%s\n' "$normalized" + return 0 + done + return 1 } configured_timeout_minutes_scope() { - local file value normalized - file="$(devloop_local_config_file)" - if value="$(config_file_value timeout_minutes "$file")" && [ -n "$value" ]; then - normalized="$(normalize_timeout_minutes "$value")" || return 1 - [ "$normalized" = "$DEFAULT_TIMEOUT_MINUTES" ] && return 1 - printf '%s\n' "local" - return 0 - fi - if file="$(devloop_global_config_file 2>/dev/null)" && value="$(config_file_value timeout_minutes "$file")" && [ -n "$value" ]; then - normalized="$(normalize_timeout_minutes "$value")" || return 1 + local scope file value normalized + ensure_global_config >/dev/null 2>&1 || true + for scope in local global; do + file="$(devloop_config_file_for_scope "$scope" 2>/dev/null)" || continue + value="$(config_file_value timeout_minutes "$file" || true)" + [ -n "$value" ] || continue + normalized="$(normalize_timeout_minutes "$value")" || continue [ "$normalized" = "$DEFAULT_TIMEOUT_MINUTES" ] && return 1 - printf '%s\n' "global" + printf '%s\n' "$scope" return 0 - fi + done return 1 } write_config_timeout_minutes() { - local scope="local" + local scope="global" local value normalized if [ "$#" -eq 2 ]; then scope="$1" @@ -585,7 +641,7 @@ write_config_timeout_minutes() { } remove_config_timeout_minutes() { - local scope="local" + local scope="global" if [ "$#" -eq 1 ]; then scope="$1"; fi remove_config_value "$scope" timeout_minutes } @@ -1036,21 +1092,24 @@ interactive_create_spec() { interactive_settings() { local choice custom_spec_dir custom_timeout scope saved value timeout_display + local default_spec_dir local choices=() while true; do custom_spec_dir="$(configured_spec_dir || true)" custom_timeout="$(configured_timeout_minutes || true)" timeout_display="$(devloop_timeout_minutes) minutes" - ui_header "Settings" "Spec paths and run timeout" + default_spec_dir="$(devloop_default_spec_dir)" + ui_header "Settings" "Global spec path and run timeout" if [ -n "$custom_spec_dir" ]; then ui_print_key_values \ - "custom" "$custom_spec_dir" \ - "default" ".specs" \ + "configured" "$custom_spec_dir" \ + "fallback" ".specs" \ "timeout" "$timeout_display" choices=("Remove spec path" "Set timeout" "Back") else ui_print_key_values \ - "default" ".specs" \ + "default" "$default_spec_dir" \ + "fallback" ".specs" \ "timeout" "$timeout_display" choices=("Add spec path" "Set timeout" "Back") fi @@ -1061,7 +1120,7 @@ interactive_settings() { case "$choice" in "Add spec path") value="$(ui_input "Spec path" "")" || return 130 - if saved="$(write_config_spec_dir local "$value")"; then + if saved="$(write_config_spec_dir global "$value")"; then printf 'spec path saved: %s\n' "$saved" >&2 else printf '%s\n' "spec path not saved" >&2 @@ -1076,7 +1135,7 @@ interactive_settings() { ;; "Set timeout") value="$(ui_input "Timeout minutes, 1-1440" "$(devloop_timeout_minutes)")" || return 130 - if saved="$(write_config_timeout_minutes local "$value")"; then + if saved="$(write_config_timeout_minutes global "$value")"; then printf 'timeout saved: %s minutes\n' "$saved" >&2 else printf '%s\n' "timeout not saved" >&2 diff --git a/tests/devloop_test.sh b/tests/devloop_test.sh index b338134..e53324b 100755 --- a/tests/devloop_test.sh +++ b/tests/devloop_test.sh @@ -272,16 +272,23 @@ equals "$(extract_generated_spec "$spec_output")" $'---\nstatus: draft\n---\n\n# config_repo="$work/config-repo" config_home="$work/config-home" +config_default_specs="$config_home/Projects/specs" mkdir -p "$config_repo/.specs" "$config_repo/.devloop/specs" "$config_home" config_repo_real="$(cd "$config_repo" && pwd)" -equals "$(devloop_config_file)" ".devloop/config" "default config file" +equals "$(HOME="$config_home" devloop_config_file)" "$config_home/.devloop/config" "default config file" printf '%s\n' "# Default" > "$config_repo/.specs/default.md" printf '%s\n' "# Devloop" > "$config_repo/.devloop/specs/devloop.md" config_specs="$(cd "$config_repo" && HOME="$config_home" list_spec_files)" +[[ -f "$config_home/.devloop/config" ]] || fail "global config was not created" +contains "$(cat "$config_home/.devloop/config")" "spec_dir=$config_default_specs" "global config default spec dir" +contains "$(cat "$config_home/.devloop/config")" "timeout_minutes=30" "global config default timeout" +[[ -d "$config_default_specs" ]] || fail "global default spec dir was not created" contains "$config_specs" ".specs/default.md" "default spec search" if printf '%s\n' "$config_specs" | grep -Fq ".devloop/specs/devloop.md"; then fail "default spec search included .devloop/specs"; fi -equals "$(cd "$config_repo" && HOME="$config_home" spec_search_label)" ".specs" "spec search label" -equals "$(cd "$config_repo" && HOME="$config_home" write_config_spec_dir "custom-specs")" "custom-specs" "write config spec dir" +equals "$(cd "$config_repo" && HOME="$config_home" devloop_spec_dir)" "$config_default_specs" "default global spec dir" +equals "$(cd "$config_repo" && HOME="$config_home" spec_search_label)" "$config_default_specs, .specs" "spec search label" +if (cd "$config_repo" && HOME="$config_home" configured_spec_dir) >/dev/null 2>&1; then fail "default global spec dir reported as custom"; fi +equals "$(cd "$config_repo" && HOME="$config_home" write_config_spec_dir local "custom-specs")" "custom-specs" "write local config spec dir" equals "$(cd "$config_repo" && HOME="$config_home" devloop_spec_dir)" "custom-specs" "configured spec dir" equals "$(cd "$config_repo" && HOME="$config_home" configured_spec_dir)" "custom-specs" "custom spec dir" equals "$(cd "$config_repo" && HOME="$config_home" configured_spec_dir_scope)" "local" "custom spec dir scope" @@ -293,7 +300,7 @@ equals "$(cd "$config_repo" && HOME="$config_home" generated_spec_path "$spec_ou if (cd "$config_repo" && HOME="$config_home" write_config_spec_dir "../bad") >/dev/null 2>&1; then fail "write_config_spec_dir accepted path traversal"; fi absolute_specs="$work/shared-specs" -equals "$(cd "$config_repo" && HOME="$config_home" write_config_spec_dir "$absolute_specs")" "$absolute_specs" "write absolute config spec dir" +equals "$(cd "$config_repo" && HOME="$config_home" write_config_spec_dir local "$absolute_specs")" "$absolute_specs" "write absolute config spec dir" equals "$(cd "$config_repo" && HOME="$config_home" devloop_spec_dir)" "$absolute_specs" "absolute configured spec dir" equals "$(cd "$config_repo" && HOME="$config_home" configured_spec_dir)" "$absolute_specs" "absolute custom spec dir" [[ -d "$absolute_specs" ]] || fail "absolute configured spec dir was not created" @@ -306,8 +313,8 @@ equals "$(spec_dir_status "$absolute_specs")" "exists" "spec dir status exists" equals "$(spec_dir_status "$work/missing-specs")" "missing" "spec dir status missing" (cd "$config_repo" && HOME="$config_home" remove_config_spec_dir local) if (cd "$config_repo" && HOME="$config_home" configured_spec_dir) >/dev/null 2>&1; then fail "custom spec dir was not removed"; fi -equals "$(cd "$config_repo" && HOME="$config_home" devloop_spec_dir)" ".specs" "removed custom spec dir falls back" -equals "$(cd "$config_repo" && HOME="$config_home" spec_search_label)" ".specs" "removed custom spec search label" +equals "$(cd "$config_repo" && HOME="$config_home" devloop_spec_dir)" "$config_default_specs" "removed custom spec dir falls back" +equals "$(cd "$config_repo" && HOME="$config_home" spec_search_label)" "$config_default_specs, .specs" "removed custom spec search label" global_repo="$work/global-repo" global_home="$work/global-home" @@ -325,18 +332,31 @@ equals "$(cd "$global_repo" && HOME="$global_home" devloop_spec_dir)" "local-spe equals "$(cd "$global_repo" && HOME="$global_home" configured_spec_dir)" "local-specs" "local custom spec dir" equals "$(cd "$global_repo" && HOME="$global_home" configured_spec_dir_scope)" "local" "local custom spec dir scope" equals "$(cd "$global_repo" && HOME="$global_home" devloop_config_value coder)" "codex" "global config fills missing local key" +printf '%s\n' "spec_dir=.specs" > "$global_repo/.devloop/config" +equals "$(cd "$global_repo" && HOME="$global_home" devloop_spec_dir)" ".specs" "repo .specs overrides global spec dir" +equals "$(cd "$global_repo" && HOME="$global_home" configured_spec_dir)" ".specs" "repo .specs reported as override" +equals "$(cd "$global_repo" && HOME="$global_home" configured_spec_dir_scope)" "local" "repo .specs override scope" + +default_scope_repo="$work/default-scope-repo" +default_scope_home="$work/default-scope-home" +default_scope_specs="$work/default-scope-specs" +mkdir -p "$default_scope_repo" "$default_scope_home" +equals "$(cd "$default_scope_repo" && HOME="$default_scope_home" write_config_spec_dir "$default_scope_specs")" "$default_scope_specs" "default write config spec dir is global" +equals "$(cat "$default_scope_home/.devloop/config")" "spec_dir=$default_scope_specs" "default write config path" +equals "$(cd "$default_scope_repo" && HOME="$default_scope_home" configured_spec_dir_scope)" "global" "default write config scope" tilde_repo="$work/tilde-repo" tilde_home="$work/home" tilde_input="~"/shared-specs mkdir -p "$tilde_repo" "$tilde_home" equals "$(cd "$tilde_repo" && HOME="$tilde_home" write_config_spec_dir "$tilde_input")" "$tilde_home/shared-specs" "tilde input expands when saved" -equals "$(cat "$tilde_repo/.devloop/config")" "spec_dir=$tilde_home/shared-specs" "tilde input saved as absolute path" +equals "$(cat "$tilde_home/.devloop/config")" "spec_dir=$tilde_home/shared-specs" "tilde input saved as absolute path" raw_tilde_repo="$work/raw-tilde-repo" -mkdir -p "$raw_tilde_repo/.devloop" +raw_tilde_home="$work/raw-tilde-home" +mkdir -p "$raw_tilde_repo/.devloop" "$raw_tilde_home" printf '%s\n' "spec_dir=~/raw-specs" > "$raw_tilde_repo/.devloop/config" -equals "$(cd "$raw_tilde_repo" && devloop_spec_dir)" ".specs" "raw tilde config falls back" +equals "$(cd "$raw_tilde_repo" && HOME="$raw_tilde_home" devloop_spec_dir)" "$raw_tilde_home/Projects/specs" "raw tilde config falls back" equals "$(normalize_timeout_minutes 1)" "1" "timeout lower bound" equals "$(normalize_timeout_minutes 30)" "30" "timeout normalize" @@ -347,8 +367,8 @@ if normalize_timeout_minutes nope >/dev/null 2>&1; then fail "timeout accepted n equals "$(cd "$config_repo" && HOME="$config_home" devloop_timeout_minutes)" "30" "default timeout" equals "$(cd "$config_repo" && HOME="$config_home" write_config_timeout_minutes 45)" "45" "write timeout" equals "$(cd "$config_repo" && HOME="$config_home" devloop_timeout_minutes)" "45" "configured timeout" -equals "$(cd "$config_repo" && HOME="$config_home" configured_timeout_minutes_scope)" "local" "configured timeout scope" -(cd "$config_repo" && HOME="$config_home" remove_config_timeout_minutes local) +equals "$(cd "$config_repo" && HOME="$config_home" configured_timeout_minutes_scope)" "global" "configured timeout scope" +(cd "$config_repo" && HOME="$config_home" remove_config_timeout_minutes) equals "$(cd "$config_repo" && HOME="$config_home" devloop_timeout_minutes)" "30" "removed timeout falls back" lint_spec_text=$'---\ntype: feat\n---\n# Title\n\n## Acceptance criteria\n1. Thing' @@ -508,13 +528,16 @@ AGENT chmod +x "$agent" repo="$work/repo" -mkdir -p "$repo/.devloop" +spec_home="$work/spec-home" repo_specs="$work/repo-specs" -printf 'spec_dir=%s\n' "$repo_specs" > "$repo/.devloop/config" +mkdir -p "$repo" "$spec_home/.devloop" +printf 'spec_dir=%s\n' "$repo_specs" > "$spec_home/.devloop/config" old_use_tui="$USE_TUI" USE_TUI=false ( cd "$repo" + HOME="$spec_home" + export HOME main spec --agent "$agent" "Keep devloop as Bash." >/tmp/devloop-spec-test.out ) USE_TUI="$old_use_tui"