Skip to content

Commit 283b3b4

Browse files
Rynaroclaude
andcommitted
fix(cli): eliminate YAML install friction and guarantee command coverage
Users running the curl-pipe installer on a box without yq or PyYAML hit a raw ModuleNotFoundError because yaml_to_json()'s python3 fallback only checked for python3 on PATH, not for PyYAML importability — and install.sh never ensured either was present. install.sh now auto-installs a pinned mikefarah/yq binary (platform- detected) when yq is missing, so the CLI has a guaranteed YAML parser out of the box. lib.sh still keeps python3 as a defensive fallback but only when PyYAML is actually importable, and dies with an actionable three-line message otherwise instead of a traceback. Along the way, fixed three pre-existing bugs that blocked full command operability (all surfaced by the new test suite): - init.sh: paste -sd, needed an explicit - for BSD paste on macOS - init/add/doctor: $CLI_SRC wasn't propagated across exec bash; switched to each subcommand's local $SELF_DIR - detect_hosts(): crashed under set -u when no host markers existed Added a bats-core test suite (58 tests, 11 files) covering every command × every flag, plus a new ci.yml workflow that runs lint, install-e2e (matrix: ubuntu/macos × yq-preinstalled/absent, with a step that reproduces the original failing `eidolons list --available` invocation), and the bats suite across both OSes. The smoke-cli job is dropped from roster-health.yml, which now focuses on its namesake — nightly upstream Eidolon repo health checks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 18c9254 commit 283b3b4

19 files changed

Lines changed: 837 additions & 41 deletions

