From 39d0464b588af3431f53f4f61b2acb36f68d6731 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:32:13 +0000 Subject: [PATCH 01/18] chore: update kernel-go-sdk to v0.107.0 Bumps the Go SDK from v0.105.1-0.20260916183112-dab5c0714192 to v0.107.0 (commit 6c9df7b). The SDK surface is unchanged across this range: the previously pinned pseudo-version already contained every feature commit in the 0.106.0 and 0.107.0 releases (persistent browser REPL, browser update start_url, config registry, AgentCard preparation contracts, Link spend request errors). Only release metadata differs, so no new commands or flags were required. Coverage analysis: full enumeration of all 165 SDK methods in api.md against the CLI command tree found no gaps. The six client.ConfigRegistry.* methods are marked x-cli-skip in openapi.yaml and are correctly excluded. Param fields without a dedicated flag were each verified as intentional: - AuditLog{List,ExportChunk}Params.SearchUserID -> covered by --user-id - AuditLogListParams.PageToken -> handled internally by ListAutoPaging - AuthConnectionLoginParams.BrowserTelemetry -> deprecated, superseded by browser.telemetry, which --telemetry covers - BrowserCurlParams.ResponseEncoding -> `browsers curl` streams raw bytes through its own HTTP client rather than the SDK method Tested: go build ./..., go vet ./..., go test ./... (all 9 packages pass), plus live API smoke tests of `browsers list`, `browsers create`, `browsers repl` (verified top-level bindings persist across calls under a stable REPL ID), `browsers update --start-url`, and `browsers delete`. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3b25c970..9eb823f3 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.105.1-0.20260916183112-dab5c0714192 + github.com/kernel/kernel-go-sdk v0.107.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 659ade8d..5f951cdc 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.105.1-0.20260916183112-dab5c0714192 h1:LuKqg8Lp9vg4EiixL4L5fPELDT5GFouh0gtSEc9uqoM= -github.com/kernel/kernel-go-sdk v0.105.1-0.20260916183112-dab5c0714192/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.107.0 h1:0m4RkOquA3irSYK7hNU8VmLhTyl1jeCSydpTQ2tjba0= +github.com/kernel/kernel-go-sdk v0.107.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 74e93b3654f7dd6d5373fd9cc4ff30acf018710b Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:53:32 +0000 Subject: [PATCH 02/18] chore: bump Go SDK to 51d7fb8 (v0.107.1-0.20260917184535) Updates github.com/kernel/kernel-go-sdk from v0.107.0 to v0.107.1-0.20260917184535-51d7fb82550d (commit 51d7fb8). The upstream commit ("chore(stlc): seal custom-code tracking files") contains no API surface changes -- the api.md/Go source diff between v0.107.0 and 51d7fb8 is empty. Coverage analysis: full enumeration of all 165 methods in api.md against the CLI command tree (215 commands). All 159 non-skipped methods have a corresponding CLI command; the 6 config-registry methods are marked x-cli-skip: true in openapi.yaml. All params struct fields map to existing flags, positional args, or are pagination internals handled by the auto-pagers. No new commands or flags were needed. Tested: go build ./..., go vet ./..., go test ./... (all pass), kernel browsers list, kernel profiles list against the live API. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 9eb823f3..9c1121c0 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.107.0 + github.com/kernel/kernel-go-sdk v0.107.1-0.20260917184535-51d7fb82550d github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 5f951cdc..871d8b93 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.107.0 h1:0m4RkOquA3irSYK7hNU8VmLhTyl1jeCSydpTQ2tjba0= -github.com/kernel/kernel-go-sdk v0.107.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.107.1-0.20260917184535-51d7fb82550d h1:VfVsuvaFQuxmQNy9OtNyFL1YeaKzE/+HlJIxw/jao1c= +github.com/kernel/kernel-go-sdk v0.107.1-0.20260917184535-51d7fb82550d/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 627c950f7f3a0667c1089519d22dc045d6f9a86f Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:40:09 +0000 Subject: [PATCH 03/18] chore: bump Go SDK to v0.108.0 (3556e00) Updates github.com/kernel/kernel-go-sdk from v0.107.1-0.20260917184535-51d7fb82550d to v0.108.0. The only difference between the two module versions is the internal/version.go version constant -- api.md and every public Go source file are byte-identical, so there are no new API methods, params, or fields to expose. Coverage analysis: full enumeration of all 165 SDK methods in api.md against all 176 CLI leaf commands found no gaps. 6 methods (ConfigRegistry.{List,Lookup,Resolve} and ConfigRegistry.Analyses.{Get,List,Cancel}) are marked x-cli-skip in openapi.yaml and are intentionally absent. The remaining 159 all have CLI commands. An automated SDK-param-field vs CLI-flag comparison surfaced only naming false positives (e.g. proxy -> --proxy-id/--proxy-name, tags -> --tag, timeout_seconds -> --timeout, hold_keys -> --hold-key) and fields exposed as positional args; all were verified covered. Tested: go build ./..., go vet ./... (clean), go test ./... (all pass), and smoke-tested `kernel browsers list` and `kernel app list` against the live API. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 9c1121c0..45b25060 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.107.1-0.20260917184535-51d7fb82550d + github.com/kernel/kernel-go-sdk v0.108.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 871d8b93..41e57300 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.107.1-0.20260917184535-51d7fb82550d h1:VfVsuvaFQuxmQNy9OtNyFL1YeaKzE/+HlJIxw/jao1c= -github.com/kernel/kernel-go-sdk v0.107.1-0.20260917184535-51d7fb82550d/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.108.0 h1:GwCC3keLb9/Dy9ACEf5U62eCYk5mAgHavN7zvVeJDyk= +github.com/kernel/kernel-go-sdk v0.108.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 7a8a71e246be6306751303483abbc7ef4ef426f6 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:06:55 +0000 Subject: [PATCH 04/18] chore: bump Go SDK to 6367486 and show org concurrency usage Updates kernel-go-sdk to 6367486e6e06cdc3d9dc13c107dddca490e9b9bc. That SDK change adds two response fields to OrgLimits: concurrent_sessions_used and concurrent_sessions_available. Surface both as rows in `kernel org limits get`. A null here means usage could not be read rather than "unlimited", so these render as "unknown" instead of reusing formatProjectLimitValue. A full enumeration of the 165 methods in api.md found no missing commands; the 6 config-registry methods are x-cli-skip. Param-field audit found no missing flags: AuditLogListParams.PageToken is consumed by the SDK auto-pager, AuthConnectionLoginParams.BrowserTelemetry is deprecated in favor of browser.telemetry (already wired), and BrowserCurlParams.ResponseEncoding does not apply because `browsers curl` streams raw bytes over the browser HTTP client rather than calling Browsers.Curl. Tested: `kernel org limits get` and `-o json` against the live API (1118 used + 882 available = 2000 max); full `go test ./...` passes. Co-Authored-By: Claude Opus 5 --- cmd/org.go | 21 +++++++++++++++++- cmd/org_test.go | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/cmd/org.go b/cmd/org.go index 2bce469d..92143ade 100644 --- a/cmd/org.go +++ b/cmd/org.go @@ -135,6 +135,16 @@ func renderOrgLimits(limits *kernel.OrgLimits) { {"Default Project Max Concurrent Sessions", formatProjectLimitValue(limits.DefaultProjectMaxConcurrentSessions, limits.JSON.DefaultProjectMaxConcurrentSessions)}, } + // Concurrency usage is measured live and only returned by newer API + // versions. Unlike the limit rows, a null here means usage could not be + // read rather than "unlimited", so render it as unknown. + if orgLimitFieldPresent(limits.JSON.ConcurrentSessionsUsed) { + rows = append(rows, []string{"Concurrent Sessions Used", formatOrgUsageValue(limits.ConcurrentSessionsUsed, limits.JSON.ConcurrentSessionsUsed)}) + } + if orgLimitFieldPresent(limits.JSON.ConcurrentSessionsAvailable) { + rows = append(rows, []string{"Concurrent Sessions Available", formatOrgUsageValue(limits.ConcurrentSessionsAvailable, limits.JSON.ConcurrentSessionsAvailable)}) + } + // Managed auth limits are plan-derived and only returned by newer API // versions, so render each row only when the field is present. A null // max_auth_connections means unlimited, so presence — not validity — is the @@ -167,6 +177,15 @@ func orgLimitFieldPresent(field respjson.Field) bool { return field.Raw() != respjson.Omitted } +// formatOrgUsageValue renders a live usage counter, where a null means the API +// could not read current usage rather than "unlimited". +func formatOrgUsageValue(value int64, field respjson.Field) string { + if !field.Valid() { + return "unknown" + } + return fmt.Sprintf("%d", value) +} + func renderOrgEntitlements(entitlements *kernel.OrgEntitlements) { if entitlements == nil { pterm.Info.Println("No organization entitlements found") @@ -266,7 +285,7 @@ var orgLimitsCmd = &cobra.Command{ var orgLimitsGetCmd = &cobra.Command{ Use: "get", Short: "Get organization limits", - Long: "Show the organization's effective limits: the concurrency limit, the default per-project cap applied to projects without an explicit override, and the plan-derived managed auth and vault limits along with current auth connection and vault usage.", + Long: "Show the organization's effective limits: the concurrency limit, current organization-wide concurrent browser usage and remaining capacity, the default per-project cap applied to projects without an explicit override, and the plan-derived managed auth and vault limits along with current auth connection and vault usage.", Args: cobra.NoArgs, RunE: runOrgLimitsGet, } diff --git a/cmd/org_test.go b/cmd/org_test.go index 9118f619..33afdbbd 100644 --- a/cmd/org_test.go +++ b/cmd/org_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "strings" "testing" "time" @@ -296,6 +297,63 @@ func TestOrgLimitsGet_NullDefaultShownAsUnlimited(t *testing.T) { assert.Contains(t, buf.String(), "unlimited") } +func TestOrgLimitsGet_RendersConcurrencyUsage(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgLimitsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + limits := &kernel.OrgLimits{ + MaxConcurrentSessions: 100, + ConcurrentSessionsUsed: 12, + ConcurrentSessionsAvailable: 88, + } + limits.JSON.ConcurrentSessionsUsed = respjson.NewField("12") + limits.JSON.ConcurrentSessionsAvailable = respjson.NewField("88") + return limits, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.Contains(t, out, "Concurrent Sessions Used") + assert.Contains(t, out, "12") + assert.Contains(t, out, "Concurrent Sessions Available") + assert.Contains(t, out, "88") +} + +func TestOrgLimitsGet_NullConcurrencyUsageShownAsUnknown(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgLimitsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + limits := &kernel.OrgLimits{MaxConcurrentSessions: 100} + // Null (not omitted) means usage could not be read, which is not + // the same as unlimited. + limits.JSON.ConcurrentSessionsUsed = respjson.NewField(respjson.Null) + limits.JSON.ConcurrentSessionsAvailable = respjson.NewField(respjson.Null) + return limits, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + // Both usage rows render as unknown rather than borrowing the "unlimited" + // meaning a null limit would have. + assert.Contains(t, out, "Concurrent Sessions Used") + assert.Contains(t, out, "Concurrent Sessions Available") + assert.Equal(t, 2, strings.Count(out, "unknown")) +} + +func TestOrgLimitsGet_OmitsConcurrencyUsageRowsWhenAbsent(t *testing.T) { + buf := capturePtermOutput(t) + c := OrgCmd{limits: &FakeOrgLimitsService{}} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.NotContains(t, out, "Concurrent Sessions Used") + assert.NotContains(t, out, "Concurrent Sessions Available") +} + func TestOrgLimitsGet_RendersManagedAuthLimits(t *testing.T) { buf := capturePtermOutput(t) fake := &FakeOrgLimitsService{ diff --git a/go.mod b/go.mod index 45b25060..ce177f64 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.108.0 + github.com/kernel/kernel-go-sdk v0.108.1-0.20260917195741-6367486e6e06 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 41e57300..3427968d 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.108.0 h1:GwCC3keLb9/Dy9ACEf5U62eCYk5mAgHavN7zvVeJDyk= -github.com/kernel/kernel-go-sdk v0.108.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.108.1-0.20260917195741-6367486e6e06 h1:VqkiahjT/KyvwQxlEqH/RCZjNd7ZFteoYHTuGj8uNfA= +github.com/kernel/kernel-go-sdk v0.108.1-0.20260917195741-6367486e6e06/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 6643d5d26f4bd716114f14219ac9e23237b0954c Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:18:47 +0000 Subject: [PATCH 05/18] Update Go SDK to 3038f83 and adopt ordered credential fields Bumps kernel-go-sdk to 3038f83eb3f7cc6a59059a0a04bdb111300ea77c, which preserves credential field order: CredentialVaultItemSpec.Fields and CredentialVaultItemSpecInputParam.Fields changed from a map keyed by name to an ordered array, and each definition now carries a stable `name`. - vaults_output.go: project spec.fields as an array (name, type, required, sensitive) instead of a keyed object, and look definitions up by name when deciding which non-sensitive values stay visible. - vaults_credentials.go: document the ordered array and the name pattern, update the create example, note that update specs stay keyed by name, reject specs still using the object form with actionable guidance, and require every create field to carry a name. Error text never echoes values. - README.md: same shape and ordering guidance. - Tests: fixtures moved to the array shape; added coverage for field order reaching the API unchanged, the keyed-form error, and the missing-name error. A full enumeration of api.md against the CLI command tree found no missing commands. All config-registry and auth exchange endpoints are x-cli-skip. Tested against the live API: vaults credentials create with out-of-alphabetical field order (order preserved in the request and in get/list output), vaults items get/list, vaults credentials update (keyed form, non-sensitive text/email values still surfaced, sensitive omitted), vaults items invoke fill by field name, and both new validation errors. Test vault, item, and browser deleted. Co-Authored-By: Claude Opus 5 --- README.md | 10 ++++-- cmd/vaults_credential_guidance_test.go | 2 +- cmd/vaults_credentials.go | 35 +++++++++++++++--- cmd/vaults_credentials_test.go | 8 ++--- cmd/vaults_fill_credentials_test.go | 8 ++--- cmd/vaults_output.go | 19 ++++++---- cmd/vaults_public_values_test.go | 6 ++-- cmd/vaults_sdk_contract_test.go | 50 +++++++++++++++++++++++++- go.mod | 2 +- go.sum | 4 +-- 10 files changed, 116 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 3c93a6aa..d60c53c7 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ sensitive form. Define the observed fields without supplying values: kernel vaults create --name user-vault kernel browsers create --vault user-vault kernel vaults credentials create user-vault login --spec-file - <<'JSON' -{"description":"Hacker News","fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}} +{"description":"Hacker News","fields":[{"name":"username","type":"text","required":true,"sensitive":false},{"name":"password","type":"password","required":true,"sensitive":true}]} JSON kernel vaults items get user-vault login --wait 60 -o json kernel vaults items invoke user-vault login fill --spec-file - <<'JSON' @@ -295,8 +295,14 @@ populated, not that login succeeded. `fill` requires an already-open page and ne navigates or submits it. Optional `page_url` selects the exact page; cards require it. Do not automatically retry failed/unknown fills or fall back to aliases. +Create specs list `fields` as an ordered array. Each entry carries a stable `name` +(letters, digits, and underscores, starting with a letter) that keys values, updates, +and fills. Order is preserved: list fields in the same top-to-bottom order as the +website, because the collection form renders that order unchanged. + Use `credentials update --version --spec-file changes.json` -with a spec such as `{"fields":{"password":{"value":"replacement"}}}`. Keep actual +with a spec such as `{"fields":{"password":{"value":"replacement"}}}`; update specs key +`fields` by name rather than using the ordered array. Keep actual secrets in protected files or stdin, never shell arguments. Omission preserves values; null or an empty string clears supported fields, including required text/email/password fields (returning them to pending collection). The form still requires nonempty required inputs. Field definitions cannot change. Stale versions fail, without retries. `items invoke collect` reopens the full form without diff --git a/cmd/vaults_credential_guidance_test.go b/cmd/vaults_credential_guidance_test.go index 00142808..eb63830c 100644 --- a/cmd/vaults_credential_guidance_test.go +++ b/cmd/vaults_credential_guidance_test.go @@ -22,7 +22,7 @@ func TestCredentialHumanGuidance(t *testing.T) { args := []string{"vaults", "items", "get", "user", "login"} switch operation { case "create": - args = []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password"}}}`)} + args = []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":[{"name":"password","type":"password"}]}`)} case "collect": args = []string{"vaults", "items", "invoke", "user", "login", "collect"} } diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 64f8f1b4..4347ec0d 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "context" "encoding/json" "fmt" @@ -21,8 +22,12 @@ card item types for credit cards and payment checkout instead. First create a vault for the end user and attach it with browsers create --vault. Use a protected JSON file or stdin, never secret values in shell arguments. -The spec contains description and fields keyed by name. Field types are text, -email, password, and totp; definitions accept required, sensitive, and value. +The spec contains description and an ordered fields array. Each field carries a +stable name (letters, digits, and underscores; must start with a letter) used to +key values, updates, and browser fills. List fields in the same top-to-bottom +order as the website: the collection form renders that order unchanged. +Field types are text, email, password, and totp; definitions accept required, +sensitive, and value. Set description to the recognizable site name only, e.g. "Hacker News", not "Hacker News sign-in credentials". This text is the user-facing form title. Set sensitive:false explicitly for ordinary usernames and email addresses. @@ -59,14 +64,14 @@ func newVaultCredentialsCommand() *cobra.Command { }, } if update { - cmd.Long += "\nUpdate preserves omitted fields, replaces nonempty string values, and clears supported values with null or an empty string. Clearing a required text/email/password field returns pending_collection; form submissions still require a nonempty value.\nField definitions are immutable. Do not automatically retry version conflicts." + cmd.Long += "\nUpdate spec fields are an object keyed by field name, not the ordered array used on create.\nUpdate preserves omitted fields, replaces nonempty string values, and clears supported values with null or an empty string. Clearing a required text/email/password field returns pending_collection; form submissions still require a nonempty value.\nField definitions are immutable. Do not automatically retry version conflicts." cmd.Flags().Int64("version", 0, "Expected version from items get (required; never auto-refreshed)") _ = cmd.MarkFlagRequired("version") cmd.Flags().String("expected-item-id", "", "Immutable item ID from the original read; reject an update if the key now refers to a replacement item") cmd.Example = " kernel vaults credentials update user-vault login --version 2 --spec-file changes.json" } else { cmd.Example = ` kernel vaults credentials create user-vault login --spec-file - <<'JSON' -{"description":"Hacker News","fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}} +{"description":"Hacker News","fields":[{"name":"username","type":"text","required":true,"sensitive":false},{"name":"password","type":"password","required":true,"sensitive":true}]} JSON` } cmd.Flags().String("spec-file", "", "Credential spec JSON file (use '-' for stdin; maximum 128 KiB)") @@ -123,8 +128,17 @@ func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data [ } else { var spec kernel.CredentialVaultItemSpecInputParam if json.Unmarshal(data, &spec) != nil || len(spec.Fields) == 0 { + if credentialSpecUsesKeyedFields(data) { + return fmt.Errorf("credential spec fields must be an ordered array of definitions carrying a name, not an object keyed by name") + } return fmt.Errorf("credential spec requires fields") } + // Names key values, updates, and fills; reject specs the form cannot address. + for _, field := range spec.Fields { + if strings.TrimSpace(field.Name) == "" { + return fmt.Errorf("every credential spec field requires a name") + } + } item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCredential: &kernel.CredentialVaultItemRequestParam{Type: "credential", Spec: spec}}, option.WithMaxRetries(0)) } if err != nil { @@ -132,3 +146,16 @@ func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data [ } return c.showItem(item, output, open) } + +// The create spec moved from fields keyed by name to an ordered array; point +// callers still sending the object form at the replacement shape. +func credentialSpecUsesKeyedFields(data []byte) bool { + var object struct { + Fields json.RawMessage `json:"fields"` + } + if json.Unmarshal(data, &object) != nil { + return false + } + fields := bytes.TrimSpace(object.Fields) + return len(fields) > 0 && fields[0] == '{' +} diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go index 8dc6ef2e..206392d0 100644 --- a/cmd/vaults_credentials_test.go +++ b/cmd/vaults_credentials_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -const credentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":2,"spec":{"description":"Website login","fields":{"password":{"type":"password","required":true,"sensitive":true,"value":"never-print"}}},"state":{"status":"pending_collection","fields":{"password":{"has_value":false,"value":"never-print"}}},"action":{"name":"collect","url":"https://vault.kernel.sh/collect#token=item.random","expires_at":"2026-10-01T00:00:00Z"},"available_operations":[{"type":"collect","description":"Open the form"},{"type":"fill","description":"Fill the form"}],"available_expansions":[]}` +const credentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":2,"spec":{"description":"Website login","fields":[{"name":"password","type":"password","required":true,"sensitive":true,"value":"never-print"}]},"state":{"status":"pending_collection","fields":{"password":{"has_value":false,"value":"never-print"}}},"action":{"name":"collect","url":"https://vault.kernel.sh/collect#token=item.random","expires_at":"2026-10-01T00:00:00Z"},"available_operations":[{"type":"collect","description":"Open the form"},{"type":"fill","description":"Fill the form"}],"available_expansions":[]}` func credentialSpecFile(t *testing.T, data string) string { t.Helper() @@ -42,12 +42,12 @@ func TestCredentialCreateAndUpdate(t *testing.T) { assert.JSONEq(t, `{"fields":{"password":{"value":null}}}`, string(body["spec"])) } else { assert.Equal(t, "PUT", r.Method) - assert.JSONEq(t, `{"fields":{"password":{"type":"password","required":true}}}`, string(body["spec"])) + assert.JSONEq(t, `{"fields":[{"name":"password","type":"password","required":true}]}`, string(body["spec"])) } w.Header().Set("Content-Type", "application/json") io.WriteString(w, credentialFixture) }) - args := []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password","required":true}}}`), "-o", "json"} + args := []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":[{"name":"password","type":"password","required":true}]}`), "-o", "json"} if update { args[2] = "update" args[6] = credentialSpecFile(t, `{"fields":{"password":{"value":null}}}`) @@ -75,7 +75,7 @@ func TestCredentialWriteErrorsAreRedactedAndNotRetried(t *testing.T) { io.WriteString(w, `{"message":"secret-echo"}`) }) c := VaultsCmd{vaults: &client.Vaults} - err := c.saveCredential(context.Background(), "user", "login", []byte(`{"fields":{"password":{"type":"password","value":"secret-echo"}}}`), false, 0, "", "json", false) + err := c.saveCredential(context.Background(), "user", "login", []byte(`{"fields":[{"name":"password","type":"password","value":"secret-echo"}]}`), false, 0, "", "json", false) require.Error(t, err) assert.NotContains(t, err.Error(), "secret-echo") assert.Equal(t, 1, calls) diff --git a/cmd/vaults_fill_credentials_test.go b/cmd/vaults_fill_credentials_test.go index 2432d9a5..ee9e6c26 100644 --- a/cmd/vaults_fill_credentials_test.go +++ b/cmd/vaults_fill_credentials_test.go @@ -13,13 +13,13 @@ import ( "github.com/stretchr/testify/require" ) -const readyFillCredentialFixture = `{"id":"credential-1","type":"credential","spec":{"fields":{"expiration":{"type":"password"},"custom field":{"type":"text"},"otp":{"type":"totp"}}},"state":{"status":"ready"},"available_operations":[{"type":"fill","description":"Fill credential fields."}]}` +const readyFillCredentialFixture = `{"id":"credential-1","type":"credential","spec":{"fields":[{"name":"expiration","type":"password"},{"name":"custom_field","type":"text"},{"name":"otp","type":"totp"}]},"state":{"status":"ready"},"available_operations":[{"type":"fill","description":"Fill credential fields."}]}` func TestVaultFillBothItemTypesAndInputs(t *testing.T) { for _, input := range []string{"params", "spec-file"} { for _, test := range []struct{ name, item, params, result string }{ {"card", readyFillCardFixture, fillParamsFixture, completedFillFixture}, - {"credential", readyFillCredentialFixture, `{"browser_id":"browser-id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, completedFillFixture}, + {"credential", readyFillCredentialFixture, `{"browser_id":"browser-id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom_field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, completedFillFixture}, {"credential URL", readyFillCredentialFixture, `{"browser_id":"browser-id","page_url":"http://localhost/login","fields":[{"field":"expiration","selector":"#password"}]}`, `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"}]}`}, } { t.Run(input+"/"+test.name, func(t *testing.T) { @@ -56,7 +56,7 @@ func TestVaultCredentialFillValidation(t *testing.T) { for _, params := range []string{ `{"browser_id":"id","fields":[{"field":"unknown","selector":"#field"}]}`, `{"browser_id":"id","fields":[{"field":"expiration","selector":"#field","format":"MM/YY"}]}`, - `{"browser_id":"id","fields":[{"field":"custom field","selector":"#field","format":"MM/YYYY"}]}`, + `{"browser_id":"id","fields":[{"field":"custom_field","selector":"#field","format":"MM/YYYY"}]}`, `{"browser_id":"id","fields":[{"field":"expiration","selector":"#field","value":"secret-sentinel"}]}`, `{"browser_id":"id","browser_id":"secret-sentinel","fields":[{"field":"expiration","selector":"#field"}]}`, } { @@ -106,7 +106,7 @@ func TestCredentialFillCLIOutcomes(t *testing.T) { io.WriteString(w, result) })) defer server.Close() - out, stderr, exit := runVaultFillCLI(t, server.URL, "fill", "--params", `{"browser_id":"id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, "-o", "json") + out, stderr, exit := runVaultFillCLI(t, server.URL, "fill", "--params", `{"browser_id":"id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom_field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, "-o", "json") assert.True(t, json.Valid([]byte(out))) assert.JSONEq(t, result, out) assert.Empty(t, stderr) diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 5616e52b..167af945 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -41,7 +41,7 @@ var vaultItemFields = vaultOutputFields{ "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, "context": nil, "expires_at": nil, "description": nil, - "fields": {"*": vaultFieldsOf("type required sensitive")}, + "fields": vaultFieldsOf("name type required sensitive"), "provider_config": vaultFieldsOf("id name"), "authorization": {"method": nil, "client": {"type": nil, "provider_config": vaultFieldsOf("id name")}}, "totals": vaultTotalFields, @@ -134,11 +134,14 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes } func preservePublicCredentialValues(source, result vaultJSON) error { + type credentialDefinition struct { + Name string `json:"name"` + Type string `json:"type"` + Sensitive *bool `json:"sensitive"` + } + // Definitions are an ordered array keyed by name; state values stay keyed by name. var spec struct { - Fields map[string]struct { - Type string `json:"type"` - Sensitive *bool `json:"sensitive"` - } `json:"fields"` + Fields []credentialDefinition `json:"fields"` } var values struct { Fields map[string]struct { @@ -149,8 +152,12 @@ func preservePublicCredentialValues(source, result vaultJSON) error { if json.Unmarshal(source["spec"], &spec) != nil || json.Unmarshal(source["state"], &values) != nil || values.Fields == nil { return nil } + definitions := make(map[string]credentialDefinition, len(spec.Fields)) + for _, definition := range spec.Fields { + definitions[definition.Name] = definition + } for name, field := range values.Fields { - definition := spec.Fields[name] + definition := definitions[name] if definition.Sensitive == nil || *definition.Sensitive || (definition.Type != "text" && definition.Type != "email") || !field.HasValue { field.Value = nil } diff --git a/cmd/vaults_public_values_test.go b/cmd/vaults_public_values_test.go index 192c86ad..960a8f32 100644 --- a/cmd/vaults_public_values_test.go +++ b/cmd/vaults_public_values_test.go @@ -12,10 +12,10 @@ import ( "github.com/stretchr/testify/require" ) -const publicCredentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":2,"spec":{"description":"Example","fields":{"username":{"type":"text","sensitive":false},"email":{"type":"email","sensitive":false},"password":{"type":"password"},"otp":{"type":"totp","sensitive":true}}},"state":{"status":"ready","fields":{"username":{"has_value":true,"value":"user-123"},"email":{"has_value":true,"value":"user@example.com"},"password":{"has_value":true,"value":"private-password"},"otp":{"has_value":true,"value":"private-seed"}}},"action":{"name":"collect","url":"https://vault.example/collect#token=user-123.token"},"available_operations":[{"type":"collect","description":"Open form"}],"available_expansions":[]}` +const publicCredentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":2,"spec":{"description":"Example","fields":[{"name":"username","type":"text","sensitive":false},{"name":"email","type":"email","sensitive":false},{"name":"password","type":"password"},{"name":"otp","type":"totp","sensitive":true}]},"state":{"status":"ready","fields":{"username":{"has_value":true,"value":"user-123"},"email":{"has_value":true,"value":"user@example.com"},"password":{"has_value":true,"value":"private-password"},"otp":{"has_value":true,"value":"private-seed"}}},"action":{"name":"collect","url":"https://vault.example/collect#token=user-123.token"},"available_operations":[{"type":"collect","description":"Open form"}],"available_expansions":[]}` func TestVaultPublicValuesAcrossCommands(t *testing.T) { - spec := credentialSpecFile(t, `{"fields":{"username":{"type":"text","sensitive":false,"value":"user-123"}}}`) + spec := credentialSpecFile(t, `{"fields":[{"name":"username","type":"text","sensitive":false,"value":"user-123"}]}`) update := credentialSpecFile(t, `{"fields":{"username":{"value":"user-123"}}}`) for _, args := range [][]string{ {"vaults", "credentials", "create", "user-123", "login", "--spec-file", spec}, @@ -55,7 +55,7 @@ func TestVaultPublicValueBoundary(t *testing.T) { {"text", "false", false, false}, } { t.Run(fmt.Sprint(tc), func(t *testing.T) { - raw := fmt.Sprintf(`{"type":"credential","spec":{"fields":{"field":{"type":%q,"sensitive":%s}}},"state":{"fields":{"field":{"has_value":%t,"value":"test-value"}}}}`, tc.kind, tc.sensitive, tc.hasValue) + raw := fmt.Sprintf(`{"type":"credential","spec":{"fields":[{"name":"field","type":%q,"sensitive":%s}]},"state":{"fields":{"field":{"has_value":%t,"value":"test-value"}}}}`, tc.kind, tc.sensitive, tc.hasValue) out, err := filterVaultJSON(json.RawMessage(raw), vaultItemFields) require.NoError(t, err) assert.Equal(t, tc.visible, strings.Contains(string(out), "test-value")) diff --git a/cmd/vaults_sdk_contract_test.go b/cmd/vaults_sdk_contract_test.go index 29fb41eb..f6ae322b 100644 --- a/cmd/vaults_sdk_contract_test.go +++ b/cmd/vaults_sdk_contract_test.go @@ -66,7 +66,7 @@ func TestCredentialUpdateIdentityPrecondition(t *testing.T) { func TestCredentialInitialValuesWithReleasedSDK(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") - spec := `{"description":"Example","fields":{"username":{"type":"text","sensitive":false,"value":"synthetic-user"},"email":{"type":"email","sensitive":false,"value":"test@example.com"},"password":{"type":"password","sensitive":true,"value":"synthetic-password"},"otp":{"type":"totp","sensitive":true,"value":"JBSWY3DPEHPK3PXP"}}}` + spec := `{"description":"Example","fields":[{"name":"username","type":"text","sensitive":false,"value":"synthetic-user"},{"name":"email","type":"email","sensitive":false,"value":"test@example.com"},{"name":"password","type":"password","sensitive":true,"value":"synthetic-password"},{"name":"otp","type":"totp","sensitive":true,"value":"JBSWY3DPEHPK3PXP"}]}` calls := 0 client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ @@ -123,3 +123,51 @@ func TestVaultPreparationEventsAreProjected(t *testing.T) { assert.Contains(t, out, `"preparation_id": "prep-1"`) assert.NotContains(t, out, "never-print") } + +func TestCredentialFieldOrderIsPreserved(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + spec := `{"description":"Example","fields":[{"name":"email","type":"email","required":true,"sensitive":false},{"name":"password","type":"password","required":true,"sensitive":true},{"name":"otp","type":"totp","required":false,"sensitive":true}]}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Spec struct { + Fields json.RawMessage `json:"fields"` + } `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + // The website's top-to-bottom order must reach the API unchanged. + assert.Equal(t, `[{"name":"email","type":"email","required":true,"sensitive":false},{"name":"password","type":"password","required":true,"sensitive":true},{"name":"otp","type":"totp","required":false,"sensitive":true}]`, string(body.Spec.Fields)) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":"credential-1","key":"login","type":"credential","version":1,"spec":%s,"state":{"status":"pending_collection","fields":{"email":{"has_value":false},"password":{"has_value":false},"otp":{"has_value":false}}},"available_operations":[],"available_expansions":[]}`, spec) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user-123", "login", "--spec-file", credentialSpecFile(t, spec), "-o", "json") + require.NoError(t, err) + assert.Less(t, strings.Index(out, `"email"`), strings.Index(out, `"password"`)) + assert.Less(t, strings.Index(out, `"password"`), strings.Index(out, `"otp"`)) + for _, name := range []string{"email", "password", "otp"} { + assert.Contains(t, out, fmt.Sprintf(`"name": %q`, name)) + } +} + +func TestCredentialKeyedFieldsAreRejectedWithGuidance(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("a keyed create spec must not reach the API") + }) + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user-123", "login", + "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password","value":"secret-echo"}}}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "ordered array") + assert.NotContains(t, err.Error(), "secret-echo") +} + +func TestCredentialFieldsRequireNames(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("an unnamed field must not reach the API") + }) + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user-123", "login", + "--spec-file", credentialSpecFile(t, `{"fields":[{"type":"password","value":"secret-echo"}]}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "name") + assert.NotContains(t, err.Error(), "secret-echo") +} diff --git a/go.mod b/go.mod index ce177f64..ac688f40 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.108.1-0.20260917195741-6367486e6e06 + github.com/kernel/kernel-go-sdk v0.108.1-0.20260917210657-3038f83eb3f7 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 3427968d..39ce8880 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.108.1-0.20260917195741-6367486e6e06 h1:VqkiahjT/KyvwQxlEqH/RCZjNd7ZFteoYHTuGj8uNfA= -github.com/kernel/kernel-go-sdk v0.108.1-0.20260917195741-6367486e6e06/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.108.1-0.20260917210657-3038f83eb3f7 h1:JCSdNBocuT1xb4jig5f1ChwUGcQG3S5w2dNAstrk0r4= +github.com/kernel/kernel-go-sdk v0.108.1-0.20260917210657-3038f83eb3f7/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 7176b477373369268b8820bd74f8e3ad292b4f09 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:41:31 +0000 Subject: [PATCH 06/18] chore: bump Go SDK to 5cfcedd and document opaque AgentCard card IDs Updates github.com/kernel/kernel-go-sdk to v0.108.1-0.20260917213155-5cfceddfb1ea (5cfcedd). The SDK change ("Accept opaque AgentCard vaulted card IDs") revises the card_id contract on CardVaultItemSpecAgentcard: the value is an opaque ID returned by AgentCard and must be passed through unchanged, without assuming a prefix or format. The `vaults cards create|update` help text still advertised a `vc_...` shape, so it is updated to match. The CLI already forwards card_id verbatim inside the --spec JSON (there is no --card-id flag), so no behavior change was needed; a regression test now pins that prefixed, unprefixed and punctuated IDs all round-trip unchanged. A full enumeration of all 165 SDK methods in api.md against the CLI command tree found no missing commands. The six client.ConfigRegistry.* methods are marked x-cli-skip in openapi.yaml and are intentionally absent. The AgentCard and Link card spec field sets in the help text were verified complete against the SDK param structs. No new params or fields were added by this SDK bump, so no new flags were required. Tested: go build ./..., go vet ./..., go test ./... (all pass); vaults cards create --help (help renders on one line); against production API: auth status, app list, vaults list, vaults items list agentcard-vault, vaults items get agentcard-vault. Co-Authored-By: Claude Opus 5 --- cmd/vaults_help.go | 2 +- cmd/vaults_test.go | 26 ++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 8e3dc275..e9b38ab7 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -69,7 +69,7 @@ type AgentCardCardSpec = { merchant: string; // approval-screen name; 1..120 characters amount: number; // integer minor units; 1..9007199254740991 currency: string; // three letters - card_id?: string; // vc_...; otherwise chosen at approval + card_id?: string; // opaque AgentCard ID, pass through unchanged; else chosen at approval }; type LinkLineItem = { diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 14942156..5ec933b3 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -303,6 +303,32 @@ func TestVaultCardRequestMapping(t *testing.T) { } } +func TestVaultCardAgentcardCardIDIsOpaque(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + // AgentCard card IDs are opaque: the CLI must forward whatever the caller + // supplies without assuming a prefix or format. + for _, cardID := range []string{"vc_chosen", "chosen", "card-123", "AGC/9f2e::7"} { + t.Run(cardID, func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + var spec struct { + CardID string `json:"card_id"` + } + require.NoError(t, json.Unmarshal(body["spec"], &spec)) + assert.Equal(t, cardID, spec.CardID) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, requestedCardFixture) + }) + spec := fmt.Sprintf(`{"wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":%q}`, cardID) + _, _, err := executeVaultCommand(t, client, + "vaults", "cards", "create", "checkout", "order-1", "-o", "json", + "--provider", "agentcard", "--spec", spec) + require.NoError(t, err) + }) + } +} + func TestVaultInvokeRequiresAdvertisedOperation(t *testing.T) { t.Setenv("KERNEL_PROJECT", "project-test") for _, state := range []string{"requested", "pending_authorization", "ready", "consumed", "expired", "declined"} { diff --git a/go.mod b/go.mod index ac688f40..b1f6bf1d 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.108.1-0.20260917210657-3038f83eb3f7 + github.com/kernel/kernel-go-sdk v0.108.1-0.20260917213155-5cfceddfb1ea github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 39ce8880..e703b32d 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.108.1-0.20260917210657-3038f83eb3f7 h1:JCSdNBocuT1xb4jig5f1ChwUGcQG3S5w2dNAstrk0r4= -github.com/kernel/kernel-go-sdk v0.108.1-0.20260917210657-3038f83eb3f7/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.108.1-0.20260917213155-5cfceddfb1ea h1:x6h74lPqdIdkUGCxmR7mVPE7pcNj8n+b1/xt9tCLdgA= +github.com/kernel/kernel-go-sdk v0.108.1-0.20260917213155-5cfceddfb1ea/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 0601cdf01da844a842fa8a3a3a409aac6ab5d20a Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:45:47 +0000 Subject: [PATCH 07/18] chore: bump Go SDK to v0.109.0 (5c91a27) Updates github.com/kernel/kernel-go-sdk from v0.108.1-0.20260917213155-5cfceddfb1ea to the v0.109.0 release (5c91a27). Coverage analysis: api.md is byte-identical across this bump, so no API methods, params or fields were added and no new commands or flags are required. The only source changes relative to the previously pinned pseudo-version are the release version stamp and a new ConfigRegistryAnalysisService.WaitForResult convenience helper. All six /config-registry endpoints are marked x-cli-skip in openapi.yaml, so that helper is intentionally out of CLI scope. A full enumeration of all 165 api.md methods against the CLI command tree found no gaps. The six methods that do not match cmd/ by name are covered through the SDK's *Streaming variants (Deployments.FollowStreaming, Invocations.FollowStreaming, Browsers.Logs.StreamStreaming, Browsers.Telemetry.StreamStreaming, Browsers.Fs.Watch.EventsStreaming, Browsers.Process.StdoutStreamStreaming). The OrgLimits concurrency fields and the opaque AgentCard card_id help text, both landed earlier on this branch, were re-verified against the release. Tested: go build ./..., go vet ./..., go test ./... (all pass); against the production API: org limits get (both concurrency fields render), vaults list, vaults cards create --help. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b1f6bf1d..196dbe1f 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.108.1-0.20260917213155-5cfceddfb1ea + github.com/kernel/kernel-go-sdk v0.109.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index e703b32d..c34e2e2f 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.108.1-0.20260917213155-5cfceddfb1ea h1:x6h74lPqdIdkUGCxmR7mVPE7pcNj8n+b1/xt9tCLdgA= -github.com/kernel/kernel-go-sdk v0.108.1-0.20260917213155-5cfceddfb1ea/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.109.0 h1:en9sI4w4AW0CptJn92KjpdvrQtzWohKcCu0ahmI6Ncg= +github.com/kernel/kernel-go-sdk v0.109.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 51d68109a081feb36bf277181be527749e097a34 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:53:51 +0000 Subject: [PATCH 08/18] Update SDK to c4dc2a2 and surface credential field labels Bumps kernel-go-sdk from v0.109.0 to v0.109.1-0.20260918194421-c4dc2a28cd2b, which adds an optional `label` to CredentialVaultFieldDefinition and CredentialVaultFieldInputParam. `label` is non-secret display metadata for the hosted collection form. It already reached the API because credential specs are passed through from --spec-file, but the display-safe output projection dropped it: the spec.fields allowlist in printVaultItem only permitted name/type/required/ sensitive, so every read silently discarded a label the API returned. Add it to the allowlist and document it in the command help and README. A full enumeration of api.md (165 methods) against the CLI command tree (176 commands) found no missing commands. The six config-registry endpoints are marked x-cli-skip in openapi.yaml and remain unexposed; Invocations.Follow and Deployments.Follow are covered by the streaming paths in logs/invoke/deploy logs. Tested against the live API: vaults credentials create with labelled text and password fields round-trips `label` to the API and back, and vaults items get/list both render it. Verified the pre-fix binary stripped `label` from the same responses. Sensitive values stay omitted. Co-Authored-By: Claude Opus 5 --- README.md | 5 ++++- cmd/vaults_credentials.go | 4 +++- cmd/vaults_output.go | 2 +- cmd/vaults_public_values_test.go | 38 ++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 78d9dba4..ae9937ca 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,10 @@ Do not automatically retry failed/unknown fills or fall back to aliases. Create specs list `fields` as an ordered array. Each entry carries a stable `name` (letters, digits, and underscores, starting with a letter) that keys values, updates, and fills. Order is preserved: list fields in the same top-to-bottom order as the -website, because the collection form renders that order unchanged. +website, because the collection form renders that order unchanged. An optional `label` +supplies non-secret display text for that field on the collection form; it never +affects value keys, updates, or fills. Use a single trimmed line of at most 128 UTF-8 +bytes, and it is returned as metadata in `get`/`list` output. Use `credentials update --version --spec-file changes.json` with a spec such as `{"fields":{"password":{"value":"replacement"}}}`; update specs key diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 84006e7b..5162a525 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -25,7 +25,9 @@ Use a protected JSON file or stdin, never secret values in shell arguments. The spec contains description and fields as an ordered array of named definitions. Inspect the website and list fields in its natural top-to-bottom order because the user-facing collection form renders that order unchanged. Field types are text, -email, password, and totp; definitions accept name, required, sensitive, and value. +email, password, and totp; definitions accept name, label, required, sensitive, and value. +Optional label is non-secret display text for the form; it never changes value keys, +updates, or fills. Use a single trimmed line of at most 128 UTF-8 bytes. Set description to the recognizable site name only, e.g. "Hacker News", not "Hacker News sign-in credentials". This text is the user-facing form title. Set sensitive:false explicitly for ordinary usernames and email addresses. diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 49270fcf..9d1c60bd 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -41,7 +41,7 @@ var vaultItemFields = vaultOutputFields{ "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, "context": nil, "expires_at": nil, "description": nil, - "fields": vaultFieldsOf("name type required sensitive"), + "fields": vaultFieldsOf("name label type required sensitive"), "provider_config": vaultFieldsOf("id name"), "authorization": {"method": nil, "client": {"type": nil, "provider_config": vaultFieldsOf("id name")}}, "totals": vaultTotalFields, diff --git a/cmd/vaults_public_values_test.go b/cmd/vaults_public_values_test.go index 669e6e63..4c9b0670 100644 --- a/cmd/vaults_public_values_test.go +++ b/cmd/vaults_public_values_test.go @@ -118,3 +118,41 @@ func TestVaultFillActionableErrors(t *testing.T) { }) } } + +// label is non-secret display metadata: it must reach the API unchanged on create +// and survive the display-safe output projection on every read. +func TestVaultCredentialLabelsRoundTrip(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + spec := `{"description":"Hacker News","fields":[{"name":"username","label":"Username or email","type":"text","required":true,"sensitive":false},{"name":"password","label":"Password","type":"password","required":true,"sensitive":true}]}` + fixture := fmt.Sprintf(`{"id":"credential-1","key":"login","type":"credential","version":1,"spec":%s,"state":{"status":"pending_collection","fields":{"username":{"has_value":false},"password":{"has_value":false}}},"available_operations":[],"available_expansions":[]}`, spec) + sent := "" + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Spec struct { + Fields json.RawMessage `json:"fields"` + } `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + sent = string(body.Spec.Fields) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, fixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user-123", "login", "--spec-file", credentialSpecFile(t, spec), "-o", "json") + require.NoError(t, err) + assert.Contains(t, sent, `"label":"Username or email"`) + assert.Contains(t, sent, `"label":"Password"`) + assert.Contains(t, out, `"label": "Username or email"`) + assert.Contains(t, out, `"label": "Password"`) +} + +// A label is metadata only; it must never carry a value into the output. +func TestVaultCredentialLabelDoesNotExposeValues(t *testing.T) { + fixture := strings.Replace(publicCredentialFixture, + `{"name":"password","type":"password"}`, + `{"name":"password","label":"Password","type":"password"}`, 1) + require.NotEqual(t, publicCredentialFixture, fixture) + out, err := filterVaultJSON(json.RawMessage(fixture), vaultItemFields) + require.NoError(t, err) + assert.Contains(t, string(out), `"label":"Password"`) + assert.NotContains(t, string(out), "private-password") +} diff --git a/go.mod b/go.mod index 196dbe1f..fea07dc7 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.109.0 + github.com/kernel/kernel-go-sdk v0.109.1-0.20260918194421-c4dc2a28cd2b github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index c34e2e2f..e8b3adea 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.109.0 h1:en9sI4w4AW0CptJn92KjpdvrQtzWohKcCu0ahmI6Ncg= -github.com/kernel/kernel-go-sdk v0.109.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.109.1-0.20260918194421-c4dc2a28cd2b h1:S2z/v7V8lUYRr2k4oJqD8nvJf5chPTqPHGWgic5xQeY= +github.com/kernel/kernel-go-sdk v0.109.1-0.20260918194421-c4dc2a28cd2b/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 5c5dc6232dd9a9ec5b97e61eca1612e63e3fdf6e Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:57:56 +0000 Subject: [PATCH 09/18] chore: bump Go SDK to released v0.110.0 (a414a68) The credential field `label` support landed here against the pre-release commit c4dc2a2 (pseudo-version v0.109.1-0.20260918194421-c4dc2a28cd2b). That change is now published as the tagged v0.110.0 release, so pin the release instead of the pseudo-version; the SDK source is identical. Also guards the credentials help text with a steering assertion so the `label` documentation is not silently dropped by a later edit. A full enumeration of all 165 api.md methods against the CLI command tree found no missing commands; the 6 config-registry methods are x-cli-skip. Tested against the live API: vaults credentials create with labelled text and password fields round-trips `label` through create, items get, and items list; the test vault was deleted afterwards. go build ./... and go test ./... pass. Co-Authored-By: Claude Opus 5 --- cmd/vaults_credential_steering_test.go | 1 + go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/vaults_credential_steering_test.go b/cmd/vaults_credential_steering_test.go index 7f2e1667..173f9bfe 100644 --- a/cmd/vaults_credential_steering_test.go +++ b/cmd/vaults_credential_steering_test.go @@ -36,4 +36,5 @@ func TestCredentialHelpSteering(t *testing.T) { assert.Contains(t, cmd.Example, `"description":"Hacker News"`) assert.Contains(t, cmd.Example, `"fields":[{"name":"username"`) assert.Contains(t, cmd.Example, `"sensitive":false`) + assert.Contains(t, cmd.Long, "definitions accept name, label, required, sensitive") } diff --git a/go.mod b/go.mod index fea07dc7..dcae6b62 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.109.1-0.20260918194421-c4dc2a28cd2b + github.com/kernel/kernel-go-sdk v0.110.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index e8b3adea..1f47cec9 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.109.1-0.20260918194421-c4dc2a28cd2b h1:S2z/v7V8lUYRr2k4oJqD8nvJf5chPTqPHGWgic5xQeY= -github.com/kernel/kernel-go-sdk v0.109.1-0.20260918194421-c4dc2a28cd2b/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.110.0 h1:2KkE0hAlJav5xg2818Eg+mIK2p1F2nDZ0rZdA2EO1QQ= +github.com/kernel/kernel-go-sdk v0.110.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 863ed39e5efd6e9693bd7e4253d5ca673cb10775 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:36:09 +0000 Subject: [PATCH 10/18] chore: bump Go SDK to 6e379e6 (proxy_error raw_code) Bumps kernel-go-sdk from v0.110.0 to v0.110.1-0.20260918232759-6e379e6df7b9. The only change is in browsertelemetry.go: the proxy_error event's code enum gains origin_response_incomplete, restricted_route_unavailable, and unknown, and BrowserProxyErrorEventData gains raw_code (the sanitized X-Kernel-Proxy-Error header, present only when code is unknown). No CLI change is needed for that field. Unlike the credential-label case, telemetry output has no display allowlist: `browsers telemetry events` and `telemetry stream` render the Seq/Time/Category/Type table from the event envelope and emit the event body verbatim under `-o json`, so raw_code and the new codes flow through on their own. A full enumeration of api.md (165 methods) against the CLI command tree (175 commands) found no missing commands or flags. The six config-registry endpoints are marked x-cli-skip in openapi.yaml and remain unexposed, as does POST /auth/connections/{id}/exchange, which the SDK does not surface; the other 159 methods all map to a command. No request param struct changed since v0.110.0, so no new flags. Also drops a stale assertion in TestCredentialHelpSteering. The branch and main both added credential field labels; main's wording of the help text won the merge, but the branch's assertion on its own earlier phrasing ("definitions accept name, label, required, sensitive") survived and failed. The neighboring assertion on "optional non-secret human-readable label" already covers the same guidance. Tested against the live API: browsers create --telemetry network,page; browsers curl; browsers telemetry events (table and -o json); browsers delete. Full go test ./... passes. Co-Authored-By: Claude Opus 5 --- cmd/vaults_credential_steering_test.go | 1 - go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/cmd/vaults_credential_steering_test.go b/cmd/vaults_credential_steering_test.go index 065c7351..4ca0b47b 100644 --- a/cmd/vaults_credential_steering_test.go +++ b/cmd/vaults_credential_steering_test.go @@ -39,5 +39,4 @@ func TestCredentialHelpSteering(t *testing.T) { assert.Contains(t, cmd.Example, `"fields":[{"name":"username"`) assert.Contains(t, cmd.Example, `"label":"Username"`) assert.Contains(t, cmd.Example, `"sensitive":false`) - assert.Contains(t, cmd.Long, "definitions accept name, label, required, sensitive") } diff --git a/go.mod b/go.mod index dcae6b62..54673228 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.110.0 + github.com/kernel/kernel-go-sdk v0.110.1-0.20260918232759-6e379e6df7b9 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 1f47cec9..7f091b2b 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.110.0 h1:2KkE0hAlJav5xg2818Eg+mIK2p1F2nDZ0rZdA2EO1QQ= -github.com/kernel/kernel-go-sdk v0.110.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.110.1-0.20260918232759-6e379e6df7b9 h1:pXDNpTFqbNjXMSmMYlbzAIpqlVVuyaNP1Xzsm36qvRI= +github.com/kernel/kernel-go-sdk v0.110.1-0.20260918232759-6e379e6df7b9/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 46ca7ed53054a188b24880271a716fd368f23e54 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:05:35 +0000 Subject: [PATCH 11/18] feat: Update Go SDK to dd9a4a4 and accept adyen checkout processor Bumps kernel-go-sdk to v0.110.1-0.20260921155736-dd9a4a4e3d3a, which adds AgentcardPreparedProcessorAdyen and reframes prepare_checkout from "tokenization" to general "checkout" preparation. - cmd/vaults_prepare_checkout.go: add adyen to vaultCheckoutProcessors so `checkout.psp: "adyen"` passes CLI validation instead of being rejected - cmd/vaults_commands.go, README.md: document adyen, its production/sandbox environment pairing, the fresh-card Sessions restriction (public dummy card fields, not vault aliases), and that device approval / browser Authorised responses are not capture or fulfillment evidence Full enumeration of api.md methods against CLI commands found no missing commands; the six /config-registry methods and auth/connections exchange are marked x-cli-skip in openapi.yaml. No new param fields in this SDK bump. Tested: `go build ./...` and `go test ./...` pass; against the live API, `vaults items invoke prepare_checkout --params '{"checkout":{...,"psp":"adyen"}}'` now passes CLI validation and reaches the API, while `"psp":"stripe"` is still rejected with the updated processor list. Did not arm a real Adyen preparation, since preparations are single-use and would irreversibly consume a live AgentCard card. Co-Authored-By: Claude Opus 5 --- README.md | 15 +++++++++++---- cmd/vaults_commands.go | 10 ++++++---- cmd/vaults_prepare_checkout.go | 7 ++++--- cmd/vaults_prepare_checkout_test.go | 2 +- go.mod | 2 +- go.sum | 4 ++-- 6 files changed, 25 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index b2e69e77..8044962b 100644 --- a/README.md +++ b/README.md @@ -569,14 +569,21 @@ kernel vaults items get user-123 order-1 --wait 60 -o json this vault attached. `merchant_origin` is the canonical HTTPS origin of the top-level merchant document, not a processor iframe; HTTP localhost is allowed for tests. -Optional `psp` selects the tokenization processor: `square`, `braintree`, `worldpay`, -`bambora`, or `mercado_pago`. Omit it for Square; non-Square processors require +Optional `psp` selects the checkout processor: `square`, `braintree`, `worldpay`, +`bambora`, `mercado_pago`, or `adyen`. Omit it for Square; non-Square processors require multi-processor preparation enablement. `environment` is `production`, `sandbox`, or -`shared`: use `production` or `sandbox` for Square, Braintree and Worldpay, and `shared` -for Bambora and Mercado Pago. Shared endpoints do not establish test mode; merchant +`shared`: use `production` or `sandbox` for Square, Braintree, Worldpay and Adyen, and +`shared` for Bambora and Mercado Pago. Shared endpoints do not establish test mode; merchant credentials and configuration determine processor test mode, independently of the AgentCard credential mode. +`adyen` supports fresh-card Sessions requests on Adyen hosts only. Fill the public dummy +card fields rather than vault aliases, and keep the approval page open through device +handoff, including Adyen encryption. The unique armed preparation is associated with the +next eligible request from the declared browser and merchant origin; competing preparations +are rejected. Adyen device approval and browser `Authorised` responses are not capture or +fulfillment evidence. + Keep the approval page open. Poll until the item's status is `ready_to_submit`, then submit native Pay before `state.preparation.expires_at`. Readiness lasts at most 30 seconds, and polling does not extend it. The CLI displays the preparation ID, status, diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index d725e66c..95518a87 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -177,11 +177,13 @@ and corrective guidance; no fields were written by that request. Inspect and cor the cause before deciding on a new fill. Transport loss remains an uncertain outcome. prepare_checkout requires checkout.browser_id, checkout.merchant_origin (canonical HTTPS origin of the top-level merchant page, not a processor iframe), and checkout.environment -(production, sandbox, or shared). Optional checkout.psp selects the tokenization processor: -square, braintree, worldpay, bambora, or mercado_pago. Omit psp for Square; non-Square +(production, sandbox, or shared). Optional checkout.psp selects the checkout processor: +square, braintree, worldpay, bambora, mercado_pago, or adyen. Omit psp for Square; non-Square processors require multi-processor preparation enablement. Use production or sandbox for -square, braintree and worldpay; shared for bambora and mercado_pago. Shared endpoints do not -establish test mode; merchant credentials determine it. +square, braintree, worldpay and adyen; shared for bambora and mercado_pago. Shared endpoints do +not establish test mode; merchant credentials determine it. adyen prepares fresh-card Sessions +requests on Adyen hosts only: fill public dummy card fields, not vault aliases. Adyen device +approval and browser Authorised responses are not capture or fulfillment evidence. Use only when advertised for an AgentCard card. Keep the returned approval page open, poll until ready_to_submit, then submit native Pay before preparation.expires_at. Preparations are single-use, including after failure or expiry; never retry automatically. diff --git a/cmd/vaults_prepare_checkout.go b/cmd/vaults_prepare_checkout.go index b58ca4d8..74967c6e 100644 --- a/cmd/vaults_prepare_checkout.go +++ b/cmd/vaults_prepare_checkout.go @@ -10,9 +10,9 @@ import ( kernel "github.com/kernel/kernel-go-sdk" ) -// Environments and tokenization processors accepted by prepare_checkout. Square, -// Braintree and Worldpay use production or sandbox; Bambora and Mercado Pago use -// shared. Pairing is enforced by the API, which owns processor enablement. +// Environments and checkout processors accepted by prepare_checkout. Square, +// Braintree, Worldpay and Adyen use production or sandbox; Bambora and Mercado Pago +// use shared. Pairing is enforced by the API, which owns processor enablement. var vaultCheckoutEnvironments = []kernel.VaultCheckoutContextEnvironment{ kernel.VaultCheckoutContextEnvironmentProduction, kernel.VaultCheckoutContextEnvironmentSandbox, @@ -25,6 +25,7 @@ var vaultCheckoutProcessors = []kernel.AgentcardPreparedProcessor{ kernel.AgentcardPreparedProcessorWorldpay, kernel.AgentcardPreparedProcessorBambora, kernel.AgentcardPreparedProcessorMercadoPago, + kernel.AgentcardPreparedProcessorAdyen, } func vaultCheckoutProcessorNames() []string { diff --git a/cmd/vaults_prepare_checkout_test.go b/cmd/vaults_prepare_checkout_test.go index 5cb43950..cadecc72 100644 --- a/cmd/vaults_prepare_checkout_test.go +++ b/cmd/vaults_prepare_checkout_test.go @@ -108,7 +108,7 @@ func TestVaultPrepareCheckoutInvalidParams(t *testing.T) { } _, err := parseVaultCheckoutParams(strings.Replace(checkoutParamsFixture, "https://shop.example", "http://localhost:3000", 1)) require.NoError(t, err) - for _, psp := range []string{"square", "braintree", "worldpay", "bambora", "mercado_pago"} { + for _, psp := range []string{"square", "braintree", "worldpay", "bambora", "mercado_pago", "adyen"} { params, err := parseVaultCheckoutParams(strings.Replace(checkoutParamsFixture, `"environment":`, `"psp":"`+psp+`","environment":`, 1)) require.NoError(t, err, psp) assert.Equal(t, psp, string(params.Psp)) diff --git a/go.mod b/go.mod index 54673228..a7bcd594 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.110.1-0.20260918232759-6e379e6df7b9 + github.com/kernel/kernel-go-sdk v0.110.1-0.20260921155736-dd9a4a4e3d3a github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 7f091b2b..09a466f6 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.110.1-0.20260918232759-6e379e6df7b9 h1:pXDNpTFqbNjXMSmMYlbzAIpqlVVuyaNP1Xzsm36qvRI= -github.com/kernel/kernel-go-sdk v0.110.1-0.20260918232759-6e379e6df7b9/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.110.1-0.20260921155736-dd9a4a4e3d3a h1:XipdgJaTGlvnqKsC+lV69HVVQXjU9ZUfKFUEPJLjF9A= +github.com/kernel/kernel-go-sdk v0.110.1-0.20260921155736-dd9a4a4e3d3a/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 345a60db50bd65eb831fa2edf1b412ea7be8a502 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:06:01 +0000 Subject: [PATCH 12/18] chore: bump Go SDK to 68c5100 (optimistic managed auth reauth) Updates github.com/kernel/kernel-go-sdk to v0.110.1-0.20260921185747-68c51002947b (68c5100). The SDK delta from dd9a4a4 touches only authconnection.go: documentation rewording on ManagedAuth.CanReauth / CanReauthReason plus one new enum value, ManagedAuthCanReauthReasonOptimisticTotpAttempt ("optimistic_totp_attempt"). No new methods, resources, or param fields. The CLI renders CanReauthReason as an opaque string in both the auth connections list and get views, so the new value surfaces with no code change. Coverage analysis: full enumeration of all 165 methods in the SDK api.md against the 176 commands in the CLI command tree found no missing commands. The 6 ConfigRegistry methods are marked x-cli-skip in openapi.yaml and are correctly absent. Tested against the production API: - kernel status - kernel auth context - kernel auth connections list --limit 3 (table and -o json) - kernel auth connections get (Can Reauth / Can Reauth Reason render) - go build ./..., go vet ./..., go test ./... all pass Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a7bcd594..48c10483 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.110.1-0.20260921155736-dd9a4a4e3d3a + github.com/kernel/kernel-go-sdk v0.110.1-0.20260921185747-68c51002947b github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 09a466f6..52514cf8 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.110.1-0.20260921155736-dd9a4a4e3d3a h1:XipdgJaTGlvnqKsC+lV69HVVQXjU9ZUfKFUEPJLjF9A= -github.com/kernel/kernel-go-sdk v0.110.1-0.20260921155736-dd9a4a4e3d3a/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.110.1-0.20260921185747-68c51002947b h1:uTkTLNjwKYGKknqPUYrPSEuGH94t3HsXejyYIK6KNq0= +github.com/kernel/kernel-go-sdk v0.110.1-0.20260921185747-68c51002947b/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From b6f9f2a9c8059563b2701b434df33610afcbbc36 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:29:42 +0000 Subject: [PATCH 13/18] chore: bump Go SDK to ca8d465 and surface managed auth completion times Updates kernel-go-sdk to v0.110.1-0.20260921192205-ca8d4655a0dc, which adds ManagedAuthTimelineEvent.CompletedAt: the stable timestamp for when a login/reauth attempt first reached a terminal status. Full enumeration of api.md methods against cmd/ found no missing commands or param flags. The only SDK change was this response field, so `kernel auth connections timeline` now renders a Completed column alongside Timestamp. It dashes out for in-progress attempts, health checks, and historical attempts with no recorded completion time. Tested: kernel auth connections timeline (table + --output json, with and without --type/--page/--per-page) against the staging API; unit test extended to cover a populated completed_at. Co-Authored-By: Claude Opus 5 --- cmd/auth_connections.go | 5 ++++- cmd/auth_connections_test.go | 7 ++++++- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index be139f69..2d05b2bd 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -1069,7 +1069,7 @@ func (c AuthConnectionCmd) Timeline(ctx context.Context, in AuthConnectionTimeli return nil } - tableData := pterm.TableData{{"Timestamp", "Type", "Status", "Step", "Browser Session", "Telemetry", "Details"}} + tableData := pterm.TableData{{"Timestamp", "Completed", "Type", "Status", "Step", "Browser Session", "Telemetry", "Details"}} for _, e := range events { details := e.ErrorMessage if details == "" { @@ -1087,6 +1087,9 @@ func (c AuthConnectionCmd) Timeline(ctx context.Context, in AuthConnectionTimeli } tableData = append(tableData, []string{ util.FormatLocal(e.Timestamp), + // Absent (dashed out) for in-progress attempts, health checks, and + // older attempts recorded before completion times were persisted. + util.FormatLocal(e.CompletedAt), string(e.Type), string(e.Status), string(e.Step), diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index f23888c3..d79feaac 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "os" "testing" + "time" "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" @@ -1223,7 +1224,8 @@ func TestTimeline_RendersEventsAndPagination(t *testing.T) { "type": "login", "status": "SUCCESS", "browser_session_id": "browser_1", - "telemetry_captured": true + "telemetry_captured": true, + "completed_at": "2026-09-21T12:00:00Z" }`), &loginEvent)) fake := &FakeAuthConnectionService{ TimelineFunc: func(ctx context.Context, id string, query kernel.AuthConnectionTimelineParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent], error) { @@ -1252,6 +1254,9 @@ func TestTimeline_RendersEventsAndPagination(t *testing.T) { // Telemetry capture is reported for events that have a browser session. assert.Contains(t, out, "Telemetry") assert.Regexp(t, `browser_1.*yes`, out) + // completed_at is shown for terminal attempts and dashed out otherwise. + assert.Contains(t, out, "Completed") + assert.Contains(t, out, util.FormatLocal(time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC))) // The third event is truncated off the page. assert.NotContains(t, out, "health_check") assert.Contains(t, out, "Has more: yes") diff --git a/go.mod b/go.mod index 48c10483..ed4ae22a 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.110.1-0.20260921185747-68c51002947b + github.com/kernel/kernel-go-sdk v0.110.1-0.20260921192205-ca8d4655a0dc github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 52514cf8..d2b73d39 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.110.1-0.20260921185747-68c51002947b h1:uTkTLNjwKYGKknqPUYrPSEuGH94t3HsXejyYIK6KNq0= -github.com/kernel/kernel-go-sdk v0.110.1-0.20260921185747-68c51002947b/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.110.1-0.20260921192205-ca8d4655a0dc h1:n90WNvVLRYFPtkmp8rMes2emHBkFd0PZYY5QBVye+ws= +github.com/kernel/kernel-go-sdk v0.110.1-0.20260921192205-ca8d4655a0dc/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From e7efb1035c3dea2f573f5e9c02f4f15b629614f1 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:59:47 +0000 Subject: [PATCH 14/18] feat: add --skill-mode to auth connections login, bump SDK Updates kernel-go-sdk to aee0b02b9b0bdb2f84172ee9b568696dcd8fa482, which adds AuthConnectionLoginParams.SkillMode: a per-login control over whether the flow reads and writes learned domain skills. Automatic reauths inherit the selected mode, so exposing it on `kernel auth connections login` is the only way to opt a connection out of skill learning from the CLI. The value is validated locally against enabled/disabled so a typo fails before a login flow is started, and omitting the flag leaves the field unset so the API keeps its default of enabled. A full enumeration of api.md methods against the CLI's commands found no other gaps: every SDK method has a command except the config-registry and auth exchange endpoints marked x-cli-skip, and the only uncovered params fields are deprecated (login browser_telemetry), handled internally (audit log page_token via ListAutoPaging), or not applicable (curl response_encoding, since the CLI streams raw bytes through the browser HTTP client). Tested: auth connections login --skill-mode disabled, --skill-mode enabled, and an invalid value against the real API, plus unit tests for set/omitted/invalid. Co-Authored-By: Claude Opus 5 --- cmd/auth_connections.go | 26 +++++++++++++++++++++++ cmd/auth_connections_test.go | 40 ++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 2d05b2bd..f1c17ed9 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -122,6 +122,7 @@ type AuthConnectionLoginInput struct { Region string Stealth BoolFlag RecordSession BoolFlag + SkillMode string Telemetry string TelemetryCdpExclude string TelemetryExport string @@ -794,6 +795,20 @@ func (c AuthConnectionCmd) Delete(ctx context.Context, in AuthConnectionDeleteIn return nil } +// parseSkillModeFlag validates the --skill-mode value against the modes the API +// accepts for a login, so a typo fails locally instead of starting a flow with +// the wrong skill behavior. +func parseSkillModeFlag(mode string) (kernel.AuthConnectionLoginParamsSkillMode, error) { + switch kernel.AuthConnectionLoginParamsSkillMode(mode) { + case kernel.AuthConnectionLoginParamsSkillModeEnabled: + return kernel.AuthConnectionLoginParamsSkillModeEnabled, nil + case kernel.AuthConnectionLoginParamsSkillModeDisabled: + return kernel.AuthConnectionLoginParamsSkillModeDisabled, nil + default: + return "", fmt.Errorf("invalid --skill-mode value: %s (must be one of enabled, disabled)", mode) + } +} + func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -825,6 +840,14 @@ func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInpu params.RecordSession = kernel.Opt(in.RecordSession.Value) } + if in.SkillMode != "" { + mode, err := parseSkillModeFlag(in.SkillMode) + if err != nil { + return err + } + params.SkillMode = mode + } + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, false) if err != nil { @@ -1384,6 +1407,7 @@ func init() { authConnectionsLoginCmd.Flags().String("region", "", "Geographic region override for this login: 'us-east', 'eu-west', or 'ap-southeast'") authConnectionsLoginCmd.Flags().Bool("stealth", true, "Override stealth mode for this login's browser session; use --stealth=false to disable") authConnectionsLoginCmd.Flags().Bool("record-session", false, "Override whether this login's browser session is recorded; use --record-session=false to disable") + authConnectionsLoginCmd.Flags().String("skill-mode", "", "Whether this login reads and writes learned domain skills: 'enabled' (default) or 'disabled'. Automatic reauths inherit the selected mode until a later accepted login sets enabled or omits the flag") authConnectionsLoginCmd.Flags().String("telemetry", "", "Telemetry override for this login only, merged onto the connection's config: --telemetry=all, --telemetry=off, or --telemetry=console,network") authConnectionsLoginCmd.Flags().String("telemetry-export-otlp", "", "Export override for this login only: an OTLP destination ID or name; --telemetry-export-otlp=off disables export for this login. Naming a destination requires passing --telemetry in the same command, since export and capture are validated together") authConnectionsLoginCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") @@ -1603,6 +1627,7 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { proxyName, _ := cmd.Flags().GetString("proxy-name") proxyMode, _ := cmd.Flags().GetString("proxy-mode") region, _ := cmd.Flags().GetString("region") + skillMode, _ := cmd.Flags().GetString("skill-mode") telemetry, _ := cmd.Flags().GetString("telemetry") telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") @@ -1617,6 +1642,7 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { Region: region, Stealth: readBoolFlag(cmd.Flags(), "stealth"), RecordSession: readBoolFlag(cmd.Flags(), "record-session"), + SkillMode: skillMode, Telemetry: telemetry, TelemetryCdpExclude: telemetryCdpExclude, TelemetryExport: telemetryExport, diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index d79feaac..339a4aa8 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -1395,3 +1395,43 @@ func TestAuthConnectionsGet_TelemetryRowOmittedWhenOff(t *testing.T) { require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "conn-1"})) assert.NotContains(t, outBuf.String(), "Browser Telemetry") } + +func TestLogin_SkillMode(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionLoginParams + fake := &FakeAuthConnectionService{ + LoginFunc: func(ctx context.Context, id string, body kernel.AuthConnectionLoginParams, opts ...option.RequestOption) (*kernel.LoginResponse, error) { + captured = body + return &kernel.LoginResponse{ID: id}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Login(context.Background(), AuthConnectionLoginInput{ID: "auth_1", SkillMode: "disabled"})) + assert.Equal(t, kernel.AuthConnectionLoginParamsSkillModeDisabled, captured.SkillMode) +} + +// Omitting --skill-mode leaves the field unset, so the API keeps its default of +// enabled rather than the CLI pinning a mode the user never asked for. +func TestLogin_SkillModeOmitted(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionLoginParams + fake := &FakeAuthConnectionService{ + LoginFunc: func(ctx context.Context, id string, body kernel.AuthConnectionLoginParams, opts ...option.RequestOption) (*kernel.LoginResponse, error) { + captured = body + return &kernel.LoginResponse{ID: id}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Login(context.Background(), AuthConnectionLoginInput{ID: "auth_1"})) + assert.Empty(t, string(captured.SkillMode)) +} + +func TestLogin_InvalidSkillModeErrors(t *testing.T) { + capturePtermOutput(t) + c := AuthConnectionCmd{svc: &FakeAuthConnectionService{}} + + err := c.Login(context.Background(), AuthConnectionLoginInput{ID: "auth_1", SkillMode: "mars"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --skill-mode value") +} diff --git a/go.mod b/go.mod index ed4ae22a..c0793f27 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.110.1-0.20260921192205-ca8d4655a0dc + github.com/kernel/kernel-go-sdk v0.110.1-0.20260921195148-aee0b02b9b0b github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index d2b73d39..cf08de2e 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.110.1-0.20260921192205-ca8d4655a0dc h1:n90WNvVLRYFPtkmp8rMes2emHBkFd0PZYY5QBVye+ws= -github.com/kernel/kernel-go-sdk v0.110.1-0.20260921192205-ca8d4655a0dc/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.110.1-0.20260921195148-aee0b02b9b0b h1:qb0fJhkc/ms44bzP9AMShSYvqIGdlC0CAMUaOY00y68= +github.com/kernel/kernel-go-sdk v0.110.1-0.20260921195148-aee0b02b9b0b/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From f3bd1bbd0451a2c778a5334d8b146f889a75149c Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:15:46 +0000 Subject: [PATCH 15/18] feat: add kernel search commands, bump Go SDK to 0554699 Updates github.com/kernel/kernel-go-sdk to 05546992051b109e2a59e1b4a7f6f4455b1470db, which adds the Search API v1 providers, and covers all four new SDK methods with CLI commands. New commands: - `kernel search ` -> client.Search.New - `kernel search get ` -> client.Search.Get - `kernel search providers` -> client.Search.Providers.List - `kernel search contents ` -> client.Search.Contents.Fetch `kernel search` exposes every RequestParam field: --country, --language, --max-results, --recency, --safe-search, --start-date, --end-date, --include-domains, --exclude-domains, --strict-params, --include-raw and --timeout-ms, plus --content and the --content-* options for portable retrieval and --show-content for printing extracted text. The strategy union is driven by --provider (pinned), --fallback-providers (fallback) and --fallback-on, with provider-native options supplied as a JSON object keyed by provider slug via --provider-options. Provider targets are built by round-tripping through the SDK's discriminated union so every provider's typed options are reachable without a flag per provider. A full enumeration of api.md against the CLI found no other gaps: the only other uncovered methods are the six client.ConfigRegistry.* methods, which are marked x-cli-skip in openapi.yaml. Tested: search is disabled for the test organization, so the four commands were exercised end-to-end against the real API only as far as the server's search_disabled/404 responses, which surface cleanly. Wire payloads (portable params, the content boolean/object union, and the pinned/fallback/auto strategy variants including native options) are covered by unit tests in cmd/search_test.go, along with flag wiring and client-side validation. Full `go build ./...` and `go test ./...` pass. Co-Authored-By: Claude Opus 5 --- README.md | 45 +++ cmd/root.go | 1 + cmd/search.go | 900 +++++++++++++++++++++++++++++++++++++++++++++ cmd/search_test.go | 414 +++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 6 files changed, 1363 insertions(+), 3 deletions(-) create mode 100644 cmd/search.go create mode 100644 cmd/search_test.go diff --git a/README.md b/README.md index 8044962b..6b42bf42 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Kernel provides sandboxed, ready-to-use Chrome browsers for browser automations - Invoke app actions (sync or async) and stream logs - Create, list, view, and delete managed browser sessions - Get a live view URL for visual monitoring and remote control +- Search the web across providers and retrieve page content for results ## Installation @@ -1169,6 +1170,50 @@ Automated authentication for web services. The `run` command orchestrates the fu - `--default-project-max-concurrent-sessions ` - Default maximum concurrent browsers for projects without an explicit override (`0` to remove the default) - `--output json`, `-o json` - Output raw JSON object +### Search + +- `kernel search ` - Search the web through Kernel's search providers + - `--country ` - ISO 3166-1 alpha-2 search locale preference + - `--language ` - BCP 47 search language preference + - `--max-results ` - Requested result count, 1-100 (clamped to the serving provider's cap) + - `--recency ` - Relative search window: `hour`, `day`, `week`, `month`, or `year` + - `--safe-search ` - Safety preference: `off`, `moderate`, or `strict` + - `--start-date ` / `--end-date ` - Inclusive publication-date bounds (`--recency` takes precedence) + - `--include-domains ` / `--exclude-domains ` - Hostname preferences, matching a hostname and its subdomains + - `--strict-params` - Require every supplied portable parameter to be honored exactly instead of approximated + - `--include-raw` - Include untouched provider payloads in the response's raw fields + - `--timeout-ms ` - Overall deadline across search attempts and inline retrieval + - `--content` - Retrieve page content for each result using portable defaults + - `--show-content` - Print the extracted content text for each result (implies `--content`) + - `--content-source ` - Retrieval source: `auto`, `provider`, or `browser` + - `--content-format ` - Extracted content format: `markdown` or `text` + - `--content-max-chars ` - Per-result Unicode character limit after extraction + - `--content-max-age-hours ` - Maximum acceptable age of cached page content; `0` forces a live fetch + - `--content-timeout-ms ` - Per-result retrieval deadline + - `--content-browser-id ` - Retrieve through an existing browser session (requires `--content-source browser`) + - `--content-browser-mode ` - Browser retrieval mode: `curl` or `render` + - `--provider ` - Pin a single provider (`brave`, `exa`, `perplexity`, `context`, `parallel`, `valyu`, `octen`, `you`, `tavily`, `serpapi`) + - `--fallback-providers ` - Ordered provider chain to try in turn + - `--fallback-on ` - Outcomes that advance to the next provider: `error`, `timeout`, `empty` + - `--provider-options ` - Provider-native options as a JSON object keyed by provider slug + - `--output json`, `-o json` - Output raw JSON object +- `kernel search get ` - Re-read a retained search without calling a provider or incurring cost + - `--show-content` - Print the extracted content text for each result + - `--output json`, `-o json` - Output raw JSON object +- `kernel search providers` - List providers, result caps, and content capabilities + - `--slug ` - Filter to a single provider; also prints its portable-parameter support matrix and notes + - `--output json`, `-o json` - Output raw JSON array +- `kernel search contents ` - Deferred content retrieval for a retained search + - `--result-ids ` - Result IDs from the retained search, in the desired response order + - `--limit ` - Number of results to fetch starting from rank 1 (mutually exclusive with `--result-ids`) + - `--timeout-ms ` - Overall deadline across all selected results + - Accepts the same `--content-*` flags as `kernel search` + - This endpoint is reserved and returns 404 until deferred retrieval ships; use `kernel search --content` for inline retrieval + +Searches are retained for 24 hours. Omitting the strategy flags lets Kernel pick an +eligible provider; portable filters a provider cannot honor are approximated or +dropped and reported as warnings unless `--strict-params` is set. + ## Examples ### Create a new app diff --git a/cmd/root.go b/cmd/root.go index 5b34a49c..90518223 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -169,6 +169,7 @@ func init() { rootCmd.AddCommand(proxies.ProxiesCmd) rootCmd.AddCommand(extensionsCmd) rootCmd.AddCommand(credentialsCmd) + rootCmd.AddCommand(searchCmd) rootCmd.AddCommand(credentialProvidersCmd) rootCmd.AddCommand(createCmd) rootCmd.AddCommand(mcp.MCPCmd) diff --git a/cmd/search.go b/cmd/search.go new file mode 100644 index 00000000..51a9b90b --- /dev/null +++ b/cmd/search.go @@ -0,0 +1,900 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/kernel/cli/pkg/util" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/param" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// SearchService defines the subset of the Kernel SDK search client that we use. +type SearchService interface { + New(ctx context.Context, body kernel.SearchNewParams, opts ...option.RequestOption) (res *kernel.Search, err error) + Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.Search, err error) +} + +// SearchProvidersService defines the subset of the Kernel SDK search provider +// client that we use. +type SearchProvidersService interface { + List(ctx context.Context, query kernel.SearchProviderListParams, opts ...option.RequestOption) (res *[]kernel.Provider, err error) +} + +// SearchContentsService defines the subset of the Kernel SDK search contents +// client that we use. +type SearchContentsService interface { + Fetch(ctx context.Context, id string, body kernel.SearchContentFetchParams, opts ...option.RequestOption) (err error) +} + +// searchProviderSlugs are the concrete providers accepted by --provider, +// --fallback-providers, and the providers --slug filter. Auto and fallback are +// strategies rather than provider entries, so they are not listed here. +var searchProviderSlugs = []string{ + string(kernel.SearchProviderListParamsSlugBrave), + string(kernel.SearchProviderListParamsSlugExa), + string(kernel.SearchProviderListParamsSlugPerplexity), + string(kernel.SearchProviderListParamsSlugContext), + string(kernel.SearchProviderListParamsSlugParallel), + string(kernel.SearchProviderListParamsSlugValyu), + string(kernel.SearchProviderListParamsSlugOcten), + string(kernel.SearchProviderListParamsSlugYou), + string(kernel.SearchProviderListParamsSlugTavily), + string(kernel.SearchProviderListParamsSlugSerpapi), +} + +var ( + searchRecencyValues = []string{"hour", "day", "week", "month", "year"} + searchSafeSearchValues = []string{"off", "moderate", "strict"} + searchContentSources = []string{"auto", "provider", "browser"} + searchContentFormats = []string{"markdown", "text"} + searchBrowserModes = []string{"curl", "render"} + searchFallbackOnValues = []string{"error", "timeout", "empty"} + searchDateLayout = "2006-01-02" + searchProviderOptsUsage = `Provider-native options as a JSON object keyed by provider slug, e.g. '{"tavily":{"include_answer":true}}'` +) + +// searchContentInput holds the portable content-retrieval options shared by +// `kernel search` and `kernel search contents`. +type searchContentInput struct { + Enabled bool + Source string + Format string + MaxChars int64 + MaxAgeHours *int64 + TimeoutMs int64 + BrowserID string + BrowserMode string +} + +// requested reports whether the user asked for content retrieval at all. +func (c searchContentInput) requested() bool { + return c.Enabled || c.customized() +} + +// customized reports whether any option beyond the bare --content toggle was set, +// which means the options object must be sent rather than the boolean shorthand. +func (c searchContentInput) customized() bool { + return c.Source != "" || c.Format != "" || c.MaxChars > 0 || c.MaxAgeHours != nil || + c.TimeoutMs > 0 || c.BrowserID != "" || c.BrowserMode != "" +} + +func (c searchContentInput) validate() error { + if err := validateSearchEnum("--content-source", c.Source, searchContentSources); err != nil { + return err + } + if err := validateSearchEnum("--content-format", c.Format, searchContentFormats); err != nil { + return err + } + if err := validateSearchEnum("--content-browser-mode", c.BrowserMode, searchBrowserModes); err != nil { + return err + } + if c.BrowserID != "" && c.Source == "provider" { + return fmt.Errorf("--content-browser-id requires --content-source browser") + } + return nil +} + +type SearchQueryInput struct { + Query string + Output string + Country string + Language string + MaxResults int64 + Recency string + SafeSearch string + StartDate string + EndDate string + IncludeDomains []string + ExcludeDomains []string + StrictParams bool + IncludeRaw bool + TimeoutMs int64 + ShowContent bool + + Content searchContentInput + + Provider string + FallbackProviders []string + FallbackOn []string + ProviderOptions string +} + +type SearchGetInput struct { + ID string + Output string + ShowContent bool +} + +type SearchProvidersInput struct { + Slug string + Output string +} + +type SearchContentsInput struct { + ID string + Output string + ResultIDs []string + Limit int64 + TimeoutMs int64 + Content searchContentInput +} + +// SearchCmd handles search operations independent of cobra. +type SearchCmd struct { + search SearchService + providers SearchProvidersService + contents SearchContentsService +} + +func (s SearchCmd) Query(ctx context.Context, in SearchQueryInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if strings.TrimSpace(in.Query) == "" { + return fmt.Errorf("a search query is required") + } + if err := validateSearchEnum("--recency", in.Recency, searchRecencyValues); err != nil { + return err + } + if err := validateSearchEnum("--safe-search", in.SafeSearch, searchSafeSearchValues); err != nil { + return err + } + if in.MaxResults < 0 || in.MaxResults > 100 { + return fmt.Errorf("--max-results must be between 1 and 100") + } + if err := in.Content.validate(); err != nil { + return err + } + + req := kernel.RequestParam{Query: in.Query} + if in.Country != "" { + req.Country = kernel.Opt(in.Country) + } + if in.Language != "" { + req.Language = kernel.Opt(in.Language) + } + if in.MaxResults > 0 { + req.MaxResults = kernel.Opt(in.MaxResults) + } + if in.TimeoutMs > 0 { + req.TimeoutMs = kernel.Opt(in.TimeoutMs) + } + if in.StrictParams { + req.StrictParams = kernel.Opt(true) + } + if in.IncludeRaw { + req.IncludeRaw = kernel.Opt(true) + } + if in.Recency != "" { + req.Recency = kernel.RequestRecency(in.Recency) + } + if in.SafeSearch != "" { + req.SafeSearch = kernel.RequestSafeSearch(in.SafeSearch) + } + if in.StartDate != "" { + t, err := parseSearchDate("--start-date", in.StartDate) + if err != nil { + return err + } + req.StartDate = kernel.Opt(t) + } + if in.EndDate != "" { + t, err := parseSearchDate("--end-date", in.EndDate) + if err != nil { + return err + } + req.EndDate = kernel.Opt(t) + } + if len(in.IncludeDomains) > 0 { + req.IncludeDomains = in.IncludeDomains + } + if len(in.ExcludeDomains) > 0 { + req.ExcludeDomains = in.ExcludeDomains + } + + if in.Content.requested() { + if in.Content.customized() { + opts := kernel.RequestContentSearchContentOptionsParam{} + applySearchContentOptions(in.Content, &opts.Source, &opts.Format, &opts.MaxChars, &opts.MaxAgeHours, &opts.TimeoutMs) + if in.Content.BrowserID != "" { + opts.Browser.BrowserID = kernel.Opt(in.Content.BrowserID) + } + if in.Content.BrowserMode != "" { + opts.Browser.Mode = in.Content.BrowserMode + } + req.Content = kernel.RequestContentUnionParam{OfRequestContentSearchContentOptions: &opts} + } else { + req.Content = kernel.RequestContentUnionParam{OfRequestContentBoolean: kernel.Opt(true)} + } + } + + strategy, err := buildSearchStrategy(in.Provider, in.FallbackProviders, in.FallbackOn, in.ProviderOptions) + if err != nil { + return err + } + if strategy != nil { + req.Strategy = *strategy + } + + if in.Output != "json" { + pterm.Info.Printf("Searching for %q...\n", in.Query) + } + + result, err := s.search.New(ctx, kernel.SearchNewParams{Request: req}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + return renderSearch(result, in.Output, in.ShowContent) +} + +func (s SearchCmd) Get(ctx context.Context, in SearchGetInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + result, err := s.search.Get(ctx, in.ID) + if err != nil { + if util.IsNotFound(err) { + if in.Output == "json" { + fmt.Println("null") + return nil + } + pterm.Error.Printf("Search '%s' not found or expired\n", in.ID) + return nil + } + return util.CleanedUpSdkError{Err: err} + } + + return renderSearch(result, in.Output, in.ShowContent) +} + +func (s SearchCmd) Providers(ctx context.Context, in SearchProvidersInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if err := validateSearchEnum("--slug", in.Slug, searchProviderSlugs); err != nil { + return err + } + + params := kernel.SearchProviderListParams{} + if in.Slug != "" { + params.Slug = kernel.SearchProviderListParamsSlug(in.Slug) + } + + res, err := s.providers.List(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + var items []kernel.Provider + if res != nil { + items = *res + } + + if in.Output == "json" { + if len(items) == 0 { + fmt.Println("[]") + return nil + } + return util.PrintPrettyJSONSlice(items) + } + + if len(items) == 0 { + pterm.Info.Println("No search providers found") + return nil + } + + rows := pterm.TableData{{"Slug", "Max Results", "Inline Content", "Post-hoc Content", "Freshness Control", "Options Schema"}} + for _, p := range items { + rows = append(rows, []string{ + p.Slug, + strconv.FormatInt(p.MaxResultsCap, 10), + formatSearchBool(p.Content.Inline), + formatSearchBool(p.Content.PostHoc), + formatSearchBool(p.Content.FreshnessControl), + orSearchDash(p.ProviderOptions.SchemaRef), + }) + } + PrintTableNoPad(rows, true) + + // The portable-parameter support matrix and notes only fit in a readable way + // when a single provider was requested. + if len(items) == 1 { + p := items[0] + paramRows := pterm.TableData{{"Portable Param", "Support", "Notes"}} + for _, pp := range []struct { + name string + support string + notes string + }{ + {"country", p.Params.Country.Support, p.Params.Country.Notes}, + {"end_date", p.Params.EndDate.Support, p.Params.EndDate.Notes}, + {"exclude_domains", p.Params.ExcludeDomains.Support, p.Params.ExcludeDomains.Notes}, + {"include_domains", p.Params.IncludeDomains.Support, p.Params.IncludeDomains.Notes}, + {"language", p.Params.Language.Support, p.Params.Language.Notes}, + {"recency", p.Params.Recency.Support, p.Params.Recency.Notes}, + {"safe_search", p.Params.SafeSearch.Support, p.Params.SafeSearch.Notes}, + {"start_date", p.Params.StartDate.Support, p.Params.StartDate.Notes}, + } { + paramRows = append(paramRows, []string{pp.name, orSearchDash(pp.support), orSearchDash(pp.notes)}) + } + pterm.Println() + PrintTableNoPad(paramRows, true) + + if len(p.Notes) > 0 { + pterm.Println() + pterm.Println("Notes:") + for _, n := range p.Notes { + pterm.Printf(" - %s\n", n) + } + } + } + + return nil +} + +func (s SearchCmd) Contents(ctx context.Context, in SearchContentsInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if len(in.ResultIDs) > 0 && in.Limit > 0 { + return fmt.Errorf("--result-ids and --limit are mutually exclusive") + } + if err := in.Content.validate(); err != nil { + return err + } + + req := kernel.FetchRequestParam{} + if in.Limit > 0 { + req.Limit = kernel.Opt(in.Limit) + } + if in.TimeoutMs > 0 { + req.TimeoutMs = kernel.Opt(in.TimeoutMs) + } + if len(in.ResultIDs) > 0 { + req.ResultIDs = in.ResultIDs + } + if in.Content.customized() { + applySearchContentOptions(in.Content, &req.Content.Source, &req.Content.Format, &req.Content.MaxChars, &req.Content.MaxAgeHours, &req.Content.TimeoutMs) + if in.Content.BrowserID != "" { + req.Content.Browser.BrowserID = kernel.Opt(in.Content.BrowserID) + } + if in.Content.BrowserMode != "" { + req.Content.Browser.Mode = in.Content.BrowserMode + } + } + + if err := s.contents.Fetch(ctx, in.ID, kernel.SearchContentFetchParams{FetchRequest: req}); err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + fmt.Println("null") + return nil + } + pterm.Success.Printf("Requested content for search %s\n", in.ID) + return nil +} + +// applySearchContentOptions copies the shared content options onto the +// destination fields of either content options struct. The two SDK structs are +// identical in shape but distinct types, so the fields are passed by pointer. +func applySearchContentOptions(in searchContentInput, source, format *string, maxChars, maxAgeHours, timeoutMs *param.Opt[int64]) { + if in.Source != "" { + *source = in.Source + } + if in.Format != "" { + *format = in.Format + } + if in.MaxChars > 0 { + *maxChars = kernel.Opt(in.MaxChars) + } + // 0 is meaningful here (it forces a live fetch), so only the pointer tells us + // whether the flag was supplied. + if in.MaxAgeHours != nil { + *maxAgeHours = kernel.Opt(*in.MaxAgeHours) + } + if in.TimeoutMs > 0 { + *timeoutMs = kernel.Opt(in.TimeoutMs) + } +} + +// buildSearchStrategy maps the strategy flags onto one of the three SDK strategy +// variants. Returns nil when no strategy flag was supplied, which lets the API +// apply its auto default. +func buildSearchStrategy(provider string, fallbackProviders, fallbackOn []string, providerOptions string) (*kernel.StrategyUnionParam, error) { + if provider != "" && len(fallbackProviders) > 0 { + return nil, fmt.Errorf("--provider and --fallback-providers are mutually exclusive") + } + if err := validateSearchEnum("--provider", provider, searchProviderSlugs); err != nil { + return nil, err + } + for _, p := range fallbackProviders { + if err := validateSearchEnum("--fallback-providers", p, searchProviderSlugs); err != nil { + return nil, err + } + } + for _, f := range fallbackOn { + if err := validateSearchEnum("--fallback-on", f, searchFallbackOnValues); err != nil { + return nil, err + } + } + + nativeOptions, err := parseSearchProviderOptions(providerOptions) + if err != nil { + return nil, err + } + + switch { + case provider != "": + if len(fallbackOn) > 0 { + return nil, fmt.Errorf("--fallback-on has no effect with --provider, which pins a single provider") + } + target, err := buildSearchProviderTarget(provider, nativeOptions[provider]) + if err != nil { + return nil, err + } + pinned := kernel.StrategyPinnedParam{Provider: target} + return &kernel.StrategyUnionParam{OfPinned: &pinned}, nil + + case len(fallbackProviders) > 0: + targets := make([]kernel.ProviderTargetUnionParam, 0, len(fallbackProviders)) + for _, p := range fallbackProviders { + target, err := buildSearchProviderTarget(p, nativeOptions[p]) + if err != nil { + return nil, err + } + targets = append(targets, target) + } + fallback := kernel.StrategyFallbackParam{Providers: targets} + if len(fallbackOn) > 0 { + fallback.FallbackOn = fallbackOn + } + return &kernel.StrategyUnionParam{OfFallback: &fallback}, nil + + case len(nativeOptions) > 0 || len(fallbackOn) > 0: + auto := kernel.StrategyAutoParam{} + if len(fallbackOn) > 0 { + auto.FallbackOn = fallbackOn + } + // Map iteration order is random, so the targets are emitted in the + // documented slug order to keep requests reproducible. + for _, slug := range searchProviderSlugs { + raw, ok := nativeOptions[slug] + if !ok { + continue + } + target, err := buildSearchProviderTarget(slug, raw) + if err != nil { + return nil, err + } + auto.ProviderOptions = append(auto.ProviderOptions, target) + } + return &kernel.StrategyUnionParam{OfAuto: &auto}, nil + } + + return nil, nil +} + +// parseSearchProviderOptions decodes the --provider-options JSON object, which +// maps a provider slug to that provider's native options object. +func parseSearchProviderOptions(raw string) (map[string]json.RawMessage, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var byProvider map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &byProvider); err != nil { + return nil, fmt.Errorf("invalid --provider-options: must be a JSON object keyed by provider slug: %w", err) + } + for slug := range byProvider { + if err := validateSearchEnum("--provider-options key", slug, searchProviderSlugs); err != nil { + return nil, err + } + } + return byProvider, nil +} + +// buildSearchProviderTarget assembles a provider target from a slug and its raw +// native options. The SDK union is discriminated on "provider", so the target is +// round-tripped through JSON rather than switched on by hand. +func buildSearchProviderTarget(slug string, options json.RawMessage) (kernel.ProviderTargetUnionParam, error) { + var target kernel.ProviderTargetUnionParam + + payload := map[string]any{"provider": slug} + if len(options) > 0 { + payload["options"] = options + } + encoded, err := json.Marshal(payload) + if err != nil { + return target, fmt.Errorf("encode provider target for %q: %w", slug, err) + } + if err := target.UnmarshalJSON(encoded); err != nil { + return target, fmt.Errorf("invalid provider options for %q: %w", slug, err) + } + return target, nil +} + +func renderSearch(result *kernel.Search, output string, showContent bool) error { + if result == nil || result.ID == "" { + if output == "json" { + fmt.Println("null") + return nil + } + pterm.Info.Println("No search returned") + return nil + } + + if output == "json" { + return util.PrintPrettyJSON(result) + } + + rows := pterm.TableData{{"Property", "Value"}} + rows = append(rows, []string{"Search ID", result.ID}) + rows = append(rows, []string{"Query", result.Query}) + rows = append(rows, []string{"Provider", result.Provider}) + rows = append(rows, []string{"Results", strconv.Itoa(len(result.Results))}) + rows = append(rows, []string{"Expires At", util.FormatLocal(result.ExpiresAt)}) + PrintTableNoPad(rows, true) + + if result.Answer != "" { + pterm.Println() + pterm.Println("Answer:") + pterm.Println(result.Answer) + } + + if len(result.Results) > 0 { + hasContent := false + for _, r := range result.Results { + if r.Content.Status != "" { + hasContent = true + break + } + } + + header := []string{"#", "Title", "URL", "Source", "Published"} + if hasContent { + header = append(header, "Content") + } + resultRows := pterm.TableData{header} + for _, r := range result.Results { + row := []string{ + strconv.FormatInt(r.Rank, 10), + orSearchDash(r.Title), + r.URL, + orSearchDash(r.Source), + orSearchDash(r.PublishedDate), + } + if hasContent { + row = append(row, orSearchDash(r.Content.Status)) + } + resultRows = append(resultRows, row) + } + pterm.Println() + PrintTableNoPad(resultRows, true) + } else { + pterm.Println() + pterm.Info.Println("No results") + } + + if showContent { + for _, r := range result.Results { + if r.Content.Text == "" { + continue + } + pterm.Println() + pterm.Printf("--- [%d] %s (%s) ---\n", r.Rank, orSearchDash(r.Title), r.URL) + pterm.Println(r.Content.Text) + } + } + + if len(result.Warnings) > 0 { + warnRows := pterm.TableData{{"Warning", "Param", "Provider", "Message"}} + for _, w := range result.Warnings { + warnRows = append(warnRows, []string{w.Code, orSearchDash(w.Param), orSearchDash(w.Provider), w.Message}) + } + pterm.Println() + PrintTableNoPad(warnRows, true) + } + + if len(result.Attempts) > 0 { + attemptRows := pterm.TableData{{"Attempt Provider", "Outcome", "Duration", "Error", "Retryable"}} + for _, a := range result.Attempts { + attemptRows = append(attemptRows, []string{ + a.Provider, + string(a.Outcome), + fmt.Sprintf("%dms", a.DurationMs), + orSearchDash(a.ErrorCode), + formatSearchBool(a.Retryable), + }) + } + pterm.Println() + PrintTableNoPad(attemptRows, true) + } + + usage := fmt.Sprintf("\nUsage: %d result(s), %d content fetch(es)", result.Usage.ResultsCount, result.Usage.ContentFetches) + if result.Usage.Cost > 0 { + usage += fmt.Sprintf(", $%.6f", result.Usage.Cost) + } + pterm.Println(usage) + + return nil +} + +func parseSearchDate(flag, value string) (time.Time, error) { + t, err := time.Parse(searchDateLayout, value) + if err != nil { + return time.Time{}, fmt.Errorf("invalid %s %q: expected YYYY-MM-DD", flag, value) + } + return t, nil +} + +func validateSearchEnum(flag, value string, allowed []string) error { + if value == "" { + return nil + } + for _, a := range allowed { + if value == a { + return nil + } + } + return fmt.Errorf("invalid %s %q: must be one of %s", flag, value, strings.Join(allowed, ", ")) +} + +func orSearchDash(s string) string { + if strings.TrimSpace(s) == "" { + return "-" + } + return s +} + +func formatSearchBool(b bool) string { + if b { + return "yes" + } + return "no" +} + +// --- Cobra wiring --- + +var searchCmd = &cobra.Command{ + Use: "search ", + Short: "Search the web", + Long: "Search the web through Kernel's search providers.\n\n" + + "By default Kernel picks an eligible provider (the auto strategy). Use --provider to pin\n" + + "one provider, or --fallback-providers to try an ordered chain. Portable filters that a\n" + + "provider cannot honor are approximated or dropped, and the outcome is reported as a\n" + + "warning unless --strict-params is set.\n\n" + + "Results are retained for 24 hours and can be re-read with `kernel search get `.", + Example: ` kernel search "kernel browser automation" + kernel search "latest go release" --recency week --max-results 5 + kernel search "site news" --include-domains example.com --content --show-content + kernel search "ai research" --provider exa --provider-options '{"exa":{"type":"neural"}}'`, + Args: cobra.ArbitraryArgs, + RunE: runSearchQuery, +} + +var searchGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a retained search by ID", + Long: "Return a retained search exactly as it was returned by `kernel search`: results, attempts,\n" + + "warnings, and usage. No provider is called and nothing is billed. Searches expire 24 hours\n" + + "after completion.", + Args: cobra.ExactArgs(1), + RunE: runSearchGet, +} + +var searchProvidersCmd = &cobra.Command{ + Use: "providers", + Short: "List search providers and their capabilities", + Long: "List providers, their result caps, content-retrieval capabilities, and the OpenAPI\n" + + "component backing their native options. Pass --slug to inspect a single provider, which\n" + + "also prints its portable-parameter support matrix and notes.", + Args: cobra.NoArgs, + RunE: runSearchProviders, +} + +var searchContentsCmd = &cobra.Command{ + Use: "contents ", + Short: "Fetch content for results of a retained search", + Long: "Deferred result-content retrieval for a retained search. This endpoint is reserved and\n" + + "returns 404 until the retrieval implementation ships; use `kernel search --content` for\n" + + "inline retrieval in the meantime.", + Args: cobra.ExactArgs(1), + RunE: runSearchContents, +} + +// addSearchContentFlags registers the portable content-retrieval options shared +// by `kernel search` and `kernel search contents`. +func addSearchContentFlags(cmd *cobra.Command) { + cmd.Flags().String("content-source", "", "Content retrieval source: auto, provider, or browser") + cmd.Flags().String("content-format", "", "Extracted content format: markdown or text") + cmd.Flags().Int64("content-max-chars", 0, "Per-result Unicode character limit after extraction") + cmd.Flags().Int64("content-max-age-hours", 0, "Maximum acceptable age of cached page content; 0 forces a live fetch") + cmd.Flags().Int64("content-timeout-ms", 0, "Per-result retrieval deadline in milliseconds") + cmd.Flags().String("content-browser-id", "", "Existing browser session to retrieve content through (requires --content-source browser)") + cmd.Flags().String("content-browser-mode", "", "Browser retrieval mode: curl or render") +} + +func searchContentFlags(cmd *cobra.Command, enabled bool) searchContentInput { + source, _ := cmd.Flags().GetString("content-source") + format, _ := cmd.Flags().GetString("content-format") + maxChars, _ := cmd.Flags().GetInt64("content-max-chars") + timeoutMs, _ := cmd.Flags().GetInt64("content-timeout-ms") + browserID, _ := cmd.Flags().GetString("content-browser-id") + browserMode, _ := cmd.Flags().GetString("content-browser-mode") + + in := searchContentInput{ + Enabled: enabled, + Source: source, + Format: format, + MaxChars: maxChars, + TimeoutMs: timeoutMs, + BrowserID: browserID, + BrowserMode: browserMode, + } + // 0 is a meaningful max age, so it is only sent when explicitly supplied. + if cmd.Flags().Changed("content-max-age-hours") { + maxAge, _ := cmd.Flags().GetInt64("content-max-age-hours") + in.MaxAgeHours = &maxAge + } + return in +} + +func init() { + searchCmd.AddCommand(searchGetCmd) + searchCmd.AddCommand(searchProvidersCmd) + searchCmd.AddCommand(searchContentsCmd) + + addJSONOutputFlag(searchCmd) + searchCmd.Flags().String("country", "", "ISO 3166-1 alpha-2 search locale preference") + searchCmd.Flags().String("language", "", "BCP 47 search language preference") + searchCmd.Flags().Int64("max-results", 0, "Requested result count, 1 through 100 (clamped to the provider cap)") + searchCmd.Flags().String("recency", "", "Relative search window: hour, day, week, month, or year") + searchCmd.Flags().String("safe-search", "", "Safety preference: off, moderate, or strict") + searchCmd.Flags().String("start-date", "", "Inclusive publication-date lower bound (YYYY-MM-DD)") + searchCmd.Flags().String("end-date", "", "Inclusive publication-date upper bound (YYYY-MM-DD)") + searchCmd.Flags().StringSlice("include-domains", nil, "Hostnames (and subdomains) to prefer") + searchCmd.Flags().StringSlice("exclude-domains", nil, "Hostnames (and subdomains) to exclude") + searchCmd.Flags().Bool("strict-params", false, "Require every supplied portable parameter to be honored exactly") + searchCmd.Flags().Bool("include-raw", false, "Include untouched provider payloads in raw fields") + searchCmd.Flags().Int64("timeout-ms", 0, "Overall deadline across search attempts and inline retrieval") + searchCmd.Flags().Bool("content", false, "Retrieve page content for each result using portable defaults") + searchCmd.Flags().Bool("show-content", false, "Print the extracted content text for each result") + addSearchContentFlags(searchCmd) + searchCmd.Flags().String("provider", "", "Pin a single provider: "+strings.Join(searchProviderSlugs, ", ")) + searchCmd.Flags().StringSlice("fallback-providers", nil, "Ordered provider chain to try in turn") + searchCmd.Flags().StringSlice("fallback-on", nil, "Outcomes that advance to the next provider: error, timeout, empty") + searchCmd.Flags().String("provider-options", "", searchProviderOptsUsage) + + addJSONOutputFlag(searchGetCmd) + searchGetCmd.Flags().Bool("show-content", false, "Print the extracted content text for each result") + + addJSONOutputFlag(searchProvidersCmd) + searchProvidersCmd.Flags().String("slug", "", "Filter to a single provider: "+strings.Join(searchProviderSlugs, ", ")) + + addJSONOutputFlag(searchContentsCmd) + searchContentsCmd.Flags().StringSlice("result-ids", nil, "Result IDs from the retained search, in the desired response order") + searchContentsCmd.Flags().Int64("limit", 0, "Number of results to fetch starting from rank 1 (mutually exclusive with --result-ids)") + searchContentsCmd.Flags().Int64("timeout-ms", 0, "Overall deadline across all selected results") + addSearchContentFlags(searchContentsCmd) +} + +func newSearchCmd(cmd *cobra.Command) SearchCmd { + client := getKernelClient(cmd) + svc := client.Search + return SearchCmd{search: &svc, providers: &svc.Providers, contents: &svc.Contents} +} + +func runSearchQuery(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + + output, _ := cmd.Flags().GetString("output") + country, _ := cmd.Flags().GetString("country") + language, _ := cmd.Flags().GetString("language") + maxResults, _ := cmd.Flags().GetInt64("max-results") + recency, _ := cmd.Flags().GetString("recency") + safeSearch, _ := cmd.Flags().GetString("safe-search") + startDate, _ := cmd.Flags().GetString("start-date") + endDate, _ := cmd.Flags().GetString("end-date") + includeDomains, _ := cmd.Flags().GetStringSlice("include-domains") + excludeDomains, _ := cmd.Flags().GetStringSlice("exclude-domains") + strictParams, _ := cmd.Flags().GetBool("strict-params") + includeRaw, _ := cmd.Flags().GetBool("include-raw") + timeoutMs, _ := cmd.Flags().GetInt64("timeout-ms") + content, _ := cmd.Flags().GetBool("content") + showContent, _ := cmd.Flags().GetBool("show-content") + provider, _ := cmd.Flags().GetString("provider") + fallbackProviders, _ := cmd.Flags().GetStringSlice("fallback-providers") + fallbackOn, _ := cmd.Flags().GetStringSlice("fallback-on") + providerOptions, _ := cmd.Flags().GetString("provider-options") + + // --show-content is only useful alongside retrieval, so it implies --content. + contentIn := searchContentFlags(cmd, content || showContent) + + return newSearchCmd(cmd).Query(cmd.Context(), SearchQueryInput{ + Query: strings.Join(args, " "), + Output: output, + Country: country, + Language: language, + MaxResults: maxResults, + Recency: recency, + SafeSearch: safeSearch, + StartDate: startDate, + EndDate: endDate, + IncludeDomains: includeDomains, + ExcludeDomains: excludeDomains, + StrictParams: strictParams, + IncludeRaw: includeRaw, + TimeoutMs: timeoutMs, + ShowContent: showContent, + Content: contentIn, + Provider: provider, + FallbackProviders: fallbackProviders, + FallbackOn: fallbackOn, + ProviderOptions: providerOptions, + }) +} + +func runSearchGet(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + showContent, _ := cmd.Flags().GetBool("show-content") + return newSearchCmd(cmd).Get(cmd.Context(), SearchGetInput{ + ID: args[0], + Output: output, + ShowContent: showContent, + }) +} + +func runSearchProviders(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + slug, _ := cmd.Flags().GetString("slug") + return newSearchCmd(cmd).Providers(cmd.Context(), SearchProvidersInput{Slug: slug, Output: output}) +} + +func runSearchContents(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + resultIDs, _ := cmd.Flags().GetStringSlice("result-ids") + limit, _ := cmd.Flags().GetInt64("limit") + timeoutMs, _ := cmd.Flags().GetInt64("timeout-ms") + + return newSearchCmd(cmd).Contents(cmd.Context(), SearchContentsInput{ + ID: args[0], + Output: output, + ResultIDs: resultIDs, + Limit: limit, + TimeoutMs: timeoutMs, + Content: searchContentFlags(cmd, true), + }) +} diff --git a/cmd/search_test.go b/cmd/search_test.go new file mode 100644 index 00000000..fc484948 --- /dev/null +++ b/cmd/search_test.go @@ -0,0 +1,414 @@ +package cmd + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// FakeSearchService implements SearchService. +type FakeSearchService struct { + NewFunc func(ctx context.Context, body kernel.SearchNewParams, opts ...option.RequestOption) (*kernel.Search, error) + GetFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.Search, error) + LastBody kernel.SearchNewParams +} + +func (f *FakeSearchService) New(ctx context.Context, body kernel.SearchNewParams, opts ...option.RequestOption) (*kernel.Search, error) { + f.LastBody = body + if f.NewFunc != nil { + return f.NewFunc(ctx, body, opts...) + } + return &kernel.Search{ID: "srch_1", Query: body.Request.Query, Provider: "brave", ExpiresAt: time.Unix(0, 0)}, nil +} + +func (f *FakeSearchService) Get(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.Search, error) { + if f.GetFunc != nil { + return f.GetFunc(ctx, id, opts...) + } + return &kernel.Search{ID: id, Query: "cached", Provider: "brave", ExpiresAt: time.Unix(0, 0)}, nil +} + +// FakeSearchProvidersService implements SearchProvidersService. +type FakeSearchProvidersService struct { + ListFunc func(ctx context.Context, query kernel.SearchProviderListParams, opts ...option.RequestOption) (*[]kernel.Provider, error) + LastQuery kernel.SearchProviderListParams +} + +func (f *FakeSearchProvidersService) List(ctx context.Context, query kernel.SearchProviderListParams, opts ...option.RequestOption) (*[]kernel.Provider, error) { + f.LastQuery = query + if f.ListFunc != nil { + return f.ListFunc(ctx, query, opts...) + } + items := []kernel.Provider{{Slug: "brave", MaxResultsCap: 20}} + return &items, nil +} + +// FakeSearchContentsService implements SearchContentsService. +type FakeSearchContentsService struct { + FetchFunc func(ctx context.Context, id string, body kernel.SearchContentFetchParams, opts ...option.RequestOption) error + LastID string + LastBody kernel.SearchContentFetchParams +} + +func (f *FakeSearchContentsService) Fetch(ctx context.Context, id string, body kernel.SearchContentFetchParams, opts ...option.RequestOption) error { + f.LastID = id + f.LastBody = body + if f.FetchFunc != nil { + return f.FetchFunc(ctx, id, body, opts...) + } + return nil +} + +// requestJSON marshals the captured request so tests can assert on the exact +// wire payload, which is where the union and strategy encoding actually matters. +func requestJSON(t *testing.T, params kernel.SearchNewParams) map[string]any { + t.Helper() + raw, err := json.Marshal(params) + require.NoError(t, err) + var out map[string]any + require.NoError(t, json.Unmarshal(raw, &out)) + return out +} + +func TestSearchQueryBuildsPortableParams(t *testing.T) { + _ = capturePtermOutput(t) + fake := &FakeSearchService{} + s := SearchCmd{search: fake} + + err := s.Query(context.Background(), SearchQueryInput{ + Query: "kernel browsers", + Country: "US", + Language: "en", + MaxResults: 5, + Recency: "week", + SafeSearch: "moderate", + StartDate: "2026-01-02", + EndDate: "2026-02-03", + IncludeDomains: []string{"example.com"}, + ExcludeDomains: []string{"spam.example"}, + StrictParams: true, + IncludeRaw: true, + TimeoutMs: 15000, + }) + require.NoError(t, err) + + body := requestJSON(t, fake.LastBody) + assert.Equal(t, "kernel browsers", body["query"]) + assert.Equal(t, "US", body["country"]) + assert.Equal(t, "en", body["language"]) + assert.EqualValues(t, 5, body["max_results"]) + assert.Equal(t, "week", body["recency"]) + assert.Equal(t, "moderate", body["safe_search"]) + assert.Equal(t, "2026-01-02", body["start_date"]) + assert.Equal(t, "2026-02-03", body["end_date"]) + assert.Equal(t, []any{"example.com"}, body["include_domains"]) + assert.Equal(t, []any{"spam.example"}, body["exclude_domains"]) + assert.Equal(t, true, body["strict_params"]) + assert.Equal(t, true, body["include_raw"]) + assert.EqualValues(t, 15000, body["timeout_ms"]) + // No strategy flags were supplied, so the API applies its auto default. + assert.NotContains(t, body, "strategy") +} + +func TestSearchQueryContentBooleanShorthand(t *testing.T) { + _ = capturePtermOutput(t) + fake := &FakeSearchService{} + s := SearchCmd{search: fake} + + require.NoError(t, s.Query(context.Background(), SearchQueryInput{ + Query: "q", + Content: searchContentInput{Enabled: true}, + })) + + body := requestJSON(t, fake.LastBody) + assert.Equal(t, true, body["content"]) +} + +func TestSearchQueryContentOptionsObject(t *testing.T) { + _ = capturePtermOutput(t) + fake := &FakeSearchService{} + s := SearchCmd{search: fake} + + maxAge := int64(0) + require.NoError(t, s.Query(context.Background(), SearchQueryInput{ + Query: "q", + Content: searchContentInput{ + Enabled: true, + Source: "browser", + Format: "text", + MaxChars: 2000, + MaxAgeHours: &maxAge, + TimeoutMs: 9000, + BrowserID: "br_123", + BrowserMode: "render", + }, + })) + + body := requestJSON(t, fake.LastBody) + content, ok := body["content"].(map[string]any) + require.True(t, ok, "content should be an options object, got %#v", body["content"]) + assert.Equal(t, "browser", content["source"]) + assert.Equal(t, "text", content["format"]) + assert.EqualValues(t, 2000, content["max_chars"]) + // 0 is meaningful: it forces a live fetch, so it must survive to the wire. + assert.EqualValues(t, 0, content["max_age_hours"]) + assert.EqualValues(t, 9000, content["timeout_ms"]) + assert.Equal(t, map[string]any{"browser_id": "br_123", "mode": "render"}, content["browser"]) +} + +func TestSearchQueryPinnedStrategyWithNativeOptions(t *testing.T) { + _ = capturePtermOutput(t) + fake := &FakeSearchService{} + s := SearchCmd{search: fake} + + require.NoError(t, s.Query(context.Background(), SearchQueryInput{ + Query: "q", + Provider: "tavily", + ProviderOptions: `{"tavily":{"include_answer":true}}`, + })) + + body := requestJSON(t, fake.LastBody) + strategy, ok := body["strategy"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "pinned", strategy["type"]) + provider, ok := strategy["provider"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "tavily", provider["provider"]) + assert.Equal(t, map[string]any{"include_answer": true}, provider["options"]) +} + +func TestSearchQueryFallbackStrategyPreservesOrder(t *testing.T) { + _ = capturePtermOutput(t) + fake := &FakeSearchService{} + s := SearchCmd{search: fake} + + require.NoError(t, s.Query(context.Background(), SearchQueryInput{ + Query: "q", + FallbackProviders: []string{"exa", "brave"}, + FallbackOn: []string{"error", "empty"}, + })) + + body := requestJSON(t, fake.LastBody) + strategy, ok := body["strategy"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "fallback", strategy["type"]) + assert.Equal(t, []any{"error", "empty"}, strategy["fallback_on"]) + providers, ok := strategy["providers"].([]any) + require.True(t, ok) + require.Len(t, providers, 2) + assert.Equal(t, "exa", providers[0].(map[string]any)["provider"]) + assert.Equal(t, "brave", providers[1].(map[string]any)["provider"]) +} + +func TestSearchQueryAutoStrategyFromProviderOptions(t *testing.T) { + _ = capturePtermOutput(t) + fake := &FakeSearchService{} + s := SearchCmd{search: fake} + + require.NoError(t, s.Query(context.Background(), SearchQueryInput{ + Query: "q", + FallbackOn: []string{"timeout"}, + ProviderOptions: `{"brave":{"safesearch":"strict"},"exa":{}}`, + })) + + body := requestJSON(t, fake.LastBody) + strategy, ok := body["strategy"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "auto", strategy["type"]) + assert.Equal(t, []any{"timeout"}, strategy["fallback_on"]) + opts, ok := strategy["provider_options"].([]any) + require.True(t, ok) + require.Len(t, opts, 2) + // Emitted in documented slug order rather than map order. + assert.Equal(t, "brave", opts[0].(map[string]any)["provider"]) + assert.Equal(t, "exa", opts[1].(map[string]any)["provider"]) +} + +func TestSearchQueryValidationErrors(t *testing.T) { + _ = capturePtermOutput(t) + s := SearchCmd{search: &FakeSearchService{}} + + cases := []struct { + name string + in SearchQueryInput + wantErr string + }{ + {"empty query", SearchQueryInput{Query: " "}, "a search query is required"}, + {"bad recency", SearchQueryInput{Query: "q", Recency: "decade"}, "invalid --recency"}, + {"bad safe search", SearchQueryInput{Query: "q", SafeSearch: "maybe"}, "invalid --safe-search"}, + {"max results too high", SearchQueryInput{Query: "q", MaxResults: 101}, "--max-results must be between 1 and 100"}, + {"bad start date", SearchQueryInput{Query: "q", StartDate: "01-02-2026"}, "invalid --start-date"}, + {"bad content source", SearchQueryInput{Query: "q", Content: searchContentInput{Source: "psychic"}}, "invalid --content-source"}, + {"browser id with provider source", SearchQueryInput{Query: "q", Content: searchContentInput{Source: "provider", BrowserID: "br_1"}}, "--content-browser-id requires --content-source browser"}, + {"bad provider", SearchQueryInput{Query: "q", Provider: "askjeeves"}, "invalid --provider"}, + {"provider with fallback chain", SearchQueryInput{Query: "q", Provider: "brave", FallbackProviders: []string{"exa"}}, "mutually exclusive"}, + {"fallback-on with pinned provider", SearchQueryInput{Query: "q", Provider: "brave", FallbackOn: []string{"error"}}, "--fallback-on has no effect with --provider"}, + {"bad fallback-on", SearchQueryInput{Query: "q", FallbackProviders: []string{"exa"}, FallbackOn: []string{"sometimes"}}, "invalid --fallback-on"}, + {"malformed provider options", SearchQueryInput{Query: "q", ProviderOptions: "not json"}, "invalid --provider-options"}, + {"unknown provider options key", SearchQueryInput{Query: "q", ProviderOptions: `{"altavista":{}}`}, "invalid --provider-options key"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := s.Query(context.Background(), tc.in) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +func TestSearchQueryRendersResults(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeSearchService{ + NewFunc: func(ctx context.Context, body kernel.SearchNewParams, opts ...option.RequestOption) (*kernel.Search, error) { + return &kernel.Search{ + ID: "srch_abc", + Query: body.Request.Query, + Provider: "brave", + Answer: "42", + ExpiresAt: time.Unix(0, 0), + Results: []kernel.Result{{ + ID: "res_1", + Rank: 1, + URL: "https://example.com/a", + Title: "Example A", + Source: "example.com", + Content: kernel.ResultContent{Status: "ok", Text: "extracted body"}, + }}, + Warnings: []kernel.Warning{{Code: "max_results_clamped", Message: "clamped to 20"}}, + Attempts: []kernel.Attempt{{Provider: "brave", Outcome: "success", DurationMs: 120}}, + Usage: kernel.Usage{ResultsCount: 1, ContentFetches: 1}, + }, nil + }, + } + s := SearchCmd{search: fake} + + require.NoError(t, s.Query(context.Background(), SearchQueryInput{Query: "q", ShowContent: true})) + + out := buf.String() + assert.Contains(t, out, "srch_abc") + assert.Contains(t, out, "Example A") + assert.Contains(t, out, "https://example.com/a") + assert.Contains(t, out, "42") + assert.Contains(t, out, "extracted body") + assert.Contains(t, out, "max_results_clamped") + assert.Contains(t, out, "success") + assert.Contains(t, out, "Usage: 1 result(s), 1 content fetch(es)") +} + +func TestSearchGetRendersRetainedSearch(t *testing.T) { + buf := capturePtermOutput(t) + s := SearchCmd{search: &FakeSearchService{}} + + require.NoError(t, s.Get(context.Background(), SearchGetInput{ID: "srch_xyz"})) + assert.Contains(t, buf.String(), "srch_xyz") +} + +func TestSearchProvidersFiltersBySlugAndPrintsDetail(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeSearchProvidersService{ + ListFunc: func(ctx context.Context, query kernel.SearchProviderListParams, opts ...option.RequestOption) (*[]kernel.Provider, error) { + items := []kernel.Provider{{ + Slug: "exa", + MaxResultsCap: 25, + Content: kernel.ProviderContent{Inline: true, PostHoc: false, FreshnessControl: true}, + ProviderOptions: kernel.ProviderProviderOptions{SchemaRef: "ExaOptions"}, + Params: kernel.ProviderParams{Recency: kernel.ProviderParamsRecency{Support: "emulated", Notes: "widened to days"}}, + Notes: []string{"neural search is slower"}, + }} + return &items, nil + }, + } + s := SearchCmd{providers: fake} + + require.NoError(t, s.Providers(context.Background(), SearchProvidersInput{Slug: "exa"})) + + assert.Equal(t, kernel.SearchProviderListParamsSlugExa, fake.LastQuery.Slug) + out := buf.String() + assert.Contains(t, out, "exa") + assert.Contains(t, out, "25") + assert.Contains(t, out, "ExaOptions") + assert.Contains(t, out, "emulated") + assert.Contains(t, out, "neural search is slower") +} + +func TestSearchProvidersRejectsUnknownSlug(t *testing.T) { + _ = capturePtermOutput(t) + s := SearchCmd{providers: &FakeSearchProvidersService{}} + err := s.Providers(context.Background(), SearchProvidersInput{Slug: "altavista"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --slug") +} + +func TestSearchProvidersEmptyJSON(t *testing.T) { + _ = capturePtermOutput(t) + fake := &FakeSearchProvidersService{ + ListFunc: func(ctx context.Context, query kernel.SearchProviderListParams, opts ...option.RequestOption) (*[]kernel.Provider, error) { + items := []kernel.Provider{} + return &items, nil + }, + } + s := SearchCmd{providers: fake} + require.NoError(t, s.Providers(context.Background(), SearchProvidersInput{Output: "json"})) +} + +func TestSearchContentsBuildsFetchRequest(t *testing.T) { + _ = capturePtermOutput(t) + fake := &FakeSearchContentsService{} + s := SearchCmd{contents: fake} + + require.NoError(t, s.Contents(context.Background(), SearchContentsInput{ + ID: "srch_abc", + ResultIDs: []string{"res_2", "res_1"}, + TimeoutMs: 5000, + Content: searchContentInput{Source: "auto", Format: "markdown", MaxChars: 500}, + })) + + assert.Equal(t, "srch_abc", fake.LastID) + raw, err := json.Marshal(fake.LastBody) + require.NoError(t, err) + var body map[string]any + require.NoError(t, json.Unmarshal(raw, &body)) + assert.Equal(t, []any{"res_2", "res_1"}, body["result_ids"]) + assert.EqualValues(t, 5000, body["timeout_ms"]) + assert.Equal(t, map[string]any{"source": "auto", "format": "markdown", "max_chars": float64(500)}, body["content"]) + assert.NotContains(t, body, "limit") +} + +func TestSearchContentsRejectsResultIDsWithLimit(t *testing.T) { + _ = capturePtermOutput(t) + s := SearchCmd{contents: &FakeSearchContentsService{}} + err := s.Contents(context.Background(), SearchContentsInput{ID: "srch_abc", ResultIDs: []string{"res_1"}, Limit: 5}) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") +} + +func TestSearchCommandFlagsAreWired(t *testing.T) { + for _, name := range []string{ + "country", "language", "max-results", "recency", "safe-search", "start-date", "end-date", + "include-domains", "exclude-domains", "strict-params", "include-raw", "timeout-ms", + "content", "content-source", "content-format", "content-max-chars", "content-max-age-hours", + "content-timeout-ms", "content-browser-id", "content-browser-mode", "show-content", + "provider", "fallback-providers", "fallback-on", "provider-options", "output", + } { + assert.NotNil(t, searchCmd.Flags().Lookup(name), "kernel search is missing --%s", name) + } + for _, name := range []string{"result-ids", "limit", "timeout-ms", "content-source", "output"} { + assert.NotNil(t, searchContentsCmd.Flags().Lookup(name), "kernel search contents is missing --%s", name) + } + assert.NotNil(t, searchProvidersCmd.Flags().Lookup("slug")) + assert.NotNil(t, searchGetCmd.Flags().Lookup("show-content")) + + var names []string + for _, c := range searchCmd.Commands() { + names = append(names, c.Name()) + } + assert.ElementsMatch(t, []string{"get", "providers", "contents"}, names, "got %s", strings.Join(names, ",")) +} diff --git a/go.mod b/go.mod index c0793f27..19be45a3 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.110.1-0.20260921195148-aee0b02b9b0b + github.com/kernel/kernel-go-sdk v0.110.1-0.20260922150240-05546992051b github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index cf08de2e..b9ed4708 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.110.1-0.20260921195148-aee0b02b9b0b h1:qb0fJhkc/ms44bzP9AMShSYvqIGdlC0CAMUaOY00y68= -github.com/kernel/kernel-go-sdk v0.110.1-0.20260921195148-aee0b02b9b0b/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.110.1-0.20260922150240-05546992051b h1:fE2MvloYV+I6+7bHI+Jzt9kvD+DGTiFVBbEmAjgI5mE= +github.com/kernel/kernel-go-sdk v0.110.1-0.20260922150240-05546992051b/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From f50d03746d1accfd4741d00a48ba003c3bfc2d3c Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:46:15 +0000 Subject: [PATCH 16/18] chore: bump Go SDK to v0.111.0 (df8b441) SDK release bump only; no API method or param changes. Full enumeration of api.md methods against CLI commands found no gaps. Tested: go build ./..., go test ./... Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 19be45a3..74391969 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.110.1-0.20260922150240-05546992051b + github.com/kernel/kernel-go-sdk v0.111.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index b9ed4708..8e430f52 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.110.1-0.20260922150240-05546992051b h1:fE2MvloYV+I6+7bHI+Jzt9kvD+DGTiFVBbEmAjgI5mE= -github.com/kernel/kernel-go-sdk v0.110.1-0.20260922150240-05546992051b/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.111.0 h1:pGH1raf7S51kBhE2PrYKT6QUA7EMXyQkRAK9rN2kmTo= +github.com/kernel/kernel-go-sdk v0.111.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 28067227686f0ef56123c1ed3d5af3cf743d84c8 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:16:32 +0000 Subject: [PATCH 17/18] chore: bump Go SDK to 1ef7b7c Updates kernel-go-sdk to 1ef7b7c34dc3b987d6c515c82ea399c424f0c6e6 (adds iframe/worker browser event target types; response-only change). Full enumeration of SDK methods and params found no CLI coverage gaps. Tested: go build ./..., go test ./... (no new commands/flags to smoke test) Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 74391969..4aacc76f 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.111.0 + github.com/kernel/kernel-go-sdk v0.111.1-0.20260922191008-1ef7b7c34dc3 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 8e430f52..db1c8fc9 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.111.0 h1:pGH1raf7S51kBhE2PrYKT6QUA7EMXyQkRAK9rN2kmTo= -github.com/kernel/kernel-go-sdk v0.111.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.111.1-0.20260922191008-1ef7b7c34dc3 h1:2LSe1R3EwZH0lwmqZnEuDO8JMEArYksOAz4QLo9LN20= +github.com/kernel/kernel-go-sdk v0.111.1-0.20260922191008-1ef7b7c34dc3/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 846f8416b306b95e50210aa39ddfac8de7b66809 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:11:17 +0000 Subject: [PATCH 18/18] chore: bump Go SDK to 0584ea1 Updates kernel-go-sdk to 0584ea1d2238bfb06f1995ef4cab305917941efa. SDK change is documentation-only (BrowserMonitorDisconnectedEvent comment); full enumeration found no new coverage gaps. Build verified with go build ./... Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4aacc76f..48c594a9 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.111.1-0.20260922191008-1ef7b7c34dc3 + github.com/kernel/kernel-go-sdk v0.111.1-0.20260922230520-0584ea1d2238 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index db1c8fc9..25c30f90 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.111.1-0.20260922191008-1ef7b7c34dc3 h1:2LSe1R3EwZH0lwmqZnEuDO8JMEArYksOAz4QLo9LN20= -github.com/kernel/kernel-go-sdk v0.111.1-0.20260922191008-1ef7b7c34dc3/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.111.1-0.20260922230520-0584ea1d2238 h1:YIXkHOZ6bpmZ8W1LtXHbAxpywu6bMZLROq8lWd9//yw= +github.com/kernel/kernel-go-sdk v0.111.1-0.20260922230520-0584ea1d2238/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=