diff --git a/AGENTS.md b/AGENTS.md index bc7e4ad..7f3e965 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ The plugin follows the agent plugin structure with four component types: - `scripts/api-request.sh` — Request wrapper that handles settings discovery, authentication, SDK/skill tracking headers, and curl execution. Skills define a local `api()` function that delegates to this script: `api() { "$PLUGIN_ROOT/scripts/api-request.sh" "$@"; }`. Usage: `api [json_body]` or `api --env` for settings. Paths use `{ad_account_id}` as a placeholder (auto-substituted from settings). - **Hooks** — Per-platform hook configs invoking `hooks/check-token.sh` to automatically refresh expired OAuth tokens before Spotify API calls. `hooks.json` at the plugin root contains the Antigravity `PreToolUse` event, auto-discovered by both Antigravity CLI (`agy plugin install`) and Antigravity 2.0 (`.agents/plugins/` workspace discovery). `.claude-plugin/hooks.json` and `.codex-plugin/hooks.json` contain the Claude/Codex `PreToolUse` event, declared via the `hooks` field in each platform's `plugin.json`. Note: the hook payload and response formats differ across platforms — Claude/Codex use `.tool_input.command` and support command rewriting via `updatedInput`, while Antigravity uses `.toolCall.args.CommandLine` and only supports allow/deny with `decision`/`reason` (the hook refreshes the token in the settings file and tells the agent to re-read it). - **Commands** (`commands/configure.toml`) — An Antigravity CLI custom command exposing `/configure` as an explicit entry point to the configure skill. Other skills auto-activate on Antigravity via its native Agent Skills support. -- **Settings** (`.codex/spotify-ads-api.local.md`, `.claude/spotify-ads-api.local.md`, or `.agents/spotify-ads-api.local.md`, with each platform preferring its own file and falling back to the other two) — Per-user local config with YAML frontmatter storing OAuth credentials (access_token, refresh_token, client_id, token_expires_at), ad_account_id, and auto_execute. The client_secret is stored in the macOS Keychain (service: `spotify-ads-api-client-secret`, account: `spotify-ads-api`), not in this file. Template lives in `templates/settings-template.md`. These files are gitignored. +- **Settings** (`.codex/spotify-ads-api.local.md`, `.claude/spotify-ads-api.local.md`, or `.agents/spotify-ads-api.local.md`, with each platform preferring its own file and falling back to the other two) — Per-user local config with YAML frontmatter storing OAuth credentials (access_token, refresh_token, client_id, token_expires_at), ad_account_id, and auto_execute. The client_secret is stored in the OS credential store (macOS Keychain, Windows Credential Manager, or Linux secret storage via `secret-tool`; service: `spotify-ads-api-client-secret`, account: `spotify-ads-api`), not in this file. Template lives in `templates/settings-template.md`. These files are gitignored. ## Marketplace Compatibility diff --git a/ANTIGRAVITY.md b/ANTIGRAVITY.md index 1416029..4c25439 100644 --- a/ANTIGRAVITY.md +++ b/ANTIGRAVITY.md @@ -6,7 +6,7 @@ Manage Spotify ad campaigns with natural language. Capabilities are packaged as Read and write per-user configuration in `.agents/spotify-ads-api.local.md` (YAML frontmatter: `access_token`, `refresh_token`, `token_expires_at`, `client_id`, `ad_account_id`, `environment`, `auto_execute`). If that file does not exist, fall back to `.claude/spotify-ads-api.local.md`, then `.codex/spotify-ads-api.local.md`. -Never commit these files. The `client_secret` is stored in the macOS Keychain (service: `spotify-ads-api-client-secret`, account: `spotify-ads-api`), not in the settings file. +Never commit these files. The `client_secret` is stored in the OS credential store (macOS Keychain, Windows Credential Manager, or Linux secret storage; service: `spotify-ads-api-client-secret`, account: `spotify-ads-api`), not in the settings file. ## First-Time Setup diff --git a/README.md b/README.md index 3bcc284..ac7d09b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ Check out our post on the [Spotify Engineering Blog](https://engineering.atspoti - Codex, [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code), or [Antigravity CLI](https://antigravity.google/) - A [Spotify Developer](https://developer.spotify.com/) account with an ads-enabled app - A Spotify Ads ad account ID -- Python 3.8+ (for automated OAuth flow; optional — manual flow available as fallback) +- Python 3.8+ (for automated OAuth flow; optional — manual flow available as fallback). On Windows, ensure `python` or `py` is in your PATH. +- [Git for Windows](https://gitforwindows.org/) (Windows only; provides Git Bash, which the token refresh hook requires) +- [jq](https://jqlang.github.io/jq/) (recommended on Windows for richer hook output; the token refresh hook works without it but produces minimal JSON) ## Install @@ -41,15 +43,15 @@ Use `codex plugin marketplace upgrade` later to refresh installed marketplace so agy plugin install https://github.com/spotify/ads-agentic-tools ``` -Restart Antigravity CLI, then verify with `/plugins`. On Antigravity, skills activate automatically from natural language (or browse them with `/skills list`); run `/configure` for first-time setup instead of `/spotify-ads-api:configure`. Note: automatic OAuth token refresh uses the macOS Keychain, so auto-refresh is macOS-only. +Restart Antigravity CLI, then verify with `/plugins`. On Antigravity, skills activate automatically from natural language (or browse them with `/skills list`); run `/configure` for first-time setup instead of `/spotify-ads-api:configure`. ## Install from source Use a source checkout for local development or testing unreleased changes. -1. Clone the repository: +1. Clone the repository (on Windows, disable `autocrlf` so the bash hooks stay LF-only): ```bash - git clone https://github.com/spotify/ads-agentic-tools.git + git clone -c core.autocrlf=false https://github.com/spotify/ads-agentic-tools.git cd ads-agentic-tools ``` @@ -163,7 +165,7 @@ Settings are stored in `.codex/spotify-ads-api.local.md` on Codex, `.claude/spot | `ad_account_id` | Default ad account UUID | — | | `auto_execute` | Skip confirmation prompts | `false` | -The client secret is stored in the **macOS Keychain** (not in the settings file) for security. It is saved during `/spotify-ads-api:configure` and retrieved automatically by the token refresh hook. +The client secret is stored in your **OS credential store** (macOS Keychain, Windows Credential Manager, or Linux secret storage) for security — not in the settings file. It is saved during `/spotify-ads-api:configure` and retrieved automatically by the token refresh hook. ## Troubleshooting @@ -182,6 +184,15 @@ Your targeting is too narrow for the selected ad format. Try broadening the age **"Asset stuck in PROCESSING"** Large files may take longer to transcode. Check status with `/spotify-ads-api:assets get `. If status is REJECTED, the file may not meet format requirements. +**"secret-tool not found" (Linux)** +Install `libsecret-tools` for credential storage: `sudo apt install libsecret-tools` (Debian/Ubuntu) or `sudo dnf install libsecret` (Fedora). + +**"Python was not found" on Windows** +The `python3` command on stock Windows 11 is a Microsoft Store redirect stub, not real Python. It exits with code 49 and prints a Store install prompt. Install Python from [python.org](https://www.python.org/downloads/) or via `winget install Python.Python.3.13`, then use `python` or `py` (the plugin detects and skips the Store stub automatically). + +**Token refresh hook not running on Windows** +The hook (`hooks/check-token.sh`) is a bash script. On Windows it requires Git Bash, which is included with [Git for Windows](https://gitforwindows.org/). If the hook silently does nothing, ensure `bash.exe` is discoverable in your PATH (it should be after installing Git for Windows). + **Skill not activating on Antigravity CLI** Run `/skills list` to confirm the plugin's skills loaded, and `/plugins` to confirm the plugin is enabled. Restart Antigravity CLI after installing or linking. diff --git a/agents/spotify-ads-request-builder.md b/agents/spotify-ads-request-builder.md index e349310..d17db5d 100644 --- a/agents/spotify-ads-request-builder.md +++ b/agents/spotify-ads-request-builder.md @@ -169,7 +169,7 @@ api GET "targets/geos?country_code=US&q=&limit=20" The `api` wrapper appends `\nHTTP_STATUS:` to every response. Always check the `HTTP_STATUS:` line first before interpreting the response body. **Error Handling:** -- If the API returns a **401 Unauthorized**, the token is likely expired. If the plugin has OAuth credentials configured (refresh_token, client_id in settings, client_secret in keychain), the pre-tool hook should auto-refresh. If auto-refresh didn't occur, suggest running the configure skill (`/spotify-ads-api:configure` on Claude/Codex, `/configure` on Antigravity) to re-authenticate. +- If the API returns a **401 Unauthorized**, the token is likely expired. If the plugin has OAuth credentials configured (refresh_token, client_id in settings, client_secret in the OS credential store), the pre-tool hook should auto-refresh. If auto-refresh didn't occur, suggest running the configure skill (`/spotify-ads-api:configure` on Claude/Codex, `/configure` on Antigravity) to re-authenticate. - If the API returns other errors, read the error message and explain what went wrong in plain language - Suggest fixes for common errors (missing fields, budget too low, targeting too narrow, etc.) - Never retry automatically on 4xx errors — explain the issue to the user diff --git a/hooks/check-token.sh b/hooks/check-token.sh index 0f96335..e332e7a 100755 --- a/hooks/check-token.sh +++ b/hooks/check-token.sh @@ -24,11 +24,16 @@ if [[ "$input" != *"api-partner.spotify.com"* ]]; then exit 0 fi -# Need jq for JSON parsing -if ! command -v jq &>/dev/null; then - exit 0 +# Prefer jq for JSON; fall back to grep/sed for minimal parsing +HAS_JQ=false +if command -v jq &>/dev/null; then + HAS_JQ=true fi +json_extract_string() { + printf '%s' "$1" | sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1 +} + # Detect platform from env vars; Antigravity sets none of the known # *_PROJECT_DIR vars, so it falls through to the default. if [ -n "${CODEX_PROJECT_DIR:-}" ]; then @@ -59,17 +64,42 @@ find_settings_file() { done } +find_python() { + local cmd candidates + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) candidates="py python python3" ;; + *) candidates="python3 python py" ;; + esac + for cmd in $candidates; do + if command -v "$cmd" &>/dev/null && "$cmd" -c "import sys" &>/dev/null; then + printf '%s\n' "$cmd" + return + fi + done +} +PYTHON="$(find_python || true)" + # Extract the command from tool input (different field paths per platform) # Claude/Codex: .tool_input.command # Antigravity: .toolCall.args.CommandLine -command=$(printf '%s' "$input" | jq -r ' - .tool_input.command // - .tool_input.cmd // - .input.command // - .input.cmd // - .toolCall.args.CommandLine // - .toolCall.args.command // - ""') +if [ "$HAS_JQ" = true ]; then + command=$(printf '%s' "$input" | jq -r ' + .tool_input.command // + .tool_input.cmd // + .input.command // + .input.cmd // + .toolCall.args.CommandLine // + .toolCall.args.command // + ""') +else + command=$(json_extract_string "$input" "command") + if [ -z "$command" ]; then + command=$(json_extract_string "$input" "cmd") + fi + if [ -z "$command" ]; then + command=$(json_extract_string "$input" "CommandLine") + fi +fi if [[ -z "$command" ]] || [[ "$command" != *"api-partner.spotify.com"* ]]; then exit 0 fi @@ -91,7 +121,11 @@ if [ -n "$SETTINGS_FILE" ] && [ -f "$SETTINGS_FILE" ]; then token_expires_at=$(get_setting "token_expires_at") refresh_token=$(get_setting "refresh_token") client_id=$(get_setting "client_id") - client_secret=$(security find-generic-password -a "spotify-ads-api" -s "spotify-ads-api-client-secret" -w 2>/dev/null || echo "") + if [ -n "$PYTHON" ]; then + client_secret=$($PYTHON "$PLUGIN_ROOT/scripts/credential-helper.py" get 2>/dev/null || echo "") + else + client_secret=$(security find-generic-password -a "spotify-ads-api" -s "spotify-ads-api-client-secret" -w 2>/dev/null || echo "") + fi # Determine if token needs refresh needs_refresh=false @@ -113,14 +147,23 @@ if [ -n "$SETTINGS_FILE" ] && [ -f "$SETTINGS_FILE" ]; then system_message="Spotify API token may be expired but no refresh credentials are configured. Run the configure skill (/spotify-ads-api:configure on Claude/Codex, /configure on Antigravity) to set up OAuth." else REFRESH_SCRIPT="${PLUGIN_ROOT}/skills/configure/scripts/refresh-token.py" - if refresh_result=$(python3 "$REFRESH_SCRIPT" \ + if [ -z "$PYTHON" ]; then + system_message="Python is required for token refresh but was not found. Install Python 3.8+ and ensure python3, python, or py is in your PATH." + elif refresh_result=$($PYTHON "$REFRESH_SCRIPT" \ --client-id "$client_id" \ --client-secret "$client_secret" \ --refresh-token "$refresh_token" 2>/dev/null); then - new_token=$(echo "$refresh_result" | jq -r '.access_token // ""') - expires_in=$(echo "$refresh_result" | jq -r '.expires_in // 3600') - new_refresh=$(echo "$refresh_result" | jq -r '.refresh_token // ""') + if [ "$HAS_JQ" = true ]; then + new_token=$(echo "$refresh_result" | jq -r '.access_token // ""') + expires_in=$(echo "$refresh_result" | jq -r '.expires_in // 3600') + new_refresh=$(echo "$refresh_result" | jq -r '.refresh_token // ""') + else + new_token=$(json_extract_string "$refresh_result" "access_token") + expires_in=$(printf '%s' "$refresh_result" | sed -n 's/.*"expires_in"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p' | head -1) + expires_in=${expires_in:-3600} + new_refresh=$(json_extract_string "$refresh_result" "refresh_token") + fi if [ -n "$new_token" ]; then new_expires=$(date -u -v+"${expires_in}"S +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || \ @@ -153,33 +196,32 @@ fi # --- Emit output --- # Claude/Codex: permissionDecision + updatedInput to rewrite the command. # Antigravity: decision + reason (no command rewriting support). + +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' | tr -d '\n' +} + +emit_json() { + if [ "$HAS_JQ" = true ]; then + printf '%s' "$1" | jq . 2>/dev/null || printf '%s\n' "$1" + else + printf '%s\n' "$1" + fi +} + if [ "$PLATFORM" = "antigravity" ]; then if [ -n "$system_message" ]; then - jq -n --arg msg "$system_message" '{ - "decision": "allow", - "reason": $msg - }' 2>/dev/null + emit_json "{\"decision\":\"allow\",\"reason\":\"$(json_escape "$system_message")\"}" fi else if [[ "$modified_command" != "$command" ]]; then if [ -n "$system_message" ]; then - jq -n --arg cmd "$modified_command" --arg msg "$system_message" '{ - "hookSpecificOutput": { - "permissionDecision": "allow", - "updatedInput": {"command": $cmd} - }, - "systemMessage": $msg - }' 2>/dev/null + emit_json "{\"hookSpecificOutput\":{\"permissionDecision\":\"allow\",\"updatedInput\":{\"command\":\"$(json_escape "$modified_command")\"}},\"systemMessage\":\"$(json_escape "$system_message")\"}" else - jq -n --arg cmd "$modified_command" '{ - "hookSpecificOutput": { - "permissionDecision": "allow", - "updatedInput": {"command": $cmd} - } - }' 2>/dev/null + emit_json "{\"hookSpecificOutput\":{\"permissionDecision\":\"allow\",\"updatedInput\":{\"command\":\"$(json_escape "$modified_command")\"}}}" fi elif [ -n "$system_message" ]; then - jq -n --arg msg "$system_message" '{"systemMessage": $msg}' 2>/dev/null + emit_json "{\"systemMessage\":\"$(json_escape "$system_message")\"}" fi fi diff --git a/scripts/credential-helper.py b/scripts/credential-helper.py new file mode 100644 index 0000000..6c793ca --- /dev/null +++ b/scripts/credential-helper.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Cross-platform credential storage for the Spotify Ads API plugin. + +Usage: + credential-helper.py get + credential-helper.py set --secret SECRET + credential-helper.py delete + +macOS: delegates to the security command (Keychain). +Windows: uses Win32 Credential Manager via ctypes. +Linux: delegates to secret-tool (libsecret). + +Exit codes: 0 success, 1 not found, 2 error, 3 missing tool. +""" +import argparse +import subprocess +import sys + +SERVICE = "spotify-ads-api-client-secret" +ACCOUNT = "spotify-ads-api" + + +# -- macOS (Keychain) --------------------------------------------------------- + +def _mac_get(): + r = subprocess.run( + ["security", "find-generic-password", "-a", ACCOUNT, "-s", SERVICE, "-w"], + capture_output=True, text=True, + ) + if r.returncode != 0: + return None + return r.stdout.strip() + + +def _mac_set(secret): + subprocess.run( + ["security", "add-generic-password", "-a", ACCOUNT, "-s", SERVICE, "-w", secret, "-U"], + check=True, capture_output=True, + ) + + +def _mac_delete(): + subprocess.run( + ["security", "delete-generic-password", "-a", ACCOUNT, "-s", SERVICE], + check=True, capture_output=True, + ) + + +# -- Windows (Credential Manager via ctypes) ---------------------------------- + +def _win_get(): + import ctypes + import ctypes.wintypes + + advapi32 = ctypes.windll.advapi32 + + CRED_TYPE_GENERIC = 1 + + class CREDENTIAL(ctypes.Structure): + _fields_ = [ + ("Flags", ctypes.wintypes.DWORD), + ("Type", ctypes.wintypes.DWORD), + ("TargetName", ctypes.wintypes.LPWSTR), + ("Comment", ctypes.wintypes.LPWSTR), + ("LastWritten", ctypes.wintypes.FILETIME), + ("CredentialBlobSize", ctypes.wintypes.DWORD), + ("CredentialBlob", ctypes.POINTER(ctypes.c_ubyte)), + ("Persist", ctypes.wintypes.DWORD), + ("AttributeCount", ctypes.wintypes.DWORD), + ("Attributes", ctypes.c_void_p), + ("TargetAlias", ctypes.wintypes.LPWSTR), + ("UserName", ctypes.wintypes.LPWSTR), + ] + + pcred = ctypes.POINTER(CREDENTIAL)() + ok = advapi32.CredReadW(SERVICE, CRED_TYPE_GENERIC, 0, ctypes.byref(pcred)) + if not ok: + return None + try: + blob = pcred.contents + size = blob.CredentialBlobSize + raw = ctypes.string_at(blob.CredentialBlob, size) + return raw.decode("utf-16-le") + finally: + advapi32.CredFree(pcred) + + +def _win_set(secret): + import ctypes + import ctypes.wintypes + + advapi32 = ctypes.windll.advapi32 + + CRED_TYPE_GENERIC = 1 + CRED_PERSIST_LOCAL_MACHINE = 2 + + class CREDENTIAL(ctypes.Structure): + _fields_ = [ + ("Flags", ctypes.wintypes.DWORD), + ("Type", ctypes.wintypes.DWORD), + ("TargetName", ctypes.wintypes.LPWSTR), + ("Comment", ctypes.wintypes.LPWSTR), + ("LastWritten", ctypes.wintypes.FILETIME), + ("CredentialBlobSize", ctypes.wintypes.DWORD), + ("CredentialBlob", ctypes.POINTER(ctypes.c_ubyte)), + ("Persist", ctypes.wintypes.DWORD), + ("AttributeCount", ctypes.wintypes.DWORD), + ("Attributes", ctypes.c_void_p), + ("TargetAlias", ctypes.wintypes.LPWSTR), + ("UserName", ctypes.wintypes.LPWSTR), + ] + + encoded = secret.encode("utf-16-le") + blob_array = (ctypes.c_ubyte * len(encoded))(*encoded) + + cred = CREDENTIAL() + cred.Type = CRED_TYPE_GENERIC + cred.TargetName = SERVICE + cred.UserName = ACCOUNT + cred.CredentialBlobSize = len(encoded) + cred.CredentialBlob = blob_array + cred.Persist = CRED_PERSIST_LOCAL_MACHINE + + ok = advapi32.CredWriteW(ctypes.byref(cred), 0) + if not ok: + raise OSError("CredWriteW failed") + + +def _win_delete(): + import ctypes + + advapi32 = ctypes.windll.advapi32 + + CRED_TYPE_GENERIC = 1 + ok = advapi32.CredDeleteW(SERVICE, CRED_TYPE_GENERIC, 0) + if not ok: + raise OSError("CredDeleteW failed") + + +# -- Linux (secret-tool / libsecret) ------------------------------------------ + +def _has_secret_tool(): + try: + subprocess.run(["secret-tool", "--version"], capture_output=True) + return True + except FileNotFoundError: + return False + + +def _linux_get(): + if not _has_secret_tool(): + print("secret-tool not found. Install libsecret-tools:", file=sys.stderr) + print(" Debian/Ubuntu: sudo apt install libsecret-tools", file=sys.stderr) + print(" Fedora: sudo dnf install libsecret", file=sys.stderr) + sys.exit(3) + r = subprocess.run( + ["secret-tool", "lookup", "service", SERVICE, "account", ACCOUNT], + capture_output=True, text=True, + ) + if r.returncode != 0 or not r.stdout.strip(): + return None + return r.stdout.strip() + + +def _linux_set(secret): + if not _has_secret_tool(): + print("secret-tool not found. Install libsecret-tools:", file=sys.stderr) + print(" Debian/Ubuntu: sudo apt install libsecret-tools", file=sys.stderr) + print(" Fedora: sudo dnf install libsecret", file=sys.stderr) + sys.exit(3) + subprocess.run( + [ + "secret-tool", "store", + "--label", "Spotify Ads API Client Secret", + "service", SERVICE, + "account", ACCOUNT, + ], + input=secret, text=True, check=True, + ) + + +def _linux_delete(): + if not _has_secret_tool(): + print("secret-tool not found. Install libsecret-tools:", file=sys.stderr) + print(" Debian/Ubuntu: sudo apt install libsecret-tools", file=sys.stderr) + print(" Fedora: sudo dnf install libsecret", file=sys.stderr) + sys.exit(3) + subprocess.run( + ["secret-tool", "clear", "service", SERVICE, "account", ACCOUNT], + check=True, + ) + + +# -- Dispatch ------------------------------------------------------------------ + +BACKENDS = { + "darwin": (_mac_get, _mac_set, _mac_delete), + "win32": (_win_get, _win_set, _win_delete), + "linux": (_linux_get, _linux_set, _linux_delete), +} + + +def main(): + parser = argparse.ArgumentParser(description="Cross-platform credential helper") + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("get", help="Retrieve the client secret") + + sp_set = sub.add_parser("set", help="Store the client secret") + sp_set.add_argument("--secret", required=True, help="The secret value to store") + + sub.add_parser("delete", help="Remove the client secret") + + args = parser.parse_args() + + platform = sys.platform + if platform.startswith("linux"): + platform = "linux" + + backend = BACKENDS.get(platform) + if not backend: + print(f"Unsupported platform: {sys.platform}", file=sys.stderr) + sys.exit(2) + + get_fn, set_fn, delete_fn = backend + + try: + if args.command == "get": + secret = get_fn() + if secret is None: + sys.exit(1) + print(secret, end="") + elif args.command == "set": + set_fn(args.secret) + elif args.command == "delete": + delete_fn() + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/skills/assets/SKILL.md b/skills/assets/SKILL.md index c8fb493..00ab428 100644 --- a/skills/assets/SKILL.md +++ b/skills/assets/SKILL.md @@ -68,8 +68,7 @@ Extract `id` from the response. First, check the file size: ```bash -stat -f%z "/path/to/file" # macOS -# or: stat --printf="%s" "/path/to/file" # Linux +FILE_SIZE=$(stat -f%z "/path/to/file" 2>/dev/null || stat --printf="%s" "/path/to/file" 2>/dev/null || wc -c < "/path/to/file") ``` **If file is <= 20MB** — Simple upload: @@ -97,7 +96,8 @@ Extract `upload_session_id` and `max_chunk_size_mb` from the response. 2. Split the file into chunks: ```bash -split -b ${MAX_CHUNK_SIZE_MB}m /path/to/file /tmp/chunk_ +CHUNK_DIR=$(mktemp -d) +split -b ${MAX_CHUNK_SIZE_MB}m /path/to/file "$CHUNK_DIR/chunk_" ``` 3. Upload each chunk (numbered starting from 1): @@ -105,7 +105,7 @@ split -b ${MAX_CHUNK_SIZE_MB}m /path/to/file /tmp/chunk_ curl -s -X POST -H "Authorization: Bearer $TOKEN" \ -H "$SDK_HEADER" \ -H "$SKILL_HEADER" \ - -F "media=@/tmp/chunk_aa" \ + -F "media=@$CHUNK_DIR/chunk_aa" \ -F "upload_section=1" \ "$BASE_URL/ad_accounts/$AD_ACCOUNT_ID/assets/$ASSET_ID/chunked_upload/transfer" ``` @@ -122,7 +122,7 @@ curl -s -X POST -H "Authorization: Bearer $TOKEN" \ 5. Clean up temp chunks: ```bash -rm /tmp/chunk_* +rm -rf "$CHUNK_DIR" ``` #### Step 5: Poll for processing status diff --git a/skills/configure/SKILL.md b/skills/configure/SKILL.md index 38d3826..d22561c 100644 --- a/skills/configure/SKILL.md +++ b/skills/configure/SKILL.md @@ -29,29 +29,33 @@ Full OAuth 2.0 authorization flow with automatic token refresh. - **client_id** (required) — Spotify app client ID from the developer dashboard - **client_secret** (required) — Spotify app client secret -3. Store the client_secret securely in the macOS Keychain: +3. Detect the Python command — try `python3`, then `python`, then `py`. Use whichever is found for all subsequent Python calls. Store the client_secret securely in the OS credential store: ```bash -security add-generic-password -a "spotify-ads-api" -s "spotify-ads-api-client-secret" -w "" -U +PLUGIN_ROOT="${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-$PWD}}" +PYTHON=$(command -v python3 || command -v python || command -v py) +$PYTHON "${PLUGIN_ROOT}/scripts/credential-helper.py" set --secret "" ``` - **Do NOT write client_secret to the settings file.** It must only be stored in the keychain. + **Do NOT write client_secret to the settings file.** It must only be stored in the OS credential store. 4. Attempt the automated OAuth flow by running the helper script. On Antigravity, no plugin-root env var is set — this skill's files live at `/skills/configure/`, so set `PLUGIN_ROOT` to the plugin root (two directories up from this skill's directory) instead of using the snippet below. ```bash PLUGIN_ROOT="${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-$PWD}}" -client_secret=$(security find-generic-password -a "spotify-ads-api" -s "spotify-ads-api-client-secret" -w) -python3 "${PLUGIN_ROOT}/skills/configure/scripts/oauth-flow.py" \ +PYTHON=$(command -v python3 || command -v python || command -v py) +client_secret=$($PYTHON "${PLUGIN_ROOT}/scripts/credential-helper.py" get) +$PYTHON "${PLUGIN_ROOT}/skills/configure/scripts/oauth-flow.py" \ --client-id "" \ --client-secret "$client_secret" ``` -If `python3` is not available, try `uv run`: +If neither `python3` nor `python` is available, try `uv run`: ```bash PLUGIN_ROOT="${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-$PWD}}" -client_secret=$(security find-generic-password -a "spotify-ads-api" -s "spotify-ads-api-client-secret" -w) +PYTHON=$(command -v python3 || command -v python || command -v py) +client_secret=$($PYTHON "${PLUGIN_ROOT}/scripts/credential-helper.py" get) uv run "${PLUGIN_ROOT}/skills/configure/scripts/oauth-flow.py" \ --client-id "" \ --client-secret "$client_secret" @@ -97,10 +101,12 @@ Manual OAuth flow for environments where the automated script cannot run. 1. Prompt for **client_id** and **client_secret** using AskUserQuestion. -2. Store the client_secret securely in the macOS Keychain: +2. Store the client_secret securely in the OS credential store: ```bash -security add-generic-password -a "spotify-ads-api" -s "spotify-ads-api-client-secret" -w "" -U +PLUGIN_ROOT="${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-$PWD}}" +PYTHON=$(command -v python3 || command -v python || command -v py) +$PYTHON "${PLUGIN_ROOT}/scripts/credential-helper.py" set --secret "" ``` **Do NOT write client_secret to the settings file.** @@ -119,9 +125,11 @@ security add-generic-password -a "spotify-ads-api" -s "spotify-ads-api-client-se 6. Exchange the code for tokens: ```bash -client_secret=$(security find-generic-password -a "spotify-ads-api" -s "spotify-ads-api-client-secret" -w) +PLUGIN_ROOT="${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-$PWD}}" +PYTHON=$(command -v python3 || command -v python || command -v py) +client_secret=$($PYTHON "${PLUGIN_ROOT}/scripts/credential-helper.py" get) curl -s -X POST "https://accounts.spotify.com/api/token" \ - -H "Authorization: Basic $(echo -n ':'"$client_secret"'' | base64)" \ + -u ":${client_secret}" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code&code=&redirect_uri=http://127.0.0.1:8080/callback" ``` @@ -165,10 +173,10 @@ auto_execute: false Local configuration for the spotify-ads-api plugin. Do not commit this file to version control. -Client secret is stored in the macOS Keychain, not in this file. +Client secret is stored in the OS credential store, not in this file. ``` -**Note:** `client_secret` is stored in the macOS Keychain (service: `spotify-ads-api-client-secret`, account: `spotify-ads-api`), not in this file. +**Note:** `client_secret` is stored in the OS credential store (macOS Keychain, Windows Credential Manager, or Linux secret storage; service: `spotify-ads-api-client-secret`, account: `spotify-ads-api`), not in this file. For the `token` mode, leave `refresh_token`, `token_expires_at`, and `client_id` as empty strings. @@ -184,6 +192,6 @@ Report the test API call result: - The settings file is gitignored via `.codex/*.local.md`, `.claude/*.local.md`, and `.agents/*.local.md`. - If the active settings directory (`.codex/`, `.claude/`, or `.agents/`) doesn't exist, create it. -- **client_secret is stored in the macOS Keychain**, not in the settings file. Use `security find-generic-password -a "spotify-ads-api" -s "spotify-ads-api-client-secret" -w` to retrieve it when needed. +- **client_secret is stored in the OS credential store** (macOS Keychain, Windows Credential Manager, or Linux secret storage), not in the settings file. Use `scripts/credential-helper.py get` to retrieve it when needed. - Never log or display the full access token or client_secret — show only the last 8 characters for confirmation. - Never write client_secret to the settings file or any other plaintext file. diff --git a/templates/settings-template.md b/templates/settings-template.md index d584a39..04b036f 100644 --- a/templates/settings-template.md +++ b/templates/settings-template.md @@ -14,7 +14,7 @@ Local configuration for the spotify-ads-api plugin. Store this file at `.codex/spotify-ads-api.local.md` on Codex, `.claude/spotify-ads-api.local.md` on Claude, or `.agents/spotify-ads-api.local.md` on Antigravity. Do not commit this file to version control. -Client secret is stored in the macOS Keychain, not in this file. +Client secret is stored in the OS credential store, not in this file. ## Fields @@ -28,4 +28,4 @@ Client secret is stored in the macOS Keychain, not in this file. ## Client Secret -The client secret is stored securely in the macOS Keychain (service: `spotify-ads-api-client-secret`, account: `spotify-ads-api`) and is never written to this file. +The client secret is stored securely in the OS credential store (macOS Keychain, Windows Credential Manager, or Linux secret storage; service: `spotify-ads-api-client-secret`, account: `spotify-ads-api`) and is never written to this file.