Skip to content

Commit 22fb4d5

Browse files
Release CLI 0.1.12
1 parent 762f504 commit 22fb4d5

19 files changed

Lines changed: 527 additions & 46 deletions

README.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,11 @@ CODAG_CONSOLE=http://localhost:3000 codag auth login
146146
After `codag setup`, supported agents can call Codag tools directly instead of
147147
fetching raw logs through shell commands. For example, an agent can call
148148
`tail_kubernetes`, `tail_aws_logs`, or the generic `wrap` tool and receive a
149-
deterministic compact summary instead of thousands of raw log lines.
149+
compact summary instead of thousands of raw log lines.
150150

151-
MCP log tools use the hosted deterministic compact path directly. They do not
152-
call the authenticated capsule/parse inference endpoints.
151+
MCP log tools use the same compact endpoint as `codag wrap`: signed-in
152+
workspaces follow the Free/Pro plan, and signed-out tools fall back to the
153+
hosted deterministic Free path.
153154

154155
If your Codag org runs out of credits or needs a billing action, MCP tool errors
155156
include a stable marker such as `CODAG_CREDITS_EXHAUSTED` and tell the agent to
@@ -162,11 +163,14 @@ to the configured Codag API server for compression. Provider credentials used by
162163
local source connectors stay in your local Codag config unless a provider API
163164
request itself requires them.
164165

165-
When you are not signed in, `codag wrap` in the default compact mode falls back
166-
to the hosted free deterministic templating path. The CLI mints and stores a
167-
server-issued anonymous `cda_...` token, calls `/v1/free/compact`, and reuses the
168-
token until you log in. Anonymous `--mode capsule` and `--mode parse` still
169-
require login because those modes use the authenticated inference pipeline.
166+
When you are signed in, `codag wrap` in the default compact mode uses the current
167+
workspace plan: Codag Free returns deterministic compaction, and Codag Pro
168+
returns the hosted inference compaction path with deterministic fallback. When
169+
you are not signed in, compact mode falls back to the hosted free deterministic
170+
path. The CLI mints and stores a server-issued anonymous `cda_...` token, calls
171+
`/v1/free/compact`, and reuses the token until you log in. Anonymous
172+
`--mode capsule` and `--mode parse` still require login and Codag Pro because
173+
those modes use the authenticated inference pipeline.
170174

171175
Use `CODAG_SERVER` or `codag config set server <url>` to point the CLI at a
172176
self-hosted, staging, or local API.

cmd/config.go

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strings"
88

99
"github.com/codag-megalith/codag-cli/internal/config"
10+
"github.com/codag-megalith/codag-cli/internal/setup"
1011
"github.com/spf13/cobra"
1112
)
1213

