From 0ca56f7e2d8cf50eb91311ff959637191f8c16ce Mon Sep 17 00:00:00 2001 From: Kevin Duconge <64347790+kevduc@users.noreply.github.com> Date: Fri, 1 May 2026 02:21:42 +0200 Subject: [PATCH 1/4] Fix auth on EU workspaces: re-attach Authorization on redirect Segment Public API redirects api.segmentapis.com -> eu1.api.segmentapis.com for EU workspaces. URLSession strips Authorization headers on redirect by default, causing the auth probe to receive a 401 "Authorization header is required". A URLSessionTaskDelegate now re-attaches the original Authorization (and Accept) headers on redirect. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/segmentcli/PAPI/PAPI.swift | 60 +++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/Sources/segmentcli/PAPI/PAPI.swift b/Sources/segmentcli/PAPI/PAPI.swift index 2275b80..e9bad15 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. // @@ -12,6 +12,9 @@ var PAPIEndpoint: String { if useStagingKey.value { return "https://api.segmentapis.build/" } else { + // Segment Public API is single-region. EU workspaces use the same host; + // the token is workspace-scoped server-side. (eu1.segmentapis.com is for + // event ingestion only, not the Public API.) return "https://api.segmentapis.com/" } } @@ -20,6 +23,30 @@ protocol PAPISection { static var pathEntry: String { get } } +// URLSession by default strips Authorization headers on redirect. Segment's +// Public API can issue redirects (e.g. canonicalising trailing slashes) which +// causes a 401 "Authorization header is required" on the followed request. +// This delegate re-attaches the original Authorization header on redirect. +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 + if let auth = task.originalRequest?.value(forHTTPHeaderField: "Authorization"), + newRequest.value(forHTTPHeaderField: "Authorization") == nil { + newRequest.setValue(auth, forHTTPHeaderField: "Authorization") + } + if let accept = task.originalRequest?.value(forHTTPHeaderField: "Accept"), + newRequest.value(forHTTPHeaderField: "Accept") == nil { + newRequest.setValue(accept, forHTTPHeaderField: "Accept") + } + completionHandler(newRequest) + } +} + class PAPI { enum StatusCode: Int { case unknown = 0 @@ -34,12 +61,16 @@ class PAPI { case tooManyRequests = 429 case serverError = 500 } - + static let shared = PAPI() - + + let session: URLSession = 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) { @@ -48,17 +79,28 @@ class PAPI { } 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) { data, response, error in + if ProcessInfo.processInfo.environment["SEGMENTCLI_DEBUG"] != nil { + if let httpResponse = response as? HTTPURLResponse { + FileHandle.standardError.write("[debug] \(httpResponse.url?.absoluteString ?? request.url?.absoluteString ?? "") -> \(httpResponse.statusCode)\n".data(using: .utf8) ?? Data()) + } + if let data = data, let body = String(data: data, encoding: .utf8) { + FileHandle.standardError.write("[debug] body: \(body)\n".data(using: .utf8) ?? Data()) + } + } + completion(data, response, error) + } task.resume() } - + } // MARK: - Global option to support staging From 38f429ff3814b8a9196dac3b4b6128b76137ad06 Mon Sep 17 00:00:00 2001 From: Kevin Duconge <64347790+kevduc@users.noreply.github.com> Date: Fri, 1 May 2026 02:27:08 +0200 Subject: [PATCH 2/4] Route remaining endpoints through redirect-aware session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PAPISources and PAPIEdgeFunctions used URLSession.shared, which strips Authorization on redirect — causing 401s on EU workspaces (US host 30x's to eu1.api.segmentapis.com). Switch them to PAPI.shared.session so the existing redirect delegate re-attaches the auth header. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/segmentcli/PAPI/PAPIEdgeFunctions.swift | 10 +++++----- Sources/segmentcli/PAPI/PAPISources.swift | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) 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() } } From 203746d5c34986bece0c7640af3cf2fb8fd396ee Mon Sep 17 00:00:00 2001 From: Kevin Duconge <64347790+kevduc@users.noreply.github.com> Date: Fri, 1 May 2026 02:41:35 +0200 Subject: [PATCH 3/4] Tidy PAPI.swift: compact comments, drop debug prints Squashes the long endpoint comment and redirect-delegate explanation into single lines, collapses the duplicated header re-attach blocks into a loop, and removes the SEGMENTCLI_DEBUG print block that was only used during diagnosis. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/segmentcli/PAPI/PAPI.swift | 46 +++++++++--------------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/Sources/segmentcli/PAPI/PAPI.swift b/Sources/segmentcli/PAPI/PAPI.swift index e9bad15..7606225 100755 --- a/Sources/segmentcli/PAPI/PAPI.swift +++ b/Sources/segmentcli/PAPI/PAPI.swift @@ -12,9 +12,6 @@ var PAPIEndpoint: String { if useStagingKey.value { return "https://api.segmentapis.build/" } else { - // Segment Public API is single-region. EU workspaces use the same host; - // the token is workspace-scoped server-side. (eu1.segmentapis.com is for - // event ingestion only, not the Public API.) return "https://api.segmentapis.com/" } } @@ -23,10 +20,8 @@ protocol PAPISection { static var pathEntry: String { get } } -// URLSession by default strips Authorization headers on redirect. Segment's -// Public API can issue redirects (e.g. canonicalising trailing slashes) which -// causes a 401 "Authorization header is required" on the followed request. -// This delegate re-attaches the original Authorization header on redirect. +// 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, @@ -35,13 +30,11 @@ final class PAPIRedirectDelegate: NSObject, URLSessionTaskDelegate { newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { var newRequest = request - if let auth = task.originalRequest?.value(forHTTPHeaderField: "Authorization"), - newRequest.value(forHTTPHeaderField: "Authorization") == nil { - newRequest.setValue(auth, forHTTPHeaderField: "Authorization") - } - if let accept = task.originalRequest?.value(forHTTPHeaderField: "Accept"), - newRequest.value(forHTTPHeaderField: "Accept") == nil { - newRequest.setValue(accept, forHTTPHeaderField: "Accept") + 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) } @@ -64,18 +57,17 @@ class PAPI { static let shared = PAPI() - let session: URLSession = URLSession(configuration: .default, - delegate: PAPIRedirectDelegate.shared, - delegateQueue: nil) + 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 } @@ -87,17 +79,7 @@ class PAPI { request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.addValue("application/vnd.segment.v1+json", forHTTPHeaderField: "Accept") - let task = session.dataTask(with: request) { data, response, error in - if ProcessInfo.processInfo.environment["SEGMENTCLI_DEBUG"] != nil { - if let httpResponse = response as? HTTPURLResponse { - FileHandle.standardError.write("[debug] \(httpResponse.url?.absoluteString ?? request.url?.absoluteString ?? "") -> \(httpResponse.statusCode)\n".data(using: .utf8) ?? Data()) - } - if let data = data, let body = String(data: data, encoding: .utf8) { - FileHandle.standardError.write("[debug] body: \(body)\n".data(using: .utf8) ?? Data()) - } - } - completion(data, response, error) - } + let task = session.dataTask(with: request, completionHandler: completion) task.resume() } From 6b2b2262b8312c93df051ef658f44d2044f77af8 Mon Sep 17 00:00:00 2001 From: Kevin Duconge <64347790+kevduc@users.noreply.github.com> Date: Fri, 1 May 2026 02:45:11 +0200 Subject: [PATCH 4/4] Add Claude Code skill for segmentcli Documents the CLI's commands, auth/profile model, EU-workspace behavior (redirect-aware), read-only vs side-effecting commands, and common workflows. Loaded automatically by Claude Code when working in this repo. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/segmentcli/SKILL.md | 129 +++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .claude/skills/segmentcli/SKILL.md 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)