74a387b093
Adds the macOS Keychain as the lowest-priority credential source on Darwin.
Items stored as generic passwords with service name "last30days-<KEY>" for
the current user are picked up automatically by get_config() — file env
and process env still win on collision.
No new config knob: behavior is strictly additive. On non-Darwin (or when
the `security` binary is missing) the loader is a no-op, so Linux/Windows
behavior is unchanged.
Priority (highest wins):
1. Environment variables
2. .claude/last30days.env (per-project)
3. ~/.config/last30days/.env (global)
4. macOS Keychain items prefixed last30days- (new)
Includes:
- lib/env.py: KEYCHAIN_SERVICE_PREFIX constant, _load_keychain helper
(platform-gated, shutil.which-gated, subprocess-error tolerant),
wiring into get_config before get_openai_auth so OPENAI_API_KEY can
come from Keychain too, _CONFIG_SOURCE reports "keychain" when no
file source is present.
- scripts/setup-keychain.sh: bash helper with interactive set,
--list, --delete, --replace modes. Uses `security add-generic-password`.
- tests/test_env_keychain.py: 12 tests covering platform gate,
missing-binary gate, success path, whitespace stripping, subprocess
errors swallowed, get_config precedence, and an OPENAI_AUTH wiring
regression test.
- tests/test_env_cookies.py: existing integration test mocks the new
_load_keychain hook so it stays hermetic on Darwin developer
machines that have real keychain entries.
- README.md: new "macOS Keychain (optional)" subsection under
"Bring your own keys" documenting setup-keychain.sh and the manual
`security add-generic-password` invocation.
Tested on macOS with a populated keychain and against the existing pytest
suite — CI-tracked tests (test_plugin_contract.py, test_version_consistency.py)
plus all env-touching tests pass. Pre-existing unrelated failures in
test_store.py / test_watchlist_commands.py / test_setup_openclaw.py /
test_footer_nudge_suppression.py are untouched.
119 lines
3.0 KiB
Bash
Executable File
119 lines
3.0 KiB
Bash
Executable File
#!/bin/bash
|
|
# Store last30days API keys in the macOS Keychain.
|
|
#
|
|
# Keys are stored as generic passwords with service name `last30days-<KEY>`
|
|
# for the current user. The lib/env.py loader picks them up automatically as
|
|
# the lowest-priority credential source on Darwin.
|
|
#
|
|
# Usage:
|
|
# ./setup-keychain.sh # interactive: prompts for each key
|
|
# ./setup-keychain.sh KEY [KEY..] # prompt only for the listed keys
|
|
# ./setup-keychain.sh --list # list which last30days-* items exist
|
|
# ./setup-keychain.sh --delete KEY # remove a stored key
|
|
#
|
|
# Existing values are shown as "(set)" and skipped unless --replace is passed.
|
|
# Skip any prompt with empty input.
|
|
|
|
set -euo pipefail
|
|
|
|
PREFIX="last30days-"
|
|
ALL_KEYS=(
|
|
OPENAI_API_KEY
|
|
XAI_API_KEY
|
|
GOOGLE_API_KEY
|
|
GEMINI_API_KEY
|
|
SCRAPECREATORS_API_KEY
|
|
APIFY_API_TOKEN
|
|
AUTH_TOKEN
|
|
CT0
|
|
BSKY_HANDLE
|
|
BSKY_APP_PASSWORD
|
|
TRUTHSOCIAL_TOKEN
|
|
BRAVE_API_KEY
|
|
EXA_API_KEY
|
|
SERPER_API_KEY
|
|
OPENROUTER_API_KEY
|
|
PARALLEL_API_KEY
|
|
XQUIK_API_KEY
|
|
)
|
|
|
|
if [[ "${OSTYPE:-}" != darwin* ]]; then
|
|
echo "setup-keychain.sh requires macOS (security command). Got: $OSTYPE" >&2
|
|
exit 1
|
|
fi
|
|
if ! command -v security >/dev/null 2>&1; then
|
|
echo "security command not found on PATH" >&2
|
|
exit 1
|
|
fi
|
|
|
|
REPLACE=0
|
|
ACTION="prompt"
|
|
TARGETS=()
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--list) ACTION="list"; shift ;;
|
|
--delete) ACTION="delete"; shift ;;
|
|
--replace) REPLACE=1; shift ;;
|
|
--help|-h) sed -n '2,/^$/p' "$0" | sed 's/^# //; s/^#//'; exit 0 ;;
|
|
-*) echo "unknown flag: $1" >&2; exit 2 ;;
|
|
*) TARGETS+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
|
|
case "$ACTION" in
|
|
list)
|
|
echo "Stored last30days-* keychain items:"
|
|
for key in "${ALL_KEYS[@]}"; do
|
|
if security find-generic-password -a "$USER" -s "${PREFIX}${key}" -w >/dev/null 2>&1; then
|
|
echo " $key"
|
|
fi
|
|
done
|
|
exit 0
|
|
;;
|
|
delete)
|
|
if [[ ${#TARGETS[@]} -eq 0 ]]; then
|
|
echo "--delete needs at least one KEY name" >&2; exit 2
|
|
fi
|
|
for key in "${TARGETS[@]}"; do
|
|
if security delete-generic-password -a "$USER" -s "${PREFIX}${key}" >/dev/null 2>&1; then
|
|
echo "deleted: $key"
|
|
else
|
|
echo "not found: $key"
|
|
fi
|
|
done
|
|
exit 0
|
|
;;
|
|
esac
|
|
|
|
if [[ ${#TARGETS[@]} -eq 0 ]]; then
|
|
TARGETS=("${ALL_KEYS[@]}")
|
|
fi
|
|
|
|
added=0; skipped=0; replaced=0
|
|
for key in "${TARGETS[@]}"; do
|
|
existing="$(security find-generic-password -a "$USER" -s "${PREFIX}${key}" -w 2>/dev/null || true)"
|
|
if [[ -n "$existing" && "$REPLACE" -eq 0 ]]; then
|
|
printf " %-28s (set, skipping — use --replace to overwrite)\n" "$key"
|
|
skipped=$((skipped + 1))
|
|
continue
|
|
fi
|
|
printf " %-28s " "$key"
|
|
IFS= read -rs value
|
|
echo
|
|
if [[ -z "$value" ]]; then
|
|
skipped=$((skipped + 1))
|
|
continue
|
|
fi
|
|
security add-generic-password -U -a "$USER" -s "${PREFIX}${key}" -w "$value"
|
|
if [[ -n "$existing" ]]; then
|
|
replaced=$((replaced + 1))
|
|
else
|
|
added=$((added + 1))
|
|
fi
|
|
done
|
|
|
|
echo
|
|
echo "Done. added=$added replaced=$replaced skipped=$skipped"
|
|
echo "Verify with: $0 --list"
|