@@ -17,14 +18,22 @@ var configCmd = &cobra.Command{
1718

1819
var configSetCmd = &cobra.Command{
1920
Use: "set <key> <value>",
20-
Short: "Set a config value (server, telemetry, updates)",
21-
Args: cobra.ExactArgs(2),
22-
RunE: runConfigSet,
21+
Short: "Set a config value (server, telemetry, updates, <provider>.<field>)",
22+
Long: `Set a config value.
23+
24+
Top-level keys: server, telemetry, updates.
25+
26+
Provider credentials use a <provider>.<field> key, e.g.:
27+
codag config set datadog.api_key <value>
28+
codag config set sentry.auth_token <value>
29+
This is the non-interactive equivalent of the provider prompts in ` + "`codag setup`" + `.`,
30+
Args: cobra.ExactArgs(2),
31+
RunE: runConfigSet,
2332
}
2433

2534
var configGetCmd = &cobra.Command{
2635
Use: "get <key>",
27-
Short: "Print a config value (server, telemetry, updates)",
36+
Short: "Print a config value (server, telemetry, updates, <provider>.<field>)",
2837
Args: cobra.ExactArgs(1),
2938
RunE: runConfigGet,
3039
}
@@ -48,6 +57,18 @@ func runConfigSet(_ *cobra.Command, args []string) error {
4857
if err != nil {
4958
return err
5059
}
60+
// `<provider>.<field>` routes to per-provider credentials (datadog.api_key,
61+
// sentry.auth_token, …) — the scriptable equivalent of `codag setup`.
62+
if provider, field, ok := strings.Cut(key, "."); ok {
63+
if err := setup.SetProviderField(c, provider, field, val); err != nil {
64+
return err
65+
}
66+
if err := config.Save(c); err != nil {
67+
return err
68+
}
69+
fmt.Fprintf(os.Stderr, "saved %s -> %s\n", key, config.ConfigPath())
70+
return nil
71+
}
5172
switch key {
5273
case "server":
5374
c.Server = val
@@ -72,7 +93,7 @@ func runConfigSet(_ *cobra.Command, args []string) error {
7293
case "api-key", "api_key":
7394
return fmt.Errorf("api-key is no longer supported; sign in with `codag auth login`")
7495
default:
75-
return fmt.Errorf("unknown key %q (valid: server, telemetry, updates)", key)
96+
return fmt.Errorf("unknown key %q (valid: server, telemetry, updates, or <provider>.<field> such as datadog.api_key)", key)
7697
}
7798
if err := config.Save(c); err != nil {
7899
return err
@@ -87,6 +108,16 @@ func runConfigGet(_ *cobra.Command, args []string) error {
87108
if err != nil {
88109
return err
89110
}
111+
// `<provider>.<field>` reads per-provider credentials. Secret fields
112+
// (api_key, auth_token, …) are masked; host/org print in full.
113+
if provider, field, ok := strings.Cut(key, "."); ok {
114+
v, err := setup.DescribeProviderField(c, provider, field)
115+
if err != nil {
116+
return err
117+
}
118+
fmt.Println(v)
119+
return nil
120+
}
90121
switch key {
91122
case "server":
92123
fmt.Println(c.Server)
@@ -105,7 +136,7 @@ func runConfigGet(_ *cobra.Command, args []string) error {
105136
case "api-key", "api_key":
106137
return fmt.Errorf("api-key is no longer supported; sign in with `codag auth login`")
107138
default:
108-
return fmt.Errorf("unknown key %q (valid: server, telemetry, updates)", key)
139+
return fmt.Errorf("unknown key %q (valid: server, telemetry, updates, or <provider>.<field> such as datadog.api_key)", key)
109140
}
110141
return nil
111142
}

cmd/help_llm.go

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ If codag MCP tools are available:
3737
- Use ` + "`wrap`" + ` for local files and provider CLIs without a named tool.
3838
- Use ` + "`compact`" + ` only for log text that is already in chat or returned by another
3939
tool and cannot be fetched from disk/provider CLI.
40-
- MCP log tools return deterministic compact summaries; they do not use the
41-
authenticated capsule/parse inference endpoints.
40+
- MCP log tools return compact summaries. If the user is signed in, they follow
41+
the workspace Free/Pro plan. If not signed in, compact falls back to hosted
42+
Free deterministic compaction.
4243
4344
## CLI fallback patterns
4445
@@ -58,11 +59,11 @@ If codag MCP tools are available:
5859
5960
## Output modes
6061
61-
- ` + "`compact`" + ` Default. Plain-text deterministic compact summary with
62-
severity, services, and representative evidence.
63-
Best for pasting directly back into your reasoning.
64-
- ` + "`capsule`" + ` IncidentCapsule JSON. Use when you need structured fields.
65-
- ` + "`parse`" + ` Just the unique templates. Use when you want the patterns only.
62+
- ` + "`compact`" + ` Default. Plan-aware when signed in: Codag Free returns
63+
deterministic compaction; Codag Pro returns the inference evidence-board path
64+
with deterministic fallback. Anonymous compact uses hosted Free.
65+
- ` + "`capsule`" + ` IncidentCapsule JSON. Requires login and Codag Pro.
66+
- ` + "`parse`" + ` Just the unique templates. Requires login and Codag Pro.
6667
6768
## Useful flags
6869
@@ -87,9 +88,10 @@ Use it to confirm compression worked.
8788
- Exit code 1 + ` + "`error: ...`" + ` on stderr if the API is unreachable or rejected
8889
the request. Pass ` + "`--server`" + ` to override the configured server URL,
8990
or run ` + "`codag auth login`" + ` if the server says you're not signed in.
90-
- If an MCP tool returns ` + "`CODAG_CREDITS_EXHAUSTED`" + ` or ` + "`CODAG_BILLING_REQUIRED`" + `,
91-
tell the user to upgrade/manage billing, or run ` + "`codag disable`" + ` to remove
92-
codag MCP tools from their agents.
91+
- If a tool returns ` + "`CODAG_PRO_REQUIRED`" + `, ` + "`CODAG_CREDITS_EXHAUSTED`" + `, or
92+
` + "`CODAG_BILLING_REQUIRED`" + `, tell the user to open Billing, upgrade/manage the
93+
workspace plan, or run ` + "`codag disable`" + ` to remove codag MCP tools from
94+
their agents.
9395
- The wrapped child's exit code is propagated, so wrapping a failing command
9496
still surfaces the failure.
9597

cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
)
1414

1515
var (
16-
Version = "0.1.8"
16+
Version = "0.1.12"
1717
Commit = "none"
1818
BuildDate = "unknown"
1919
)

cmd/telemetry.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ func addTelemetryError(props map[string]any, err error) {
2525
code = "unauthenticated"
2626
case errors.Is(err, api.ErrQuotaExceeded):
2727
code = "quota_exceeded"
28+
case errors.Is(err, api.ErrOverloaded):
29+
code = "overloaded"
2830
case errors.As(err, &httpErr):
2931
code = "http_error"
3032
props["http_status"] = httpErr.Status

cmd/telemetry_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ func TestAddTelemetryErrorClassifiesKnownSentinels(t *testing.T) {
4848
if got := props["error_code"]; got != "quota_exceeded" {
4949
t.Fatalf("error_code = %v, want quota_exceeded", got)
5050
}
51+
52+
props = map[string]any{}
53+
addTelemetryError(props, fmt.Errorf("wrapped: %w", api.ErrOverloaded))
54+
55+
if got := props["error_code"]; got != "overloaded" {
56+
t.Fatalf("error_code = %v, want overloaded", got)
57+
}
5158
}
5259

5360
func TestAddTelemetryErrorCapturesChildExitCode(t *testing.T) {

cmd/wrap.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/codag-megalith/codag-cli/internal/analytics"
1414
"github.com/codag-megalith/codag-cli/internal/api"
15+
"github.com/codag-megalith/codag-cli/internal/config"
1516
childexec "github.com/codag-megalith/codag-cli/internal/exec"
1617
"github.com/codag-megalith/codag-cli/internal/source"
1718
"github.com/spf13/cobra"
@@ -101,6 +102,16 @@ func wrapAuthRequiredError(mode string) error {
101102
return fmt.Errorf("--mode %s needs a signed-in Codag account.\n\nRun:\n codag auth login\n\nThen retry:\n codag wrap --mode %s -- <your log command>\n\nIf you only need the compact summary, omit --mode %s; default compact mode works without login:\n codag wrap -- <your log command>", mode, mode, mode)
102103
}
103104

105+
func wrapProRequiredError(mode string, upgradePath string) error {
106+
if upgradePath == "" {
107+
upgradePath = "/dashboard/billing"
108+
}
109+
if !strings.HasPrefix(upgradePath, "http://") && !strings.HasPrefix(upgradePath, "https://") {
110+
upgradePath = strings.TrimRight(config.ResolveConsole(), "/") + "/" + strings.TrimLeft(upgradePath, "/")
111+
}
112+
return fmt.Errorf("--mode %s needs Codag Pro for this workspace.\n\nOpen Billing:\n %s\n\nThen retry:\n codag wrap --mode %s -- <your log command>\n\nIf you only need the compact summary, omit --mode %s; default compact mode uses Free or Pro based on the workspace plan:\n codag wrap -- <your log command>", mode, upgradePath, mode, mode)
113+
}
114+
104115
func init() {
105116
wrapCmd.Flags().String("service", "", "Service tag for these lines")
106117
wrapCmd.Flags().String("source", "", "Where the logs came from (auto-detected from the wrapped command; e.g. railway, vercel, kubectl). Override to label explicitly.")
@@ -232,6 +243,10 @@ func runWrap(cmd *cobra.Command, args []string) error {
232243

233244
if apiErr != nil {
234245
analytics.Capture("cli_wrap_failed", wrapEventProps(mode, hadChild, inToks, 0, time.Since(wrapStart), len(lines), apiErr))
246+
var billingErr *api.BillingError
247+
if errors.As(apiErr, &billingErr) && billingErr.Detail == "pro_required" {
248+
return wrapProRequiredError(mode, billingErr.UpgradePath)
249+
}
235250
if errors.Is(apiErr, api.ErrUnauthenticated) {
236251
switch mode {
237252
case "capsule":
@@ -279,6 +294,7 @@ func wrapEventProps(mode string, hadChild bool, inToks, outToks int, dur time.Du
279294
props := map[string]any{
280295
"mode": mode,
281296
"input_kind": "stdin",
297+
"line_count": lineCount,
282298
"input_lines": lineCount,
283299
"input_tokens": inToks,
284300
"duration_ms": dur.Milliseconds(),

cmd/wrap_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"strings"
55
"testing"
6+
"time"
67
)
78

89
func TestWrapNoInputErrorTeachesHappyPath(t *testing.T) {
@@ -36,6 +37,21 @@ func TestWrapAuthRequiredErrorOffersCompactFallback(t *testing.T) {
3637
}
3738
}
3839

40+
func TestWrapProRequiredErrorLinksBilling(t *testing.T) {
41+
t.Setenv("CODAG_CONSOLE", "https://console.example.test")
42+
msg := wrapProRequiredError("parse", "/dashboard/billing").Error()
43+
for _, want := range []string{
44+
"--mode parse needs Codag Pro",
45+
"https://console.example.test/dashboard/billing",
46+
"codag wrap --mode parse -- <your log command>",
47+
"default compact mode uses Free or Pro",
48+
} {
49+
if !strings.Contains(msg, want) {
50+
t.Fatalf("pro error missing %q in:\n%s", want, msg)
51+
}
52+
}
53+
}
54+
3955
func TestWrapHelpDistinguishesCompactAndCapsule(t *testing.T) {
4056
if strings.Contains(wrapCmd.Short, "incident capsule") {
4157
t.Fatalf("wrap short help still says incident capsule: %q", wrapCmd.Short)
@@ -51,3 +67,16 @@ func TestWrapHelpDistinguishesCompactAndCapsule(t *testing.T) {
5167
}
5268
}
5369
}
70+
71+
func TestWrapEventPropsIncludesCanonicalLineCount(t *testing.T) {
72+
props := wrapEventProps("compact", true, 100, 25, 50*time.Millisecond, 7, nil)
73+
if props["line_count"] != 7 {
74+
t.Fatalf("line_count = %v, want 7", props["line_count"])
75+
}
76+
if props["input_lines"] != 7 {
77+
t.Fatalf("input_lines = %v, want 7", props["input_lines"])
78+
}
79+
if props["input_kind"] != "child" {
80+
t.Fatalf("input_kind = %v, want child", props["input_kind"])
81+
}
82+
}

internal/api/client.go

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ var ErrUnauthenticated = errors.New("not signed in (or refresh failed); run `cod
2929
// quota count) is preserved for users who need the exact number.
3030
var ErrQuotaExceeded = errors.New("warm quota exceeded for today")
3131

32+
// ErrOverloaded is returned when Codag is temporarily unable to accept more
33+
// synchronous work. This is retryable and distinct from billing/quota.
34+
var ErrOverloaded = errors.New("codag is busy; retry later")
35+
3236
// ErrBillingRequired is returned when the server blocks a metered call
3337
// because the org needs a billing action: out of included credits,
3438
// subscription past due, etc. Callers should surface a clear upgrade path
@@ -43,6 +47,34 @@ type BillingError struct {
4347
Body string
4448
}
4549

50+
// RateLimitError carries the machine-readable detail and Retry-After hint from
51+
// a 429 that is not a warm quota exhaustion.
52+
type RateLimitError struct {
53+
Detail string
54+
RetryAfter string
55+
Body string
56+
}
57+
58+
func (e *RateLimitError) Error() string {
59+
if e == nil {
60+
return ErrOverloaded.Error()
61+
}
62+
if e.RetryAfter != "" {
63+
return fmt.Sprintf("%s: retry after %s seconds", ErrOverloaded, e.RetryAfter)
64+
}
65+
if e.Detail != "" {
66+
return fmt.Sprintf("%s: %s", ErrOverloaded, e.Detail)
67+
}
68+
if e.Body != "" {
69+
return fmt.Sprintf("%s: %s", ErrOverloaded, e.Body)
70+
}
71+
return ErrOverloaded.Error()
72+
}
73+
74+
func (e *RateLimitError) Unwrap() error {
75+
return ErrOverloaded
76+
}
77+
4678
func (e *BillingError) Error() string {
4779
if e == nil {
4880
return ErrBillingRequired.Error()
@@ -135,8 +167,11 @@ type CapsuleResponse struct {
135167
}
136168

137169
type CompactResponse struct {
138-
Text string `json:"text"`
139-
Stats Stats `json:"stats"`
170+
Text string `json:"text"`
171+
Stats Stats `json:"stats"`
172+
CompactEngine string `json:"compact_engine,omitempty"`
173+
CompactFallback string `json:"compact_fallback,omitempty"`
174+
CompactError string `json:"compact_error,omitempty"`
140175
}
141176

142177
type AnonymousTokenResponse struct {
@@ -328,8 +363,7 @@ func (c *Client) do(method, path string, body any, out any) error {
328363
}
329364
}
330365
if resp.StatusCode == http.StatusTooManyRequests {
331-
// Wrap with the sentinel so callers can errors.Is(err, ErrQuotaExceeded).
332-
return fmt.Errorf("%w: %s", ErrQuotaExceeded, body)
366+
return rateLimitError(resp, body)
333367
}
334368
return &HTTPError{Status: resp.StatusCode, Body: body}
335369
}
@@ -446,7 +480,7 @@ func decodeHTTPResponse(resp *http.Response, respBody []byte, out any) error {
446480
}
447481
}
448482
if resp.StatusCode == http.StatusTooManyRequests {
449-
return fmt.Errorf("%w: %s", ErrQuotaExceeded, body)
483+
return rateLimitError(resp, body)
450484
}
451485
return &HTTPError{Status: resp.StatusCode, Body: body}
452486
}
@@ -468,6 +502,18 @@ func responseDetail(body string) string {
468502
return body
469503
}
470504

505+
func rateLimitError(resp *http.Response, body string) error {
506+
detail := responseDetail(body)
507+
if strings.Contains(strings.ToLower(detail), "quota") {
508+
return fmt.Errorf("%w: %s", ErrQuotaExceeded, body)
509+
}
510+
return &RateLimitError{
511+
Detail: detail,
512+
RetryAfter: resp.Header.Get("Retry-After"),
513+
Body: body,
514+
}
515+
}
516+
471517
func readBody(r io.Reader, limit int64) ([]byte, error) {
472518
body, err := io.ReadAll(io.LimitReader(r, limit+1))
473519
if err != nil {

0 commit comments

Comments
 (0)