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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" <skill-name> "$@"; }`. Usage: `api <METHOD> <path> [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

Expand Down
2 changes: 1 addition & 1 deletion ANTIGRAVITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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.

## Install

Expand Down Expand Up @@ -41,7 +41,7 @@ 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

Expand Down Expand Up @@ -163,7 +163,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

Expand All @@ -182,6 +182,9 @@ 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 <id>`. 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).

**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.

Expand Down
2 changes: 1 addition & 1 deletion agents/spotify-ads-request-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ api GET "targets/geos?country_code=US&q=<user_location>&limit=20"
The `api` wrapper appends `\nHTTP_STATUS:<code>` 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
Expand Down
21 changes: 19 additions & 2 deletions hooks/check-token.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ find_settings_file() {
done
}

find_python() {
local cmd
for cmd in python3 python py; do
if command -v "$cmd" &>/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
Expand Down Expand Up @@ -91,7 +102,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
Expand All @@ -113,7 +128,9 @@ 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
Expand Down
243 changes: 243 additions & 0 deletions scripts/credential-helper.py
Original file line number Diff line number Diff line change
@@ -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()
10 changes: 5 additions & 5 deletions skills/assets/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -97,15 +96,16 @@ 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):
```bash
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"
```
Expand All @@ -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
Expand Down
Loading