diff --git a/.gitignore b/.gitignore index e6e4972..13f3ae5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .DS_Store .agent-lock.json +__pycache__/ +*.pyc diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9768956 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +.PHONY: help config-sync config-sync-dry config-sync-diff + +help: + @echo "Targets:" + @echo " make config-sync Sync ~/.config/codeman/config.env with config/config.env.example (prompts on deletions)" + @echo " make config-sync-dry Same as config-sync, but doesn't write" + @echo " make config-sync-diff Print a diff of the proposed changes (may include secrets)" + +config-sync: + @python3 scripts/sync_config_env.py --template config/config.env.example --config "$$HOME/.config/codeman/config.env" + +config-sync-dry: + @python3 scripts/sync_config_env.py --template config/config.env.example --config "$$HOME/.config/codeman/config.env" --dry-run + +config-sync-diff: + @python3 scripts/sync_config_env.py --template config/config.env.example --config "$$HOME/.config/codeman/config.env" --dry-run --show-diff + diff --git a/README.md b/README.md index 0c8feae..2a172c6 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,12 @@ If Slack or Discord webhook is configured, Codeman can notify when: ``` 3. Persist it so it works in every terminal - Add the `export ...` line(s) to `~/.zshrc` (or `~/.bashrc`) + - Or add them to `~/.config/codeman/config.env` (sourced automatically by `codeman`) + - Template: `config/config.env.example` + - Optional: keep it in sync with the template: + ```bash + make config-sync + ``` - Then reload your shell: ```bash source ~/.zshrc @@ -131,7 +137,7 @@ If Slack or Discord webhook is configured, Codeman can notify when: - If you see `πŸ”• Webhook is configured, but notifications are disabled...`, remove `-N` or unset `CODEMAN_NOTIFY_DISABLED`. 5. Choose when notifications should fire (optional) ```bash - export CODEMAN_NOTIFY_ON='wait,complete' + export CODEMAN_NOTIFY_ON='wait,reply,complete' ``` 6. Customize the label (optional) ```bash @@ -161,8 +167,9 @@ Optional controls: export CODEMAN_NOTIFY_PREFIX='MBP-Blue' export CODEMAN_NOTIFY_HOST_CODENAME='MBP-Blue' export CODEMAN_NOTIFY_PROJECT_CODENAME='HiveCore' -export CODEMAN_NOTIFY_ON='wait,complete' +export CODEMAN_NOTIFY_ON='wait,reply,complete' export CODEMAN_NOTIFY_COOLDOWN_SEC=30 +export CODEMAN_NOTIFY_REPLY_COOLDOWN_SEC=0 export CODEMAN_NOTIFY_DISABLED=1 ``` diff --git a/bin/codeman b/bin/codeman index 7dc33ed..3dc5802 100755 --- a/bin/codeman +++ b/bin/codeman @@ -26,6 +26,7 @@ CONFIG_FILE="$CONFIG_DIR/config.env" CODEMAN_NOTIFY_LAST_WAIT=0 CODEMAN_NOTIFY_LAST_COMPLETE=0 +CODEMAN_NOTIFY_LAST_REPLY=0 CODEMAN_NOTIFY_SUPPRESS=0 CODEMAN_CONFIRM="${CODEMAN_CONFIRM:-1}" CODEMAN_SAVED_NOTIFY_PREFIX="${CODEMAN_SAVED_NOTIFY_PREFIX:-}" @@ -274,6 +275,9 @@ HIGH-RISK WARNING 🚨 WEBHOOK NOTIFICATIONS πŸ”” Set CODEMAN_SLACK_WEBHOOK_URL and/or CODEMAN_DISCORD_WEBHOOK_URL. + You can set these in your shell (e.g. ~/.zshrc) or in: + ~/.config/codeman/config.env + (override the directory with CODEMAN_CONFIG_DIR). Triggered on: - wait/approval signals (sandbox denied, permission issues) - task completion @@ -282,8 +286,9 @@ 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,reply,complete - CODEMAN_NOTIFY_COOLDOWN_SEC=30 + - CODEMAN_NOTIFY_REPLY_COOLDOWN_SEC=0 - CODEMAN_NOTIFY_DISABLED=1 Runtime flags: @@ -530,7 +535,7 @@ mode_flags() { event_enabled() { local event="$1" - local configured="${CODEMAN_NOTIFY_ON:-wait,complete}" + local configured="${CODEMAN_NOTIFY_ON:-wait,reply,complete}" case ",$configured," in *",$event,"*) return 0 ;; *) return 1 ;; @@ -610,7 +615,12 @@ notify_with_cooldown() { local message="$2" local cooldown now last - cooldown="${CODEMAN_NOTIFY_COOLDOWN_SEC:-30}" + # reply is typically wanted on every assistant response, so default it to no cooldown. + if [ "$kind" = "reply" ]; then + cooldown="${CODEMAN_NOTIFY_REPLY_COOLDOWN_SEC:-0}" + else + cooldown="${CODEMAN_NOTIFY_COOLDOWN_SEC:-30}" + fi now="$(date +%s)" if [ "$kind" = "wait" ]; then @@ -619,6 +629,12 @@ notify_with_cooldown() { return 0 fi CODEMAN_NOTIFY_LAST_WAIT="$now" + elif [ "$kind" = "reply" ]; then + last="$CODEMAN_NOTIFY_LAST_REPLY" + if [ $((now - last)) -lt "$cooldown" ]; then + return 0 + fi + CODEMAN_NOTIFY_LAST_REPLY="$now" else last="$CODEMAN_NOTIFY_LAST_COMPLETE" if [ $((now - last)) -lt "$cooldown" ]; then @@ -632,7 +648,8 @@ notify_with_cooldown() { latest_session_file() { # shellcheck disable=SC2012 - ls -1t "$HOME"/.codex/sessions/*/*/*/*.jsonl 2>/dev/null | head -n 1 || true + local codex_home="${CODEX_HOME:-$HOME/.codex}" + ls -1t "$codex_home"/sessions/*/*/*/*.jsonl 2>/dev/null | head -n 1 || true } handle_session_line() { @@ -652,6 +669,11 @@ handle_session_line() { if [ "$evt" = "task_complete" ] && event_enabled complete; then notify_with_cooldown complete "βœ… ${prefix} πŸ“ ${project} πŸš€ Codeman task complete (mode: ${mode})." fi + # Fired when the assistant produces a user-visible message. This is the closest thing + # to "finished responding" while staying in an interactive Codex session. + if [ "$evt" = "agent_message" ] && event_enabled reply; then + notify_with_cooldown reply "πŸ’¬ ${prefix} πŸ“ ${project} βœ… Codex replied (mode: ${mode})." + fi ;; response_item) evt="$(printf '%s\n' "$line" | jq -r '.payload.type // empty' 2>/dev/null || true)" @@ -857,6 +879,16 @@ run_codex() { fi set -e + # Codex sessions currently don't emit a reliable "task_complete" event in the jsonl. + # Send a completion notification based on the process exit instead. + if event_enabled complete; then + if [ "${rc:-1}" -eq 0 ]; then + notify_with_cooldown complete "βœ… ${prefix} πŸ“ ${project} πŸš€ Codeman run complete (mode: ${mode})." + else + notify_with_cooldown complete "❌ ${prefix} πŸ“ ${project} πŸ’₯ Codeman exited with code ${rc} (mode: ${mode})." + fi + fi + : > "$stop_file" wait "$monitor_pid" 2>/dev/null || true rm -f "$stop_file" diff --git a/config/config.env.example b/config/config.env.example new file mode 100644 index 0000000..263fc32 --- /dev/null +++ b/config/config.env.example @@ -0,0 +1,29 @@ +# Codeman config template (sourced by: ~/.config/codeman/config.env) +# This file uses shell syntax: KEY=VALUE. Quotes are recommended for strings/URLs. +# +# Install: +# mkdir -p ~/.config/codeman +# cp /path/to/codeman/config/config.env.example ~/.config/codeman/config.env +# +# Security: +# Treat webhook URLs like secrets. If one leaks, rotate it immediately. + +# Pick one (or set both) +# CODEMAN_SLACK_WEBHOOK_URL='https://hooks.slack.com/services/...' +CODEMAN_DISCORD_WEBHOOK_URL='https://discord.com/api/webhooks/...' + +# When to notify: wait (needs approval), reply (assistant responded), complete (run ended) +CODEMAN_NOTIFY_ON='wait,reply,complete' + +# Cooldowns (seconds) +# CODEMAN_NOTIFY_COOLDOWN_SEC=30 +CODEMAN_NOTIFY_REPLY_COOLDOWN_SEC=0 + +# Labels +# CODEMAN_NOTIFY_PREFIX='MBP-Blue' +# CODEMAN_NOTIFY_HOST_CODENAME='MBP-Blue' +# CODEMAN_NOTIFY_PROJECT_CODENAME='HiveCore' + +# Disable notifications globally +# CODEMAN_NOTIFY_DISABLED=1 + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b7e7664 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,23 @@ +# Codeman Docs + +This folder contains the static website published via GitHub Pages. + +## Notifications Config + +Codeman loads configuration from: + +- Shell environment variables (for example in `~/.zshrc` or `~/.bashrc`) +- `~/.config/codeman/config.env` (sourced automatically by `codeman`; override dir with `CODEMAN_CONFIG_DIR`) + +Template for `config.env` lives in the repo: + +- `config/config.env.example` + +To sync a local `~/.config/codeman/config.env` with template updates (adds new keys, prompts before deleting unknown keys): + +```bash +make config-sync +``` + +Note: `make config-sync-diff` prints a diff that may include secrets (webhook URLs). + diff --git a/docs/diagrams/config-env-sync.md b/docs/diagrams/config-env-sync.md new file mode 100644 index 0000000..9684b7e --- /dev/null +++ b/docs/diagrams/config-env-sync.md @@ -0,0 +1,16 @@ +# Config Env Sync (Template β†’ Local) + +```mermaid +flowchart TD + T[config/config.env.example
Repo template] -->|make config-sync| S[scripts/sync_config_env.py] + S -->|reads existing (if present)| C[(~/.config/codeman/config.env)] + S -->|writes updated file
keeps template order/comments| C + S -->|optional: dry-run/diff| O[stdout / diff output] + + %% Legend / Notes + subgraph Notes + N1[Unknown keys in local config: prompt before deletion (default keep)] + N2[Diff output may include secrets (webhook URLs)] + end +``` + diff --git a/docs/index.html b/docs/index.html index eb1bfd7..6929bf2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -244,11 +244,12 @@

Resume by session UUID

-
-
-

Slack/Discord notifications

-

Baby steps. It either works or it tells you exactly why it doesn’t.

-
+
+
+

Slack/Discord notifications

+

Baby steps. It either works or it tells you exactly why it doesn’t.

+

Notifications only fire when you run Codex via codeman (for example codeman l3), not codex directly.

+
@@ -258,43 +259,74 @@

Create a webhook URL

Slack: Incoming Webhook. Discord: channel webhook.

-
-
2
-
-

Export it in your shell

-
- -
export CODEMAN_DISCORD_WEBHOOK_URL='https://discord.com/api/webhooks/...'
-export CODEMAN_SLACK_WEBHOOK_URL='https://hooks.slack.com/services/...'
-
-
-
-
-
3
-
-

Persist it

-

Put the exports into ~/.zshrc (or ~/.bashrc), then reload.

-
- -
source ~/.zshrc
-
-
-
-
-
4
-
-

Test

+
+
2
+
+

Set the webhook URL

+

+ Put it in your shell (~/.zshrc/~/.bashrc) or in + ~/.config/codeman/config.env (sourced automatically by codeman). +

+
+ +
# Option A: shell
+export CODEMAN_DISCORD_WEBHOOK_URL='https://discord.com/api/webhooks/...'
+export CODEMAN_SLACK_WEBHOOK_URL='https://hooks.slack.com/services/...'
+
+# Option B: ~/.config/codeman/config.env (no export needed)
+CODEMAN_DISCORD_WEBHOOK_URL='https://discord.com/api/webhooks/...'
+CODEMAN_SLACK_WEBHOOK_URL='https://hooks.slack.com/services/...'
+
+
+
+
+
3
+
+

Persist it

+

+ Shell: put the exports into ~/.zshrc (or ~/.bashrc), then reload. + For config.env, use the template in the repo at config/config.env.example + (it’s sourced on every codeman run). Optionally keep it synced with template updates: +

+
+ +
# shell
+source ~/.zshrc
+
+# in a repo checkout (optional)
+make config-sync
+
+
+
+
+
4
+
+

Test

codeman notify-test

If you see ℹ️ No Slack/Discord integration configured, the env var isn’t set in this shell. -

-
-
-
-
+

+ + +
+
5
+
+

Choose when notifications fire (optional)

+

+ reply notifies when Codex produces a user-visible assistant message (useful for interactive sessions). +

+
+ +
export CODEMAN_NOTIFY_ON='wait,reply,complete'
+export CODEMAN_NOTIFY_REPLY_COOLDOWN_SEC=0
+
+
+
+ +
diff --git a/scripts/sync_config_env.py b/scripts/sync_config_env.py new file mode 100644 index 0000000..42cfc72 --- /dev/null +++ b/scripts/sync_config_env.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +""" +Sync a codeman config env file (~/.config/codeman/config.env) with the repository +template (config/config.env.example). + +Goals: +- Keep the template's structure/comments/order. +- For each KEY defined in the template (commented or not), preserve the user's + value/commented-state if present; otherwise keep template defaults. +- For keys present in the user's config but not in the template, ask before + deleting (default: keep). +""" + +from __future__ import annotations + +import argparse +import dataclasses +import datetime as _dt +import difflib +import os +import re +import shutil +import sys +from typing import Dict, List, Optional, Tuple + + +_ASSIGN_RE = re.compile(r"^\s*(?Pexport\s+)?(?P[A-Za-z_][A-Za-z0-9_]*)=(?P.*)\s*$") +_COMMENTED_ASSIGN_RE = re.compile( + r"^\s*#\s*(?Pexport\s+)?(?P[A-Za-z_][A-Za-z0-9_]*)=(?P.*)\s*$" +) + + +@dataclasses.dataclass(frozen=True) +class AssignLine: + key: str + val: str # raw RHS, including any quotes + commented: bool + exported: bool + raw: str # original line + + +@dataclasses.dataclass(frozen=True) +class TemplateLine: + raw: str + assign: Optional[AssignLine] = None + + +def _mask_value(val: str) -> str: + v = val.strip() + if not v: + return v + # Keep simple non-secret-ish small values visible; mask URLs/tokens. + if "http://" in v or "https://" in v or "webhook" in v.lower(): + return "" + if len(v) > 24: + return v[:3] + "<...>" + v[-3:] + return v + + +def _parse_assign(line: str) -> Optional[AssignLine]: + m = _ASSIGN_RE.match(line) + if m: + return AssignLine( + key=m.group("key"), + val=m.group("val"), + commented=False, + exported=bool(m.group("export")), + raw=line.rstrip("\n"), + ) + m = _COMMENTED_ASSIGN_RE.match(line) + if m: + return AssignLine( + key=m.group("key"), + val=m.group("val"), + commented=True, + exported=bool(m.group("export")), + raw=line.rstrip("\n"), + ) + return None + + +def _read_lines(path: str) -> List[str]: + try: + with open(path, "r", encoding="utf-8") as f: + return f.read().splitlines(True) + except FileNotFoundError: + return [] + + +def _parse_template(path: str) -> Tuple[List[TemplateLine], List[str]]: + raw_lines = _read_lines(path) + if not raw_lines: + raise SystemExit(f"Template not found or empty: {path}") + + out: List[TemplateLine] = [] + keys: List[str] = [] + for ln in raw_lines: + a = _parse_assign(ln) + out.append(TemplateLine(raw=ln.rstrip("\n"), assign=a)) + if a: + keys.append(a.key) + return out, keys + + +def _parse_user_config(path: str) -> Dict[str, AssignLine]: + d: Dict[str, AssignLine] = {} + for ln in _read_lines(path): + a = _parse_assign(ln) + if not a: + continue + # Last one wins (common shell behavior). + d[a.key] = a + return d + + +def _render_assign(key: str, val: str, commented: bool, exported: bool) -> str: + prefix = "" + if commented: + prefix = "# " + exp = "export " if exported else "" + return f"{prefix}{exp}{key}={val}".rstrip() + + +def _prompt_choice(prompt: str, choices: Dict[str, str], default_key: str) -> str: + # choices: key -> label + keys = "/".join([k.upper() if k == default_key else k for k in choices.keys()]) + while True: + sys.stderr.write(f"{prompt} [{keys}]: ") + sys.stderr.flush() + ans = sys.stdin.readline() + if not ans: + return default_key + ans = ans.strip().lower() + if not ans: + return default_key + if ans in choices: + return ans + sys.stderr.write(f"Invalid choice: {ans}\n") + + +def _backup_file(path: str) -> Optional[str]: + if not os.path.exists(path): + return None + ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S") + bak = f"{path}.bak.{ts}" + shutil.copy2(path, bak) + return bak + + +def _compute_new_content( + template_lines: List[TemplateLine], + template_keys: List[str], + user: Dict[str, AssignLine], + keep_unknown_keys: List[str], +) -> List[str]: + template_keyset = set(template_keys) + keep_unknown_set = set(keep_unknown_keys) + + rendered: List[str] = [] + for t in template_lines: + if not t.assign: + rendered.append(t.raw) + continue + + key = t.assign.key + # Pull user override if available; else template default. + if key in user: + u = user[key] + rendered.append(_render_assign(key, u.val, u.commented, u.exported)) + else: + rendered.append(_render_assign(key, t.assign.val, t.assign.commented, t.assign.exported)) + + # Append unknown keys we decided to keep. + unknown_kept = [k for k in user.keys() if k not in template_keyset and k in keep_unknown_set] + if unknown_kept: + rendered.append("") + rendered.append("# --- User-defined (not in template) ---") + for k in sorted(unknown_kept): + u = user[k] + rendered.append(_render_assign(k, u.val, u.commented, u.exported)) + + # Ensure newline at EOF. + return [ln + "\n" for ln in rendered] + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--template", default="config/config.env.example") + ap.add_argument("--config", default=os.path.expanduser("~/.config/codeman/config.env")) + ap.add_argument("--dry-run", action="store_true", help="Don't write, just report what would change.") + ap.add_argument( + "--show-diff", + action="store_true", + help="Print a unified diff to stdout (may include secrets).", + ) + ap.add_argument( + "--non-interactive", + action="store_true", + help="Never prompt; keep unknown keys (no deletions).", + ) + args = ap.parse_args() + + template_lines, template_keys = _parse_template(args.template) + template_keyset = set(template_keys) + user = _parse_user_config(args.config) + + unknown_keys = sorted([k for k in user.keys() if k not in template_keyset]) + keep_unknown: List[str] = unknown_keys[:] # default: keep all + + if unknown_keys and not args.non_interactive: + sys.stderr.write("Found keys in your config that are not in the template:\n") + for k in unknown_keys: + sys.stderr.write(f" - {k}={_mask_value(user[k].val)}\n") + + choice = _prompt_choice( + "Delete unknown keys not present in the template?", + choices={"k": "keep all", "d": "delete all", "i": "decide one-by-one"}, + default_key="k", + ) + if choice == "d": + keep_unknown = [] + elif choice == "i": + keep_unknown = [] + for k in unknown_keys: + c = _prompt_choice( + f"Keep {k}?", + choices={"y": "keep", "n": "delete"}, + default_key="y", + ) + if c == "y": + keep_unknown.append(k) + else: + keep_unknown = unknown_keys[:] + + old_lines = _read_lines(args.config) + new_lines = _compute_new_content(template_lines, template_keys, user, keep_unknown) + + changed = old_lines != new_lines + if not changed: + sys.stderr.write("No changes needed.\n") + return 0 + + if args.show_diff: + diff = difflib.unified_diff( + old_lines, + new_lines, + fromfile=args.config, + tofile=f"{args.config} (synced)", + lineterm="", + ) + for ln in diff: + print(ln) + + if args.dry_run: + sys.stderr.write("Dry run: config would be updated.\n") + return 0 + + os.makedirs(os.path.dirname(args.config), exist_ok=True) + bak = _backup_file(args.config) + with open(args.config, "w", encoding="utf-8") as f: + f.writelines(new_lines) + if bak: + sys.stderr.write(f"Updated {args.config} (backup: {bak}).\n") + else: + sys.stderr.write(f"Created {args.config}.\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +