diff --git a/.claude/skills/segmentcli/SKILL.md b/.claude/skills/segmentcli/SKILL.md new file mode 100644 index 0000000..679f997 --- /dev/null +++ b/.claude/skills/segmentcli/SKILL.md @@ -0,0 +1,129 @@ +--- +name: segmentcli +description: >- + Use the `segmentcli` CLI to drive a Segment workspace — list sources, manage + Analytics Live Plugins, send test analytics events, import CSV data, and + scaffold plugin/edge-function/importer code. Use whenever the user mentions + "segmentcli", asks to upload/disable a Live Plugin, list Segment sources, + send a track/identify/etc. event from the terminal, import a CSV into + Segment, or scaffold a Segment plugin or edge function. +--- + +# segmentcli + +CLI for working with Segment workspaces. Sources upstream: +[`segment-integrations/segmentcli`](https://github.com/segment-integrations/segmentcli). +Installs as `/usr/local/bin/segmentcli` via `sudo make install` from a checkout. + +## Look things up before answering + +Always prefer `--help` over guessing — the CLI is small and self-documenting: + +- `segmentcli --help` — top-level groups and commands +- `segmentcli --help` — subcommands of a group +- `segmentcli --help` — exact arguments +- `segmentcli version` — version string (currently `1.0.0`) + +## Authentication & profiles + +State lives in `~/.segmentcli` (JSON). Profiles are workspace tokens with a +local nickname. + +- **Auth** (one-time per workspace): `segmentcli auth ` +- **List**: `segmentcli profile list` +- **Set default**: `segmentcli profile set ` +- **Delete**: `segmentcli profile delete ` +- Most commands accept `-p, --profile ` to override the default profile + for that single call. +- `--staging` global flag routes to `api.segmentapis.build` (Segment internal staging). + +### Getting a token + +1. https://app.segment.com → Settings → Workspace Settings → Access Management → Tokens +2. *Create token* with the **Workspace Owner** role (required for full CLI access). +3. Token format starts with `sgp_…`. + +### EU workspaces + +The Public API is single-host (`https://api.segmentapis.com`); for EU +workspaces it 30x's to `https://eu1.api.segmentapis.com`. The CLI's auth +session preserves the `Authorization` header across the redirect, so EU +auth works transparently — but only on builds that include the +"re-attach Authorization on redirect" fix. If an EU workspace returns +"Supplied token is not authorized" or "Authorization header is required", +the binary is too old; rebuild from `master`. + +> Note: `eu1.api.segmentapis.com` is the EU Public API host (Public API only). +> `events.eu1.segmentapis.com` is the EU **event ingestion** host (TAPI) — used +> by analytics SDKs at runtime, NOT by `segmentcli`. + +## Read-only commands (safe to run anytime) + +- `segmentcli version` +- `segmentcli profile list` +- `segmentcli sources list` — lists all sources in the workspace (name, id, type, write keys) +- `segmentcli liveplugins latest ` — info on the live plugin currently bound to a source +- `segmentcli analytics list` — show locally-pending event batches (does not contact Segment) + +## Write / side-effecting commands (confirm before running) + +These either mutate workspace state, send live event traffic, write to disk, +or modify local profile state. **Always echo the intended command back to the +user and confirm before running.** Don't auto-fire them. + +- `segmentcli auth ` — writes a profile to `~/.segmentcli` +- `segmentcli profile set/delete` — mutates local profile state +- `segmentcli liveplugins upload ` — deploys a Live Plugin to a source +- `segmentcli liveplugins disable ` — disables Live Plugins for a source +- `segmentcli analytics {track,identify,screen,group,alias} …` — sends real events +- `segmentcli analytics flush` — flushes locally-queued events to Segment +- `segmentcli analytics reset` — clears local anonId/userId +- `segmentcli import ` — bulk-ingests CSV rows as events +- `segmentcli scaffold {-p|-e|-i} [--swift|--kotlin|--java|--objc|--js|--ts] [-n NAME]` — generates code files in CWD +- `segmentcli repl` — interactive Substrata JS REPL + +## Common workflows + +### Find a source's ID +1. `segmentcli sources list` +2. Match by name; record `id` field. (Or grab from app.segment.com → Connections → Sources → API Keys.) + +### Ship a Live Plugin +1. Scaffold: `segmentcli scaffold --plugin --swift -n MyPlugin` (or `--kotlin`/`--java`/`--objc`/`--ts`) +2. Edit the generated file. +3. `segmentcli liveplugins upload ` (the deployed file is the JS bundle, not the platform source). +4. Verify: `segmentcli liveplugins latest `. Settings propagation can take a few minutes. + +### Test events from the terminal +1. Find the source's write key: `segmentcli sources list`. +2. `segmentcli analytics track "Event Name" key=value other=val --flush` +3. `--flush` ensures the batch is sent before the process exits; without it, events sit locally until `analytics flush` is run. + +### Import a CSV +1. `segmentcli analytics list` to confirm queue state. +2. `segmentcli import data.csv` +3. The CLI batches rows as track events (one per row). Large files take a while. + +### Scaffold an edge function +- `segmentcli scaffold --edgefn --js -n MyEdgeFn` → drops a JS template in CWD. + +## Guardrails + +- **Confirm before any write.** Especially `liveplugins upload`, `liveplugins disable`, `analytics track/identify/...`, and `import` — they all hit production by default. Use `--staging` if available and intended. +- **Never paste a token into chat or commit it.** Tokens are workspace-scoped and grant full Workspace Owner access. The token lives in `~/.segmentcli` (mode 644 — flag if storing on a shared host). +- **Don't run `repl` non-interactively** — it expects a TTY. +- **Source IDs vs write keys** are different. Live plugin / sources commands take **source IDs** (a short opaque ID per source). Analytics and import commands take **write keys** (a separate opaque ID, also per source). Both are in `sources list` output. +- **Settings file at `~/.segmentcli`** is plain JSON; safe to inspect/back up but treat as a secret. + +## When the CLI is not the right tool + +- **Bulk Public API operations** (filtering, pagination, fields the CLI doesn't surface): hit `https://api.segmentapis.com` directly with `curl`. The official SDKs are at https://github.com/segmentio (Go, Python, Java, C#, TypeScript, Swift). +- **Event ingestion at scale**: use a real Segment SDK in your app, not `analytics track`. +- **Tracking Plans / Destinations / Warehouses CRUD**: not exposed by `segmentcli`; use the Public API or app.segment.com. + +## References + +- Repo: https://github.com/segment-integrations/segmentcli +- Public API docs: https://docs.segmentapis.com +- Regional Segment (EU): https://segment.com/docs/guides/regional-segment/ +- Analytics Live Plugins SDKs: [Swift](https://github.com/segment-integrations/analytics-swift-live), [Kotlin](https://github.com/segment-integrations/analytics-kotlin-live) diff --git a/Sources/segmentcli/PAPI/PAPI.swift b/Sources/segmentcli/PAPI/PAPI.swift index 2275b80..7606225 100755 --- a/Sources/segmentcli/PAPI/PAPI.swift +++ b/Sources/segmentcli/PAPI/PAPI.swift @@ -1,6 +1,6 @@ // // File.swift -// +// // // Created by Brandon Sneed on 12/3/21. // @@ -20,6 +20,26 @@ protocol PAPISection { static var pathEntry: String { get } } +// URLSession strips Authorization on redirect by default; api.segmentapis.com +// 30x's to regional hosts (e.g. eu1.api.segmentapis.com) for EU workspaces. +final class PAPIRedirectDelegate: NSObject, URLSessionTaskDelegate { + static let shared = PAPIRedirectDelegate() + func urlSession(_ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + var newRequest = request + for header in ["Authorization", "Accept"] { + if let value = task.originalRequest?.value(forHTTPHeaderField: header), + newRequest.value(forHTTPHeaderField: header) == nil { + newRequest.setValue(value, forHTTPHeaderField: header) + } + } + completionHandler(newRequest) + } +} + class PAPI { enum StatusCode: Int { case unknown = 0 @@ -34,31 +54,35 @@ class PAPI { case tooManyRequests = 429 case serverError = 500 } - + static let shared = PAPI() - + + let session = URLSession(configuration: .default, + delegate: PAPIRedirectDelegate.shared, + delegateQueue: nil) + let sources = PAPI.Sources() let edgeFunctions = PAPI.EdgeFunctions() - + func statusCode(response: URLResponse?) -> StatusCode { - if let httpResponse = response as? HTTPURLResponse { - if let status = StatusCode(rawValue: httpResponse.statusCode) { - return status - } + if let httpResponse = response as? HTTPURLResponse, + let status = StatusCode(rawValue: httpResponse.statusCode) { + return status } return .unknown } - + func authenticate(token: String, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { guard let url = URL(string: PAPIEndpoint) else { completion(nil, nil, "Unable to create URL."); return } - + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 30) request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - - let task = URLSession.shared.dataTask(with: request, completionHandler: completion) + request.addValue("application/vnd.segment.v1+json", forHTTPHeaderField: "Accept") + + let task = session.dataTask(with: request, completionHandler: completion) task.resume() } - + } // MARK: - Global option to support staging diff --git a/Sources/segmentcli/PAPI/PAPIEdgeFunctions.swift b/Sources/segmentcli/PAPI/PAPIEdgeFunctions.swift index 6fe58b2..db2c838 100755 --- a/Sources/segmentcli/PAPI/PAPIEdgeFunctions.swift +++ b/Sources/segmentcli/PAPI/PAPIEdgeFunctions.swift @@ -23,7 +23,7 @@ extension PAPI { request.httpMethod = "GET" request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - let task = URLSession.shared.dataTask(with: request, completionHandler: completion) + let task = PAPI.shared.session.dataTask(with: request, completionHandler: completion) task.resume() } @@ -41,7 +41,7 @@ extension PAPI { request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = "{ \"sourceId\": \"\(sourceId)\" }".data(using: .utf8) - let task = URLSession.shared.dataTask(with: request, completionHandler: completion) + let task = PAPI.shared.session.dataTask(with: request, completionHandler: completion) task.resume() } @@ -59,7 +59,7 @@ extension PAPI { request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = "{ \"sourceId\": \"\(sourceId)\" }".data(using: .utf8) - let task = URLSession.shared.dataTask(with: request, completionHandler: completion) + let task = PAPI.shared.session.dataTask(with: request, completionHandler: completion) task.resume() } @@ -79,7 +79,7 @@ extension PAPI { request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = "{ \"uploadURL\": \"\(uploadURL.absoluteString)\", \"sourceId\": \"\(sourceId)\" }".data(using: .utf8) - let task = URLSession.shared.dataTask(with: request, completionHandler: completion) + let task = PAPI.shared.session.dataTask(with: request, completionHandler: completion) task.resume() } @@ -88,7 +88,7 @@ extension PAPI { guard let fileURL = fileURL else { completion(nil, nil, "File URL is nil."); return } var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 30) request.httpMethod = "PUT" - let task = URLSession.shared.uploadTask(with: request, fromFile: fileURL, completionHandler: completion) + let task = PAPI.shared.session.uploadTask(with: request, fromFile: fileURL, completionHandler: completion) task.resume() } } diff --git a/Sources/segmentcli/PAPI/PAPISources.swift b/Sources/segmentcli/PAPI/PAPISources.swift index c89537f..1307403 100755 --- a/Sources/segmentcli/PAPI/PAPISources.swift +++ b/Sources/segmentcli/PAPI/PAPISources.swift @@ -20,7 +20,7 @@ extension PAPI { var request = URLRequest(url: newURL, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 30) request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - let task = URLSession.shared.dataTask(with: request, completionHandler: completion) + let task = PAPI.shared.session.dataTask(with: request, completionHandler: completion) task.resume() } }