← back to Cli Printing Press
fix(cli): session_handshake auth correctness pass (#534)
96dd07c06c236e49ef55dd9ba9ae33c6dd65ae9f · 2026-05-03 17:23:16 -0700 · Trevin Chow
* fix(cli): session handshake inherits browser-impersonated transport
When auth.type: session_handshake and http_transport: browser-chrome are
both set, the data client uses Surf but the session bootstrap and
token-fetch calls inside newSessionManager() were running on a vanilla
&http.Client{}. The handshake itself (the call that actually needs the
bot wall cleared) was getting 429'd even when probe-reachability
confirmed Surf cleared the same endpoint.
Route the session manager's HTTP client through the same newHTTPClient
factory as the data client so both inherit whatever transport the spec
requested. The non-browser path is byte-identical: newHTTPClient returns
&http.Client{Timeout: timeout, Jar: jar} when UsesBrowserHTTPTransport
is unset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): session_handshake — canonical attach fields, multi-host cookies, spec validation
Two user-reported bugs in printed CLIs that wrap session_handshake APIs
(Yahoo Finance crumb-style flows):
1. The generated client.go always emitted req.Header.Set("Authorization",
token) for session_handshake auth, regardless of the spec's
token_param_in. A spec that set only the canonical session-handshake
fields (token_param_in / token_param_name) and not the api_key pair
(in / header) had its crumb attached as a header instead of the
?crumb= query param the API expected.
2. saveToDisk persisted cookies for the token endpoint host only.
Bootstrap-host and data-host cookies (e.g. fc.yahoo.com sets cookies
that legitimize a query2.finance.yahoo.com crumb against
query1.finance.yahoo.com) were dropped on save, so multi-call
invocations 401'd with "Invalid Crumb" mid-investigation.
Template changes:
- client.go.tmpl: branch on .Auth.Type == "session_handshake" before the
api_key in/header branch and emit using TokenParamName / TokenParamIn.
Same branch added to dryRun preview so wired auth shows correctly.
- session_handshake.go.tmpl: persist cookies per host across
[bootstrap, token, base], dedupe by rootURL+name=value so identical
parent-domain cookies don't multiply when hosts share a registrable
domain.
Hardening (review-driven):
- internal/spec/spec.go: validateSessionHandshake fail-fasts on specs
that would otherwise emit silently-broken Go: missing
session_token_url, missing token_param_name (would emit q.Set("", t)),
or mixed-case token_param_in (template byte-compares, so "Header"
silently routes to the query branch).
Tests:
- session_handshake_test.go: TokenAttachesAsQueryParam uses only the
canonical fields and asserts q.Set("crumb", authHeader);
TokenAttachesAsHeader covers the alternate placement;
CookiePersistenceMultiHost asserts the per-host iteration is emitted.
Shared canonicalSessionHandshakeSpec helper.
- spec_test.go: TestSessionHandshakeValidate covers each rejection path
and the accepted shapes (empty TokenParamIn defaults to query).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M internal/generator/session_handshake_test.goM internal/generator/templates/client.go.tmplM internal/generator/templates/session_handshake.go.tmplM internal/spec/spec.goM internal/spec/spec_test.go
Diff
commit 96dd07c06c236e49ef55dd9ba9ae33c6dd65ae9f
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun May 3 17:23:16 2026 -0700
fix(cli): session_handshake auth correctness pass (#534)
* fix(cli): session handshake inherits browser-impersonated transport
When auth.type: session_handshake and http_transport: browser-chrome are
both set, the data client uses Surf but the session bootstrap and
token-fetch calls inside newSessionManager() were running on a vanilla
&http.Client{}. The handshake itself (the call that actually needs the
bot wall cleared) was getting 429'd even when probe-reachability
confirmed Surf cleared the same endpoint.
Route the session manager's HTTP client through the same newHTTPClient
factory as the data client so both inherit whatever transport the spec
requested. The non-browser path is byte-identical: newHTTPClient returns
&http.Client{Timeout: timeout, Jar: jar} when UsesBrowserHTTPTransport
is unset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): session_handshake — canonical attach fields, multi-host cookies, spec validation
Two user-reported bugs in printed CLIs that wrap session_handshake APIs
(Yahoo Finance crumb-style flows):
1. The generated client.go always emitted req.Header.Set("Authorization",
token) for session_handshake auth, regardless of the spec's
token_param_in. A spec that set only the canonical session-handshake
fields (token_param_in / token_param_name) and not the api_key pair
(in / header) had its crumb attached as a header instead of the
?crumb= query param the API expected.
2. saveToDisk persisted cookies for the token endpoint host only.
Bootstrap-host and data-host cookies (e.g. fc.yahoo.com sets cookies
that legitimize a query2.finance.yahoo.com crumb against
query1.finance.yahoo.com) were dropped on save, so multi-call
invocations 401'd with "Invalid Crumb" mid-investigation.
Template changes:
- client.go.tmpl: branch on .Auth.Type == "session_handshake" before the
api_key in/header branch and emit using TokenParamName / TokenParamIn.
Same branch added to dryRun preview so wired auth shows correctly.
- session_handshake.go.tmpl: persist cookies per host across
[bootstrap, token, base], dedupe by rootURL+name=value so identical
parent-domain cookies don't multiply when hosts share a registrable
domain.
Hardening (review-driven):
- internal/spec/spec.go: validateSessionHandshake fail-fasts on specs
that would otherwise emit silently-broken Go: missing
session_token_url, missing token_param_name (would emit q.Set("", t)),
or mixed-case token_param_in (template byte-compares, so "Header"
silently routes to the query branch).
Tests:
- session_handshake_test.go: TokenAttachesAsQueryParam uses only the
canonical fields and asserts q.Set("crumb", authHeader);
TokenAttachesAsHeader covers the alternate placement;
CookiePersistenceMultiHost asserts the per-host iteration is emitted.
Shared canonicalSessionHandshakeSpec helper.
- spec_test.go: TestSessionHandshakeValidate covers each rejection path
and the accepted shapes (empty TokenParamIn defaults to query).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
internal/generator/session_handshake_test.go | 136 +++++++++++++++++++++
internal/generator/templates/client.go.tmpl | 33 ++++-
.../generator/templates/session_handshake.go.tmpl | 33 ++++-
internal/spec/spec.go | 26 ++++
internal/spec/spec_test.go | 93 ++++++++++++++
5 files changed, 314 insertions(+), 7 deletions(-)
diff --git a/internal/generator/session_handshake_test.go b/internal/generator/session_handshake_test.go
index 373cfa97..77685c8f 100644
--- a/internal/generator/session_handshake_test.go
+++ b/internal/generator/session_handshake_test.go
@@ -167,6 +167,142 @@ func TestSessionHandshakeBrowserTransportSharesJar(t *testing.T) {
if !strings.Contains(string(sessionContent), "func (m *SessionManager) CookieJar() http.CookieJar") {
t.Error("session.go missing CookieJar accessor")
}
+
+ // The bootstrap + token-fetch calls inside the SessionManager must run
+ // through the same newHTTPClient as the data path. Otherwise the
+ // handshake itself uses vanilla net/http and the bot wall returns 429
+ // on the very call that would have established the session, even when
+ // the data client could have cleared it.
+ if !strings.Contains(string(sessionContent), "client: newHTTPClient(timeout, jar)") {
+ t.Error("session.go's newSessionManager must use newHTTPClient so the handshake inherits the browser-impersonated transport, not a vanilla &http.Client{}")
+ }
+ if strings.Contains(string(sessionContent), "&http.Client{Timeout: timeout, Jar: jar}") {
+ t.Error("session.go still constructs a vanilla &http.Client{} — the handshake will bypass Surf impersonation")
+ }
+}
+
+// canonicalSessionHandshakeSpec returns a session-handshake spec that uses
+// only the canonical fields (TokenParamName / TokenParamIn) and omits the
+// api_key In / Header pair so tests can verify session-handshake fields
+// drive generation on their own.
+func canonicalSessionHandshakeSpec() *spec.APISpec {
+ return &spec.APISpec{
+ Name: "demo",
+ Version: "1.0.0",
+ Description: "test",
+ BaseURL: "https://query1.example.com",
+ Auth: spec.AuthConfig{
+ Type: "session_handshake",
+ BootstrapURL: "https://bootstrap.example.com/",
+ SessionTokenURL: "https://query2.example.com/v1/getcrumb",
+ TokenFormat: "text",
+ TokenParamName: "crumb",
+ TokenParamIn: "query",
+ InvalidateOnStatus: []int{401, 403},
+ },
+ Config: spec.ConfigSpec{Format: "toml", Path: "~/.config/demo-pp-cli/config.toml"},
+ Resources: map[string]spec.Resource{
+ "quote": {
+ Description: "Quotes",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/v7/finance/quote", Description: "Get quotes"},
+ },
+ },
+ },
+ }
+}
+
+// TestSessionHandshakeTokenAttachesAsQueryParam verifies that for
+// session_handshake auth with TokenParamIn=query, the generated client.go
+// attaches the token as a query parameter named TokenParamName — NOT as an
+// Authorization header. The spec's canonical fields for session-handshake
+// token placement are TokenParamIn / TokenParamName, distinct from the
+// api_key In / Header pair. A spec that sets only the canonical fields
+// must still produce a query-param attachment, not fall through to a
+// header.
+func TestSessionHandshakeTokenAttachesAsQueryParam(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ if err := New(canonicalSessionHandshakeSpec(), dir).Generate(); err != nil {
+ t.Fatalf("Generate: %v", err)
+ }
+
+ clientContent, err := os.ReadFile(filepath.Join(dir, "internal", "client", "client.go"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !strings.Contains(string(clientContent), `q.Set("crumb", authHeader)`) {
+ t.Error("client.go does not attach the session-handshake token as a query parameter named \"crumb\" — likely fell through to header attachment")
+ }
+ if strings.Contains(string(clientContent), `req.Header.Set("Authorization", authHeader)`) {
+ t.Error("client.go still emits req.Header.Set(\"Authorization\", authHeader) for session_handshake auth — token will reach the wrong place")
+ }
+}
+
+// TestSessionHandshakeTokenAttachesAsHeader covers the alternate placement
+// where session-handshake tokens travel as a request header (TokenParamIn
+// "header") instead of a query parameter. The template branches on this
+// value in both do() and dryRun(); without this test the header path is
+// uncovered and a regression flipping the polarity of the conditional
+// would not be caught.
+func TestSessionHandshakeTokenAttachesAsHeader(t *testing.T) {
+ t.Parallel()
+
+ sp := canonicalSessionHandshakeSpec()
+ sp.Auth.TokenParamIn = "header"
+ sp.Auth.TokenParamName = "X-Crumb"
+
+ dir := t.TempDir()
+ if err := New(sp, dir).Generate(); err != nil {
+ t.Fatalf("Generate: %v", err)
+ }
+
+ clientContent, err := os.ReadFile(filepath.Join(dir, "internal", "client", "client.go"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !strings.Contains(string(clientContent), `req.Header.Set("X-Crumb", authHeader)`) {
+ t.Error("client.go does not attach the session-handshake token as a request header named \"X-Crumb\" — header branch is broken or unreachable")
+ }
+ if strings.Contains(string(clientContent), `q.Set("X-Crumb", authHeader)`) {
+ t.Error("client.go also emits a query-param attachment for TokenParamIn=header — branches collapsed")
+ }
+}
+
+// TestSessionHandshakeCookiePersistenceMultiHost verifies the generated
+// session.go snapshots cookies for every host the session interacts with
+// (bootstrap, token, data base) so cookies set during bootstrap survive
+// across invocations and reach the data host that needs them. cookiejar
+// strips Domain on Cookies(u), so a single-host snapshot loses any cookie
+// not host-scoped to that host.
+func TestSessionHandshakeCookiePersistenceMultiHost(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ if err := New(canonicalSessionHandshakeSpec(), dir).Generate(); err != nil {
+ t.Fatalf("Generate: %v", err)
+ }
+
+ sessionContent, err := os.ReadFile(filepath.Join(dir, "internal", "client", "session.go"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for _, want := range []string{
+ `sessionDataBaseURL = "https://query1.example.com"`,
+ `for _, host := range []string{sessionBootstrapURL, sessionTokenURL, sessionDataBaseURL}`,
+ } {
+ if !strings.Contains(string(sessionContent), want) {
+ t.Errorf("session.go missing expected substring %q", want)
+ }
+ }
+
+ if strings.Contains(string(sessionContent), `// Persist cookies for the token endpoint host (broadest plausible scope).`) {
+ t.Error("session.go still contains the single-host cookie-persistence comment — cross-host bootstrap cookies will be dropped on save")
+ }
}
// TestSessionHandshakeNotEmittedForOtherAuth verifies the session helper is
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 399540c1..c533257c 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -442,7 +442,15 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
{{- end}}
if authHeader != "" {
-{{- if and .Auth .Auth.In (eq .Auth.In "query")}}
+{{- if eq .Auth.Type "session_handshake"}}
+{{- if eq .Auth.TokenParamIn "header"}}
+ req.Header.Set("{{.Auth.TokenParamName}}", authHeader)
+{{- else}}
+ q := req.URL.Query()
+ q.Set("{{.Auth.TokenParamName}}", authHeader)
+ req.URL.RawQuery = q.Encode()
+{{- end}}
+{{- else if and .Auth .Auth.In (eq .Auth.In "query")}}
// API key goes in query parameter
q := req.URL.Query()
q.Set("{{if .Auth.Header}}{{.Auth.Header}}{{else}}api_key{{end}}", authHeader)
@@ -563,7 +571,20 @@ func (c *Client) dryRun(method, targetURL, path string, params map[string]string
queryPrinted = true
}
}
-{{- if and .Auth .Auth.In (eq .Auth.In "query")}}
+{{- if eq .Auth.Type "session_handshake"}}
+{{- if ne .Auth.TokenParamIn "header"}}
+ // Session-handshake token travels as a query parameter on the live
+ // request — surface it in the preview so users can verify wiring.
+ if authHeader != "" {
+ sep := "?"
+ if queryPrinted {
+ sep = "&"
+ }
+ fmt.Fprintf(os.Stderr, " %s%s=%s\n", sep, "{{.Auth.TokenParamName}}", maskToken(authHeader))
+ queryPrinted = true
+ }
+{{- end}}
+{{- else if and .Auth .Auth.In (eq .Auth.In "query")}}
// Auth material travels as a query parameter on the live request — surface
// it in the preview so users can verify wiring.
if authHeader != "" {
@@ -586,7 +607,13 @@ func (c *Client) dryRun(method, targetURL, path string, params map[string]string
}
}
{{- end}}
-{{- if not (and .Auth .Auth.In (eq .Auth.In "query"))}}
+{{- if eq .Auth.Type "session_handshake"}}
+{{- if eq .Auth.TokenParamIn "header"}}
+ if authHeader != "" {
+ fmt.Fprintf(os.Stderr, " %s: %s\n", "{{.Auth.TokenParamName}}", maskToken(authHeader))
+ }
+{{- end}}
+{{- else if not (and .Auth .Auth.In (eq .Auth.In "query"))}}
if authHeader != "" {
fmt.Fprintf(os.Stderr, " %s: %s\n", "{{if .Auth.Header}}{{.Auth.Header}}{{else}}Authorization{{end}}", maskToken(authHeader))
}
diff --git a/internal/generator/templates/session_handshake.go.tmpl b/internal/generator/templates/session_handshake.go.tmpl
index a57ad71d..7c2c2dd0 100644
--- a/internal/generator/templates/session_handshake.go.tmpl
+++ b/internal/generator/templates/session_handshake.go.tmpl
@@ -33,6 +33,7 @@ import (
const (
sessionBootstrapURL = "{{.Auth.BootstrapURL}}"
sessionTokenURL = "{{.Auth.SessionTokenURL}}"
+ sessionDataBaseURL = "{{.BaseURL}}"
sessionTokenFormat = "{{if .Auth.TokenFormat}}{{.Auth.TokenFormat}}{{else}}text{{end}}"
sessionTokenJSONPath = "{{.Auth.TokenJSONPath}}"
sessionUserAgent = "Mozilla/5.0 (compatible; {{.Name}}-pp-cli)"
@@ -65,11 +66,17 @@ type SessionManager struct {
// newSessionManager constructs a SessionManager with a cookie jar attached to
// its HTTP client. Any cached session on disk is loaded immediately.
+//
+// The bootstrap and token-fetch calls go through the same newHTTPClient as the
+// data path so a browser-impersonated transport (Surf, when configured) is
+// applied to the handshake itself — otherwise the very calls that establish
+// the session run as a vanilla net/http client and get rate-limited away
+// even when the data client would have cleared the bot wall.
func newSessionManager(timeout time.Duration) *SessionManager {
jar, _ := cookiejar.New(nil)
m := &SessionManager{
jar: jar,
- client: &http.Client{Timeout: timeout, Jar: jar},
+ client: newHTTPClient(timeout, jar),
}
m.loadFromDisk()
return m
@@ -264,12 +271,30 @@ func (m *SessionManager) saveToDisk() {
tok := m.token
m.mu.Unlock()
+ // Persist cookies for every host the session interacts with. cookiejar
+ // strips Domain on Cookies(u), so a single-host snapshot drops cookies
+ // scoped to a parent domain that other hosts in the handshake also rely
+ // on. Re-snapshot per host so each destination has the cookies it needs
+ // after restore.
var cookies []jarCookie
- // Persist cookies for the token endpoint host (broadest plausible scope).
- if u, err := url.Parse(sessionTokenURL); err == nil {
+ seen := make(map[string]bool)
+ for _, host := range []string{sessionBootstrapURL, sessionTokenURL, sessionDataBaseURL} {
+ if host == "" {
+ continue
+ }
+ u, err := url.Parse(host)
+ if err != nil || u.Host == "" {
+ continue
+ }
+ rootURL := u.Scheme + "://" + u.Host + "/"
for _, ck := range m.jar.Cookies(u) {
+ key := rootURL + "|" + ck.Name + "=" + ck.Value
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
cookies = append(cookies, jarCookie{
- URL: u.Scheme + "://" + u.Host + "/",
+ URL: rootURL,
Name: ck.Name,
Value: ck.Value,
Path: ck.Path,
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 1751e164..974368a1 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -405,6 +405,29 @@ func validateOAuth2Grant(c AuthConfig) error {
}
}
+// validateSessionHandshake enforces fail-fast on session_handshake auth specs
+// that would otherwise emit silently-broken Go code: missing token_param_name
+// produces q.Set("", token); a SessionTokenURL is required to bootstrap; and
+// token_param_in is byte-compared in the template, so spec authors who write
+// "Header" or "QUERY" silently route to the wrong attachment path.
+func validateSessionHandshake(c AuthConfig) error {
+ if c.Type != "session_handshake" {
+ return nil
+ }
+ if c.SessionTokenURL == "" {
+ return fmt.Errorf("auth.session_token_url is required when auth.type is %q", c.Type)
+ }
+ if c.TokenParamName == "" {
+ return fmt.Errorf("auth.token_param_name is required when auth.type is %q", c.Type)
+ }
+ switch c.TokenParamIn {
+ case "", "header", "query":
+ return nil
+ default:
+ return fmt.Errorf("auth.token_param_in %q is not recognized (valid: %q, %q)", c.TokenParamIn, "header", "query")
+ }
+}
+
type ConfigSpec struct {
Format string `yaml:"format" json:"format"` // toml, yaml
Path string `yaml:"path" json:"path"`
@@ -1125,6 +1148,9 @@ func (s *APISpec) Validate() error {
if err := validateOAuth2Grant(s.Auth); err != nil {
return err
}
+ if err := validateSessionHandshake(s.Auth); err != nil {
+ return err
+ }
if s.ClientPattern == "proxy-envelope" && s.HasResourceBaseURLOverride() {
return fmt.Errorf("resource base_url overrides are incompatible with client_pattern=proxy-envelope; the proxy POSTs every request to the spec-level BaseURL, so per-resource overrides would be silently ignored")
}
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 57d363cf..46acc030 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -184,6 +184,99 @@ func TestOAuth2GrantValidate(t *testing.T) {
}
}
+func TestSessionHandshakeValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg AuthConfig
+ wantErr string
+ }{
+ {name: "non-session_handshake type is unaffected", cfg: AuthConfig{Type: "api_key"}},
+ {
+ name: "valid session_handshake config",
+ cfg: AuthConfig{
+ Type: "session_handshake",
+ SessionTokenURL: "https://api.example.com/token",
+ TokenParamName: "crumb",
+ TokenParamIn: "query",
+ },
+ },
+ {
+ name: "TokenParamIn empty is accepted (template defaults to query)",
+ cfg: AuthConfig{
+ Type: "session_handshake",
+ SessionTokenURL: "https://api.example.com/token",
+ TokenParamName: "crumb",
+ },
+ },
+ {
+ name: "header attachment is accepted",
+ cfg: AuthConfig{
+ Type: "session_handshake",
+ SessionTokenURL: "https://api.example.com/token",
+ TokenParamName: "crumb",
+ TokenParamIn: "header",
+ },
+ },
+ {
+ name: "missing SessionTokenURL is rejected",
+ cfg: AuthConfig{
+ Type: "session_handshake",
+ TokenParamName: "crumb",
+ },
+ wantErr: "auth.session_token_url is required",
+ },
+ {
+ name: "missing TokenParamName is rejected (would emit q.Set(\"\", token))",
+ cfg: AuthConfig{
+ Type: "session_handshake",
+ SessionTokenURL: "https://api.example.com/token",
+ },
+ wantErr: "auth.token_param_name is required",
+ },
+ {
+ name: "title-cased TokenParamIn is rejected (template byte-compares)",
+ cfg: AuthConfig{
+ Type: "session_handshake",
+ SessionTokenURL: "https://api.example.com/token",
+ TokenParamName: "crumb",
+ TokenParamIn: "Header",
+ },
+ wantErr: `auth.token_param_in "Header" is not recognized`,
+ },
+ {
+ name: "uppercase TokenParamIn is rejected",
+ cfg: AuthConfig{
+ Type: "session_handshake",
+ SessionTokenURL: "https://api.example.com/token",
+ TokenParamName: "crumb",
+ TokenParamIn: "QUERY",
+ },
+ wantErr: "not recognized",
+ },
+ {
+ name: "unknown TokenParamIn value is rejected",
+ cfg: AuthConfig{
+ Type: "session_handshake",
+ SessionTokenURL: "https://api.example.com/token",
+ TokenParamName: "crumb",
+ TokenParamIn: "cookie",
+ },
+ wantErr: "not recognized",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateSessionHandshake(tt.cfg)
+ if tt.wantErr == "" {
+ require.NoError(t, err)
+ return
+ }
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ })
+ }
+}
+
func TestEffectiveOAuth2Grant(t *testing.T) {
tests := []struct {
name string
← 13995da7 feat(skills): brainstorm novel features via Task subagent (#
·
back to Cli Printing Press
·
fix(cli): sync root highlights from verified features (#549) 8b099ba0 →