.github/workflows/ci.yml

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
name: CI
2+
3+
# Validates the eidolons CLI on every push and PR:
4+
# - lint: shellcheck + schema validation
5+
# - install-e2e: curl-pipe install against the checkout on Ubuntu + macOS,
6+
# with and without a pre-existing yq, to guarantee the
7+
# bootstrap works on a bare box (the ModuleNotFoundError
8+
# for PyYAML must never reach a user again)
9+
# - cli-tests: bats suite covering every command end-to-end
10+
11+
on:
12+
push:
13+
branches: [main]
14+
pull_request:
15+
workflow_dispatch:
16+
17+
jobs:
18+
lint:
19+
name: Lint
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
24+
- name: Install shellcheck + yq + jq
25+
run: |
26+
sudo apt-get update
27+
sudo apt-get install -y shellcheck jq
28+
sudo wget -qO /usr/local/bin/yq \
29+
https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64
30+
sudo chmod +x /usr/local/bin/yq
31+
32+
- name: shellcheck all shell scripts (error severity)
33+
run: |
34+
set -e
35+
# -S error = block merges only on real errors, not style/info.
36+
# Warnings are surfaced but non-blocking; promote to -S warning
37+
# once the existing warnings are cleaned up.
38+
find cli -name '*.sh' -type f -print0 | xargs -0 shellcheck -x -S error
39+
shellcheck -x -S error cli/eidolons
40+
41+
- name: Validate JSON schemas parse
42+
run: |
43+
for schema in schemas/*.json; do
44+
jq empty "$schema"
45+
done
46+
47+
- name: Validate roster/index.yaml required fields
48+
run: |
49+
set -euo pipefail
50+
yq eval '.' roster/index.yaml > /tmp/roster.json
51+
for name in $(jq -r '.eidolons[].name' /tmp/roster.json); do
52+
jq -e --arg n "$name" '.eidolons[] | select(.name == $n) |
53+
.methodology.name and .methodology.version and .methodology.cycle and
54+
.source.repo and
55+
.versions.latest and
56+
.handoffs.upstream and .handoffs.downstream' \
57+
/tmp/roster.json > /dev/null || { echo "::error::$name missing required fields"; exit 1; }
58+
done
59+
60+
install-e2e:
61+
name: Install — ${{ matrix.os }} / yq=${{ matrix.yq }}
62+
runs-on: ${{ matrix.os }}
63+
strategy:
64+
fail-fast: false
65+
matrix:
66+
os: [ubuntu-latest, macos-latest]
67+
yq: [preinstalled, absent]
68+
steps:
69+
- uses: actions/checkout@v4
70+
71+
- name: Install jq
72+
run: |
73+
if [[ "$RUNNER_OS" == "Linux" ]]; then
74+
sudo apt-get update && sudo apt-get install -y jq
75+
else
76+
brew install jq
77+
fi
78+
79+
- name: Remove any pre-existing yq (matrix.yq=absent)
80+
if: matrix.yq == 'absent'
81+
run: |
82+
for p in /usr/local/bin/yq /usr/bin/yq /opt/homebrew/bin/yq "$HOME/.local/bin/yq"; do
83+
[[ -e "$p" ]] && sudo rm -f "$p" || true
84+
done
85+
# Ubuntu runners may have yq via snap too.
86+
command -v yq && sudo rm -f "$(command -v yq)" || true
87+
if command -v yq >/dev/null 2>&1; then
88+
echo "::error::yq still on PATH after cleanup: $(command -v yq)"
89+
exit 1
90+
fi
91+
92+
- name: Ensure yq is present (matrix.yq=preinstalled)
93+
if: matrix.yq == 'preinstalled'
94+
run: |
95+
if ! command -v yq >/dev/null 2>&1; then
96+
if [[ "$RUNNER_OS" == "Linux" ]]; then
97+
sudo wget -qO /usr/local/bin/yq \
98+
https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64
99+
sudo chmod +x /usr/local/bin/yq
100+
else
101+
brew install yq
102+
fi
103+
fi
104+
105+
- name: Run install.sh against the checkout
106+
env:
107+
EIDOLONS_REPO: file://${{ github.workspace }}
108+
EIDOLONS_REF: ${{ github.sha }}
109+
EIDOLONS_HOME: ${{ github.workspace }}/.e2e-home
110+
EIDOLONS_BIN_DIR: ${{ github.workspace }}/.e2e-bin
111+
run: |
112+
set -euo pipefail
113+
bash cli/install.sh
114+
"$EIDOLONS_BIN_DIR/eidolons" version
115+
# yq must exist after install regardless of matrix.yq.
116+
command -v yq >/dev/null 2>&1 || [[ -x "$EIDOLONS_BIN_DIR/yq" ]]
117+
118+
- name: Reproduce the original user bug (list --available must work)
119+
env:
120+
EIDOLONS_HOME: ${{ github.workspace }}/.e2e-home
121+
EIDOLONS_BIN_DIR: ${{ github.workspace }}/.e2e-bin
122+
run: |
123+
export PATH="$EIDOLONS_BIN_DIR:$PATH"
124+
# This is the exact invocation that was failing with
125+
# ModuleNotFoundError: No module named 'yaml'.
126+
eidolons list --available
127+
128+
- name: Second install run is idempotent
129+
env:
130+
EIDOLONS_REPO: file://${{ github.workspace }}
131+
EIDOLONS_REF: ${{ github.sha }}
132+
EIDOLONS_HOME: ${{ github.workspace }}/.e2e-home
133+
EIDOLONS_BIN_DIR: ${{ github.workspace }}/.e2e-bin
134+
run: |
135+
bash cli/install.sh
136+
"$EIDOLONS_BIN_DIR/eidolons" version
137+
138+
cli-tests:
139+
name: CLI tests (bats) — ${{ matrix.os }}
140+
runs-on: ${{ matrix.os }}
141+
needs: lint
142+
strategy:
143+
fail-fast: false
144+
matrix:
145+
os: [ubuntu-latest, macos-latest]
146+
steps:
147+
- uses: actions/checkout@v4
148+
149+
- name: Install prerequisites (jq, yq, bats)
150+
run: |
151+
if [[ "$RUNNER_OS" == "Linux" ]]; then
152+
sudo apt-get update
153+
sudo apt-get install -y jq bats
154+
sudo wget -qO /usr/local/bin/yq \
155+
https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64
156+
sudo chmod +x /usr/local/bin/yq
157+
else
158+
brew install jq yq bats-core
159+
fi
160+
161+
- name: Make CLI executable
162+
run: chmod +x cli/eidolons cli/src/*.sh cli/install.sh
163+
164+
- name: Run bats suite
165+
run: bats cli/tests/

.github/workflows/roster-health.yml

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -116,31 +116,8 @@ jobs:
116116
cd /tmp/eidolon
117117
bash install.sh --help > /dev/null
118118
119-
smoke-cli:
120-
name: Smoke-test the eidolons CLI
121-
runs-on: ubuntu-latest
122-
needs: validate-roster
123-
steps:
124-
- uses: actions/checkout@v4
125-
126-
- name: Install dependencies
127-
run: |
128-
sudo apt-get update
129-
sudo apt-get install -y jq
130-
sudo wget -qO /usr/local/bin/yq \
131-
https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
132-
sudo chmod +x /usr/local/bin/yq
133-
134-
- name: Smoke-test CLI commands
135-
run: |
136-
set -euo pipefail
137-
chmod +x cli/eidolons cli/src/*.sh
138-
139-
# Point the CLI at this checkout rather than ~/.eidolons/nexus
140-
export EIDOLONS_NEXUS="$(pwd)"
141-
142-
./cli/eidolons version
143-
./cli/eidolons help
144-
./cli/eidolons list --available
145-
./cli/eidolons list --presets
146-
./cli/eidolons roster atlas
119+
# CLI smoke testing has moved to .github/workflows/ci.yml (install-e2e +
120+
# cli-tests jobs, which cover every command across Ubuntu and macOS with
121+
# bats). This workflow now focuses on its namesake: verifying the roster
122+
# itself is valid and every upstream Eidolon repo is reachable + EIIS-
123+
# conformant (runs on path-filtered pushes and nightly).

cli/install.sh

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ EIDOLONS_REPO="${EIDOLONS_REPO:-https://github.com/Rynaro/eidolons}"
2525
EIDOLONS_REF="${EIDOLONS_REF:-main}"
2626
EIDOLONS_HOME="${EIDOLONS_HOME:-$HOME/.eidolons}"
2727
EIDOLONS_BIN_DIR="${EIDOLONS_BIN_DIR:-$HOME/.local/bin}"
28+
EIDOLONS_YQ_VERSION="${EIDOLONS_YQ_VERSION:-v4.44.3}"
2829

2930
# ─── Pretty output ─────────────────────────────────────────────────────────
3031
if [[ -t 1 ]]; then
@@ -54,12 +55,50 @@ need git "required to clone the nexus and fetch Eidolon repos"
5455
need bash "required — the CLI is bash"
5556
need jq "required for JSON parsing in CLI commands"
5657

57-
if ! command -v yq >/dev/null 2>&1; then
58-
warn "yq not found — falling back to internal YAML parser (slower, less strict)"
59-
fi
60-
6158
ok "Prerequisites OK"
6259

60+
# ─── yq (YAML parser) ──────────────────────────────────────────────────────
61+
# yq is a hard dependency — every CLI command reads YAML. Auto-install the
62+
# mikefarah/yq static binary when missing so users never hit
63+
# "ModuleNotFoundError: No module named 'yaml'" from a Python fallback.
64+
install_yq() {
65+
local os arch asset url dest
66+
case "$(uname -s)" in
67+
Linux) os="linux" ;;
68+
Darwin) os="darwin" ;;
69+
*) die "Unsupported OS for automatic yq install: $(uname -s). Install yq manually: https://github.com/mikefarah/yq/releases" ;;
70+
esac
71+
case "$(uname -m)" in
72+
x86_64|amd64) arch="amd64" ;;
73+
arm64|aarch64) arch="arm64" ;;
74+
*) die "Unsupported arch for automatic yq install: $(uname -m). Install yq manually: https://github.com/mikefarah/yq/releases" ;;
75+
esac
76+
asset="yq_${os}_${arch}"
77+
url="https://github.com/mikefarah/yq/releases/download/${EIDOLONS_YQ_VERSION}/${asset}"
78+
dest="$EIDOLONS_BIN_DIR/yq"
79+
80+
mkdir -p "$EIDOLONS_BIN_DIR"
81+
say "Downloading yq ${EIDOLONS_YQ_VERSION} (${os}/${arch}) → $dest"
82+
83+
if command -v curl >/dev/null 2>&1; then
84+
curl -fsSL "$url" -o "$dest" || die "Failed to download yq from $url"
85+
elif command -v wget >/dev/null 2>&1; then
86+
wget -qO "$dest" "$url" || die "Failed to download yq from $url"
87+
else
88+
die "Need curl or wget to auto-install yq. Install yq manually: https://github.com/mikefarah/yq/releases"
89+
fi
90+
91+
chmod +x "$dest"
92+
"$dest" --version >/dev/null 2>&1 || die "yq binary at $dest failed to execute. Remove it and install yq manually."
93+
ok "yq installed to $dest"
94+
}
95+
96+
if command -v yq >/dev/null 2>&1; then
97+
ok "yq already present ($(command -v yq))"
98+
else
99+
install_yq
100+
fi
101+
63102
# ─── Install the nexus ─────────────────────────────────────────────────────
64103
mkdir -p "$EIDOLONS_HOME" "$EIDOLONS_BIN_DIR"
65104

cli/src/add.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,4 @@ done
7171

7272
# ─── Delegate install to sync ────────────────────────────────────────────
7373
say "Running sync"
74-
exec bash "$CLI_SRC/sync.sh" ${NON_INTERACTIVE:+--non-interactive}
74+
exec bash "$SELF_DIR/sync.sh" ${NON_INTERACTIVE:+--non-interactive}

cli/src/doctor.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ else
112112
warn "$ERRORS issue(s) found."
113113
if [[ "$FIX" == "true" ]]; then
114114
say "Attempting repairs via 'eidolons sync'..."
115-
exec bash "$CLI_SRC/sync.sh"
115+
exec bash "$SELF_DIR/sync.sh"
116116
else
117117
echo ""
118118
echo "Run 'eidolons doctor --fix' to attempt repairs, or 'eidolons sync' manually."

cli/src/init.sh

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ fi
6868
# ─── Resolve members ──────────────────────────────────────────────────────
6969
resolve_members() {
7070
if [[ -n "$PRESET" ]]; then
71-
roster_preset_members "$PRESET" | paste -sd,
71+
roster_preset_members "$PRESET" | paste -sd, -
7272
return
7373
fi
7474
if [[ -n "$MEMBERS" ]]; then
@@ -88,7 +88,7 @@ resolve_members() {
8888
echo ""
8989
read -rp "Enter preset name, or comma-separated members: " choice
9090
if roster_presets | grep -Fxq "$choice"; then
91-
roster_preset_members "$choice" | paste -sd,
91+
roster_preset_members "$choice" | paste -sd, -
9292
else
9393
echo "$choice"
9494
fi
@@ -112,7 +112,7 @@ if [[ "$HOSTS" == "auto" ]]; then
112112
HOSTS="claude-code,copilot,cursor,opencode"
113113
info "Greenfield project — wiring all major hosts"
114114
else
115-
detected="$(detect_hosts | paste -sd,)"
115+
detected="$(detect_hosts | paste -sd, -)"
116116
if [[ -z "$detected" ]]; then
117117
warn "No hosts detected. Defaulting to claude-code + copilot."
118118
HOSTS="claude-code,copilot"
@@ -154,4 +154,4 @@ ok "$PROJECT_MANIFEST written"
154154

155155
# ─── Delegate actual install to `eidolons sync` ──────────────────────────
156156
say "Running sync to install members"
157-
exec bash "$CLI_SRC/sync.sh" ${NON_INTERACTIVE:+--non-interactive}
157+
exec bash "$SELF_DIR/sync.sh" ${NON_INTERACTIVE:+--non-interactive}

cli/src/lib.sh

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,14 @@ yaml_to_json() {
4040
else
4141
yq . "$file"
4242
fi
43-
elif command -v python3 >/dev/null 2>&1; then
43+
elif command -v python3 >/dev/null 2>&1 && python3 -c "import yaml" >/dev/null 2>&1; then
4444
python3 -c "import sys, json, yaml; print(json.dumps(yaml.safe_load(open(sys.argv[1]))))" "$file"
4545
else
46-
die "Need yq or python3 to parse YAML. Install yq: https://github.com/mikefarah/yq"
46+
die "Cannot parse YAML: neither yq nor python3+PyYAML is available.
47+
Fix (recommended): rerun the bootstrap installer to auto-install yq:
48+
curl -sSL https://raw.githubusercontent.com/Rynaro/eidolons/main/cli/install.sh | bash
49+
Or install yq manually: https://github.com/mikefarah/yq/releases
50+
Or install PyYAML: pip install --user pyyaml"
4751
fi
4852
}
4953

@@ -84,7 +88,10 @@ detect_hosts() {
8488
[[ -d ".github" || -f "AGENTS.md" ]] && hosts+=("copilot")
8589
[[ -d ".cursor" || -f ".cursorrules" ]] && hosts+=("cursor")
8690
[[ -d ".opencode" ]] && hosts+=("opencode")
87-
printf "%s\n" "${hosts[@]}"
91+
if (( ${#hosts[@]} > 0 )); then
92+
printf "%s\n" "${hosts[@]}"
93+
fi
94+
return 0
8895
}
8996

9097
# ─── Eidolon repo fetching ────────────────────────────────────────────────

0 commit comments

Comments
 (0)