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
33 changes: 31 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,47 @@ permissions:
contents: read

jobs:
shell:
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Bash syntax check
run: |
bash -n bin/codeman
bash -n install.sh

- name: ShellCheck (best-effort)
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
shellcheck -x bin/codeman
shellcheck -x bin/codeman install.sh

bats:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4

- name: Install bats
run: |
if [ "${RUNNER_OS}" = "Linux" ]; then
sudo apt-get update
sudo apt-get install -y bats
elif [ "${RUNNER_OS}" = "macOS" ]; then
brew install bats-core
else
echo "Unsupported OS: ${RUNNER_OS}" >&2
exit 2
fi

- name: Show bash version
run: |
bash --version | head -n 1

- name: Run bats tests
run: |
bats -r tests
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0
0.1.3
93 changes: 80 additions & 13 deletions bin/codeman
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
VERSION_FILE="$REPO_ROOT/VERSION"
CONFIG_DIR="${CODEMAN_CONFIG_DIR:-$HOME/.config/codeman}"
CONFIG_FILE="$CONFIG_DIR/config.env"
CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}"

CODEMAN_NOTIFY_LAST_WAIT=0
CODEMAN_NOTIFY_LAST_COMPLETE=0
CODEMAN_NOTIFY_LAST_MESSAGE=0
CODEMAN_NOTIFY_SUPPRESS=0
CODEMAN_CONFIRM="${CODEMAN_CONFIRM:-1}"
CODEMAN_SAVED_NOTIFY_PREFIX="${CODEMAN_SAVED_NOTIFY_PREFIX:-}"
Expand Down Expand Up @@ -160,7 +162,11 @@ confirm_mode_if_needed() {
esac

local -a cmd_lines
mapfile -t cmd_lines < <(printf '%s\n' "$cmd_str" | fold -s -w 96)
cmd_lines=()
# Bash 3.2 (macOS default) doesn't have `mapfile`.
while IFS= read -r _line; do
cmd_lines+=("$_line")
done < <(printf '%s\n' "$cmd_str" | fold -s -w 96)

local -a lines
lines+=("$status_line")
Expand Down Expand Up @@ -282,7 +288,7 @@ WEBHOOK NOTIFICATIONS 🔔
- CODEMAN_NOTIFY_PREFIX="HOST-CODENAME"
- CODEMAN_NOTIFY_HOST_CODENAME="HOST-CODENAME"
- CODEMAN_NOTIFY_PROJECT_CODENAME="PROJECT-CODENAME"
- CODEMAN_NOTIFY_ON=wait,complete
- CODEMAN_NOTIFY_ON=wait,complete[,message]
- CODEMAN_NOTIFY_COOLDOWN_SEC=30
- CODEMAN_NOTIFY_DISABLED=1

Expand Down Expand Up @@ -610,6 +616,13 @@ notify_with_cooldown() {
local message="$2"
local cooldown now last

# Message notifications are opt-in via CODEMAN_NOTIFY_ON and should not be
# throttled by default (users usually want one ping per assistant message).
if [ "$kind" = "message" ]; then
send_notification_message "$message"
return 0
fi

cooldown="${CODEMAN_NOTIFY_COOLDOWN_SEC:-30}"
now="$(date +%s)"

Expand All @@ -632,7 +645,7 @@ notify_with_cooldown() {

latest_session_file() {
# shellcheck disable=SC2012
ls -1t "$HOME"/.codex/sessions/*/*/*/*.jsonl 2>/dev/null | head -n 1 || true
ls -1t "$CODEX_HOME_DIR"/sessions/*/*/*/*.jsonl 2>/dev/null | head -n 1 || true
}

handle_session_line() {
Expand All @@ -642,22 +655,65 @@ handle_session_line() {
local project="$4"
local line_type evt out

command -v jq >/dev/null 2>&1 || return 0

line_type="$(printf '%s\n' "$line" | jq -r '.type // empty' 2>/dev/null || true)"
# Prefer jq when available, but fall back to string matching so notifications
# still work on systems where jq isn't on PATH.
if command -v jq >/dev/null 2>&1; then
line_type="$(printf '%s\n' "$line" | jq -r '.type // empty' 2>/dev/null || true)"
else
case "$line" in
*'"type":"event_msg"'*) line_type="event_msg" ;;
*'"type":"response_item"'*) line_type="response_item" ;;
*) return 0 ;;
esac
fi

case "$line_type" in
event_msg)
evt="$(printf '%s\n' "$line" | jq -r '.payload.type // empty' 2>/dev/null || true)"
if command -v jq >/dev/null 2>&1; then
evt="$(printf '%s\n' "$line" | jq -r '.payload.type // empty' 2>/dev/null || true)"
else
case "$line" in
*'"payload":{"type":"task_complete"'*) evt="task_complete" ;;
*'"payload":{"type":"agent_message"'*) evt="agent_message" ;;
*) evt="" ;;
esac
fi
if [ "$evt" = "task_complete" ] && event_enabled complete; then
notify_with_cooldown complete "✅ ${prefix} 📁 ${project} 🚀 Codeman task complete (mode: ${mode})."
fi
if [ "$evt" = "agent_message" ] && event_enabled message; then
local msg
if command -v jq >/dev/null 2>&1; then
msg="$(printf '%s\n' "$line" | jq -r '.payload.message // empty' 2>/dev/null || true)"
else
msg=""
fi

# Keep the ping readable: flatten whitespace and clip length.
msg="$(printf '%s' "$msg" | tr '\r\n' ' ' | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')"
if [ -z "$msg" ]; then
notify_with_cooldown message "💬 ${prefix} 📁 ${project} New Codex message (mode: ${mode})."
else
if [ "${#msg}" -gt 200 ]; then
msg="${msg:0:200}..."
fi
notify_with_cooldown message "💬 ${prefix} 📁 ${project} ${msg}"
fi
fi
;;
response_item)
evt="$(printf '%s\n' "$line" | jq -r '.payload.type // empty' 2>/dev/null || true)"
if [ "$evt" = "function_call_output" ] && event_enabled wait; then
out="$(printf '%s\n' "$line" | jq -r '.payload.output // ""' 2>/dev/null || true)"
if printf '%s' "$out" | grep -Eiq 'SandboxDenied|require_escalated|Operation not permitted|permission denied|ask for approval|request approval'; then
if command -v jq >/dev/null 2>&1; then
evt="$(printf '%s\n' "$line" | jq -r '.payload.type // empty' 2>/dev/null || true)"
if [ "$evt" = "function_call_output" ] && event_enabled wait; then
out="$(printf '%s\n' "$line" | jq -r '.payload.output // \"\"' 2>/dev/null || true)"
if printf '%s' "$out" | grep -Eiq 'SandboxDenied|require_escalated|Operation not permitted|permission denied|ask for approval|request approval'; then
notify_with_cooldown wait "🚨 ${prefix} 📁 ${project} ⏳ Codeman is waiting for your input/approval (mode: ${mode})."
fi
fi
else
# Without jq, just look for the denial markers anywhere in the line.
# This keeps "wait" notifications working even in minimal PATH envs.
if event_enabled wait && printf '%s' "$line" | grep -Eiq 'SandboxDenied|require_escalated|Operation not permitted|permission denied|ask for approval|request approval'; then
notify_with_cooldown wait "🚨 ${prefix} 📁 ${project} ⏳ Codeman is waiting for your input/approval (mode: ${mode})."
fi
fi
Expand Down Expand Up @@ -812,7 +868,12 @@ run_codex() {
mode="balanced"
fi

mapfile -t flags < <(mode_flags "$mode")
# Bash 3.2 (macOS default) doesn't have `mapfile`.
flags=()
while IFS= read -r _flag; do
[ -n "$_flag" ] || continue
flags+=("$_flag")
done < <(mode_flags "$mode")

local notify_text
notify_text="$(print_notify_status)"
Expand Down Expand Up @@ -895,7 +956,13 @@ while [ "$#" -gt 0 ]; do
;;
esac
done
set -- "${PARSED_ARGS[@]}"
# Bash 3.2 + `set -u` treats expanding an empty array as "unbound variable".
# Only expand when non-empty to support running `codeman` with no args.
if [ "${#PARSED_ARGS[@]}" -gt 0 ]; then
set -- "${PARSED_ARGS[@]}"
else
set --
fi

cmd_word="${1:-}"
case "$cmd_word" in
Expand Down
73 changes: 73 additions & 0 deletions tests/test_codeman.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env bats

@test "codeman (no args) prints mode picker and does not crash" {
run ./bin/codeman

# codeman intentionally exits 2 when no args are provided
[ "$status" -eq 2 ]

[[ "$output" == *"Pick a mode explicitly."* ]]
[[ "$output" == *"Available modes:"* ]]

# Regression guard for Bash 3.2 + `set -u` empty-array expansion.
[[ "$output" != *"unbound variable"* ]]
[[ "$output" != *"PARSED_ARGS[@]"* ]]
}

@test "codeman help prints usage" {
run ./bin/codeman help
[ "$status" -eq 0 ]
[[ "$output" == *"USAGE"* ]]
[[ "$output" == *"codeman"* ]]
}

@test "codeman completion zsh prints a completion script" {
run ./bin/codeman completion zsh
[ "$status" -eq 0 ]
[[ "$output" == *"#compdef codeman"* ]]
}

@test "codeman mode run works on bash 3.2 (no mapfile) with a stubbed codex" {
tmp="$(mktemp -d)"
mkdir -p "$tmp/bin"
cat >"$tmp/bin/codex" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
echo "STUB CODEX OK"
EOF
chmod +x "$tmp/bin/codex"

# Avoid confirmations/notifications; just verify the mode path doesn't crash.
HOME="$tmp" PATH="$tmp/bin:$PATH" run ./bin/codeman -N -y l3 "hello"
[ "$status" -eq 0 ]
[[ "$output" == *"STUB CODEX OK"* ]]
}

@test "codeman sends per-message notifications when CODEMAN_NOTIFY_ON includes message (no jq required)" {
tmp="$(mktemp -d)"
mkdir -p "$tmp/bin"
mkdir -p "$tmp/.codex/sessions/2026/02/13"

# Stub curl to capture payloads.
cat >"$tmp/bin/curl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
echo "curl $*" >> "${HOME}/curl_calls.txt"
exit 0
EOF
chmod +x "$tmp/bin/curl"

# Stub codex to write an agent_message line into the session log.
cat >"$tmp/bin/codex" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
log="${HOME}/.codex/sessions/2026/02/13/stub.jsonl"
printf '%s\n' '{"type":"event_msg","payload":{"type":"agent_message","message":"hello from stub"}}' >> "$log"
sleep 2
EOF
chmod +x "$tmp/bin/codex"

HOME="$tmp" PATH="$tmp/bin:$PATH" CODEMAN_NOTIFY_POLL_SEC=1 CODEMAN_NOTIFY_ON=message CODEMAN_DISCORD_WEBHOOK_URL='http://example.invalid' run ./bin/codeman -y l3 "hello"
[ "$status" -eq 0 ]
[ -f "$tmp/curl_calls.txt" ]
}
Loading