Skip to content

Commit 8de7536

Browse files
authored
feat(cli): add verbose diagnostics mode (#68)
## Summary - Adds `kontext start --verbose` and `KONTEXT_DEBUG=1` as equivalent opt-in diagnostics modes. - Adds shared diagnostic helpers with redaction before any raw details reach stderr. - Keeps default output focused on human summaries. ## Why Normal runs should be quiet, but debugging still needs enough detail to be useful. This gives developers one explicit verbose path and keeps tokens, auth codes, headers, cookies, and OAuth URLs out of terminal output. ## Before / After Terminal Capture Before, raw details had no common redaction/output path: ```text ⚠ Could not create hosted connect session (connect session request failed: 502 Bad Gateway: ...) ``` After, normal output stays concise and diagnostics are opt-in: ```text ⚠ Could not create hosted connect session. ``` With `--verbose` or `KONTEXT_DEBUG=1`, sensitive values are redacted: ```text hosted connect session error: token exchange failed: Authorization: Bearer [REDACTED] code=[REDACTED] ``` ## Verification - Ran `go test ./...` on this branch.
1 parent c40cbeb commit 8de7536

7 files changed

Lines changed: 208 additions & 17 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ The first run opens your browser for login and provider connection. After that,
4848

4949
Prefer a direct binary? Download the latest build from [GitHub Releases](https://github.com/kontext-security/kontext-cli/releases).
5050

51+
Need more detail while debugging startup? Run `kontext start --verbose` or set `KONTEXT_DEBUG=1` to print redacted diagnostics to stderr.
52+
5153
## Managed Credentials
5254

5355
The CLI creates `.env.kontext` locally on first run:

‎cmd/kontext/main.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ func startCmd() *cobra.Command {
4646
var (
4747
agentName string
4848
templateFile string
49+
verbose bool
4950
)
5051

5152
cmd := &cobra.Command{
@@ -59,6 +60,7 @@ func startCmd() *cobra.Command {
5960
TemplateFile: templateFile,
6061
IssuerURL: auth.DefaultIssuerURL,
6162
ClientID: auth.DefaultClientID,
63+
Verbose: verbose,
6264
Args: args,
6365
})
6466
if exitErr, ok := err.(*run.AgentExitError); ok {
@@ -71,6 +73,7 @@ func startCmd() *cobra.Command {
7173

7274
cmd.Flags().StringVar(&agentName, "agent", "claude", "Agent to launch (currently: claude)")
7375
cmd.Flags().StringVar(&templateFile, "env-template", ".env.kontext", "Path to env template file")
76+
cmd.Flags().BoolVar(&verbose, "verbose", false, "Show redacted diagnostic output")
7477

7578
return cmd
7679
}

‎cmd/kontext/main_test.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ func TestLogoutCmdSuccess(t *testing.T) {
2929
}
3030
}
3131

32+
func TestStartCmdHasVerboseFlag(t *testing.T) {
33+
cmd := startCmd()
34+
flag := cmd.Flags().Lookup("verbose")
35+
if flag == nil {
36+
t.Fatal("start command missing --verbose flag")
37+
}
38+
if flag.DefValue != "false" {
39+
t.Fatalf("--verbose default = %q, want false", flag.DefValue)
40+
}
41+
}
42+
3243
func TestLogoutCmdAlreadyLoggedOut(t *testing.T) {
3344
cmd := newLogoutCmd(func() error { return keyring.ErrNotFound })
3445

‎internal/diagnostic/diagnostic.go‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package diagnostic
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"regexp"
8+
"strings"
9+
)
10+
11+
// EnabledFromEnv reports whether diagnostic output was requested by env.
12+
func EnabledFromEnv() bool {
13+
return os.Getenv("KONTEXT_DEBUG") == "1"
14+
}
15+
16+
// Logger writes human diagnostics only when verbose output is enabled.
17+
type Logger struct {
18+
out io.Writer
19+
enabled bool
20+
}
21+
22+
func New(out io.Writer, enabled bool) Logger {
23+
return Logger{out: out, enabled: enabled}
24+
}
25+
26+
func (l Logger) Enabled() bool {
27+
return l.enabled
28+
}
29+
30+
func (l Logger) Printf(format string, args ...any) {
31+
if !l.enabled || l.out == nil {
32+
return
33+
}
34+
fmt.Fprint(l.out, Redact(fmt.Sprintf(format, args...)))
35+
}
36+
37+
var secretPatterns = []*regexp.Regexp{
38+
regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._~+/=-]+`),
39+
regexp.MustCompile(`(?i)(access_token|id_token|refresh_token|authorization|cookie)=([^&\s]+)`),
40+
regexp.MustCompile(`(?i)(code|token)=([^&\s]+)`),
41+
}
42+
43+
var jsonSecretPattern = regexp.MustCompile(`(?i)("(?:access_token|id_token|refresh_token|authorization|cookie|code|token)"\s*:\s*")([^"]+)(")`)
44+
45+
// Redact removes credential-shaped values before diagnostics reach stderr.
46+
func Redact(input string) string {
47+
output := jsonSecretPattern.ReplaceAllString(input, `${1}[REDACTED]${3}`)
48+
for _, pattern := range secretPatterns {
49+
output = pattern.ReplaceAllStringFunc(output, func(match string) string {
50+
if len(match) >= 6 && strings.EqualFold(match[:6], "Bearer") {
51+
return "Bearer [REDACTED]"
52+
}
53+
parts := pattern.FindStringSubmatch(match)
54+
if len(parts) >= 2 {
55+
return parts[1] + "=[REDACTED]"
56+
}
57+
return "[REDACTED]"
58+
})
59+
}
60+
return output
61+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package diagnostic
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestEnabledFromEnvRequiresOne(t *testing.T) {
10+
t.Setenv("KONTEXT_DEBUG", "1")
11+
if !EnabledFromEnv() {
12+
t.Fatal("EnabledFromEnv() = false, want true")
13+
}
14+
15+
t.Setenv("KONTEXT_DEBUG", "true")
16+
if EnabledFromEnv() {
17+
t.Fatal("EnabledFromEnv() = true, want false")
18+
}
19+
}
20+
21+
func TestLoggerWritesRedactedDiagnosticsOnlyWhenEnabled(t *testing.T) {
22+
var output bytes.Buffer
23+
logger := New(&output, false)
24+
logger.Printf("Authorization: Bearer secret-token")
25+
if output.String() != "" {
26+
t.Fatalf("disabled logger output = %q, want empty", output.String())
27+
}
28+
29+
logger = New(&output, true)
30+
logger.Printf("Authorization: Bearer secret-token code=secret-code")
31+
got := output.String()
32+
if strings.Contains(got, "secret-token") || strings.Contains(got, "secret-code") {
33+
t.Fatalf("diagnostic output leaked secret: %q", got)
34+
}
35+
if !strings.Contains(got, "Bearer [REDACTED]") || !strings.Contains(got, "code=[REDACTED]") {
36+
t.Fatalf("diagnostic output = %q, want redacted markers", got)
37+
}
38+
}
39+
40+
func TestLoggerRedactsJSONSecrets(t *testing.T) {
41+
var output bytes.Buffer
42+
logger := New(&output, true)
43+
44+
logger.Printf(`{"access_token":"secret-token","code":"secret-code","message":"keep"}`)
45+
got := output.String()
46+
if strings.Contains(got, "secret-token") || strings.Contains(got, "secret-code") {
47+
t.Fatalf("diagnostic output leaked JSON secret: %q", got)
48+
}
49+
if !strings.Contains(got, `"access_token":"[REDACTED]"`) || !strings.Contains(got, `"code":"[REDACTED]"`) {
50+
t.Fatalf("diagnostic output = %q, want JSON redaction markers", got)
51+
}
52+
if !strings.Contains(got, `"message":"keep"`) {
53+
t.Fatalf("diagnostic output = %q, want non-secret fields preserved", got)
54+
}
55+
}

‎internal/run/run.go‎

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/kontext-security/kontext-cli/internal/auth"
2929
"github.com/kontext-security/kontext-cli/internal/backend"
3030
"github.com/kontext-security/kontext-cli/internal/credential"
31+
"github.com/kontext-security/kontext-cli/internal/diagnostic"
3132
"github.com/kontext-security/kontext-cli/internal/sidecar"
3233
)
3334

@@ -37,15 +38,20 @@ type Options struct {
3738
TemplateFile string
3839
IssuerURL string
3940
ClientID string
41+
Verbose bool
4042
Args []string
4143
}
4244

4345
// Start is the main entry point for `kontext start`.
4446
func Start(ctx context.Context, opts Options) error {
47+
diagnostics := diagnostic.New(os.Stderr, opts.Verbose || diagnostic.EnabledFromEnv())
48+
diagnostics.Printf("start: agent=%s env_template=%s\n", opts.Agent, opts.TemplateFile)
49+
4550
agentPath, err := preflightAgent(opts.Agent)
4651
if err != nil {
4752
return err
4853
}
54+
diagnostics.Printf("agent preflight: %s -> %s\n", opts.Agent, agentPath)
4955

5056
// 1. Auth
5157
session, err := ensureSession(ctx, opts.IssuerURL, opts.ClientID)
@@ -63,7 +69,7 @@ func Start(ctx context.Context, opts Options) error {
6369
}
6470

6571
// 2. Backend client — token source refreshes automatically on expiry
66-
client := backend.NewClient(backend.BaseURL(), newSessionTokenSource(ctx, session))
72+
client := backend.NewClient(backend.BaseURL(), newSessionTokenSource(ctx, session, diagnostics))
6773

6874
// 3. Create session via ConnectRPC
6975
hostname, _ := os.Hostname()
@@ -114,7 +120,8 @@ func Start(ctx context.Context, opts Options) error {
114120

115121
var templateDoc *credential.TemplateFile
116122
if bootstrapErr != nil {
117-
fmt.Fprintf(os.Stderr, "⚠ Provider sync skipped (%v)\n", bootstrapErr)
123+
diagnostics.Printf("provider sync skipped: %v\n", bootstrapErr)
124+
fmt.Fprintln(os.Stderr, "⚠ Provider sync skipped; using the local env template.")
118125
templateDoc, err = credential.LoadTemplateFile(opts.TemplateFile)
119126
if err != nil {
120127
return fmt.Errorf("load env template: %w", err)
@@ -177,7 +184,7 @@ func Start(ctx context.Context, opts Options) error {
177184
// so reading session fields is safe without synchronization)
178185
var resolved []credential.Resolved
179186
if len(templateDoc.Entries) > 0 {
180-
resolved, err = resolveCredentials(ctx, session, templateDoc.Entries, credentialClientID)
187+
resolved, err = resolveCredentials(ctx, session, templateDoc.Entries, credentialClientID, diagnostics)
181188
if err != nil {
182189
return err
183190
}
@@ -266,7 +273,7 @@ func ensureSession(ctx context.Context, issuerURL, clientID string) (*auth.Sessi
266273
}
267274

268275
// resolveCredentials exchanges each template entry for a live credential.
269-
func resolveCredentials(ctx context.Context, session *auth.Session, entries []credential.Entry, credentialClientID string) ([]credential.Resolved, error) {
276+
func resolveCredentials(ctx context.Context, session *auth.Session, entries []credential.Entry, credentialClientID string, diagnostics diagnostic.Logger) ([]credential.Resolved, error) {
270277
fmt.Fprintln(os.Stderr, "\nResolving credentials...")
271278
resolved := make([]credential.Resolved, 0, len(entries))
272279
failures := make(map[string]error)
@@ -277,7 +284,8 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr
277284
fmt.Fprintf(os.Stderr, " %s (%s)... ", entry.EnvVar, entry.Target())
278285
value, err := exchangeCredential(ctx, session, entry, credentialClientID)
279286
if err != nil {
280-
fmt.Fprintf(os.Stderr, "⚠ skipped (%v)\n", err)
287+
diagnostics.Printf("credential %s exchange failed: %v\n", entry.EnvVar, err)
288+
fmt.Fprintf(os.Stderr, "⚠ skipped (%s)\n", credentialFailureSummary(err))
281289
failures[entry.EnvVar] = err
282290
continue
283291
}
@@ -287,7 +295,7 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr
287295

288296
connectable := unresolvedConnectableEntries(entryByEnvVar, failures)
289297
if len(connectable) == 0 {
290-
printLaunchWarnings(entryByEnvVar, failures)
298+
printLaunchWarnings(entryByEnvVar, failures, diagnostics)
291299
return resolved, nil
292300
}
293301

@@ -300,11 +308,12 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr
300308
auth.Login,
301309
)
302310
if connectErr != nil {
311+
diagnostics.Printf("hosted connect session failed: %v\n", connectErr)
303312
if !interactive && needsGatewayAccessReauthentication(connectErr) {
304313
fmt.Fprintln(os.Stderr, "⚠ Non-interactive session detected. Re-run `kontext start` in an interactive terminal to authorize hosted connect.")
305314
}
306-
fmt.Fprintf(os.Stderr, "⚠ Could not create hosted connect session (%v)\n", connectErr)
307-
printLaunchWarnings(entryByEnvVar, failures)
315+
fmt.Fprintf(os.Stderr, "⚠ Could not create hosted connect session (%s)\n", connectFailureSummary(connectErr))
316+
printLaunchWarnings(entryByEnvVar, failures, diagnostics)
308317
return resolved, nil
309318
}
310319

@@ -314,13 +323,14 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr
314323

315324
if !interactive {
316325
fmt.Fprintln(os.Stderr, "⚠ Non-interactive session detected. Open this URL in a browser, then rerun `kontext start`.")
317-
printLaunchWarnings(entryByEnvVar, failures)
326+
printLaunchWarnings(entryByEnvVar, failures, diagnostics)
318327
return resolved, nil
319328
}
320329

321330
fmt.Fprintf(os.Stderr, " Opening browser to connect %s...\n", providerList)
322331
if err := browser.OpenURL(connectURL); err != nil {
323-
fmt.Fprintf(os.Stderr, " ⚠ Could not open browser automatically (%v)\n", err)
332+
diagnostics.Printf("hosted connect browser open failed: %v\n", err)
333+
fmt.Fprintln(os.Stderr, " ⚠ Could not open browser automatically.")
324334
fmt.Fprintln(os.Stderr, " Open the URL above to continue.")
325335
}
326336
fmt.Fprint(os.Stderr, " Press Enter after connecting...")
@@ -331,6 +341,7 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr
331341
session,
332342
connectable,
333343
credentialClientID,
344+
diagnostics,
334345
)
335346
resolved = append(resolved, retriedResolved...)
336347
for _, entry := range connectable {
@@ -341,7 +352,7 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr
341352
delete(failures, entry.EnvVar)
342353
}
343354

344-
printLaunchWarnings(entryByEnvVar, failures)
355+
printLaunchWarnings(entryByEnvVar, failures, diagnostics)
345356
return resolved, nil
346357
}
347358

@@ -369,6 +380,7 @@ func retryConnectableCredentials(
369380
session *auth.Session,
370381
entries []credential.Entry,
371382
credentialClientID string,
383+
diagnostics diagnostic.Logger,
372384
) ([]credential.Resolved, map[string]error) {
373385
attemptDelays := []time.Duration{0, 3 * time.Second, 7 * time.Second}
374386
pending := make(map[string]credential.Entry, len(entries))
@@ -396,7 +408,8 @@ func retryConnectableCredentials(
396408
)
397409
value, err := exchangeCredential(ctx, session, entry, credentialClientID)
398410
if err != nil {
399-
fmt.Fprintf(os.Stderr, "⚠ skipped (%v)\n", err)
411+
diagnostics.Printf("credential %s retry failed: %v\n", entry.EnvVar, err)
412+
fmt.Fprintf(os.Stderr, "⚠ skipped (%s)\n", credentialFailureSummary(err))
400413
failures[envVar] = err
401414
continue
402415
}
@@ -410,7 +423,33 @@ func retryConnectableCredentials(
410423
return resolved, failures
411424
}
412425

413-
func printLaunchWarnings(entryByEnvVar map[string]credential.Entry, failures map[string]error) {
426+
func credentialFailureSummary(err error) string {
427+
var resolutionErr *credentialResolutionError
428+
if errors.As(err, &resolutionErr) {
429+
switch resolutionErr.Reason {
430+
case failureDisconnected:
431+
return "provider needs connection"
432+
case failureNotAttached:
433+
return "provider is not attached"
434+
case failureUnknown:
435+
return "unknown provider"
436+
case failureInvalid:
437+
return "invalid placeholder"
438+
case failureTransient:
439+
return "temporary exchange error"
440+
}
441+
}
442+
return "run with --verbose for details"
443+
}
444+
445+
func connectFailureSummary(err error) string {
446+
if needsGatewayAccessReauthentication(err) {
447+
return "gateway access needs authorization"
448+
}
449+
return "run with --verbose for details"
450+
}
451+
452+
func printLaunchWarnings(entryByEnvVar map[string]credential.Entry, failures map[string]error, diagnostics diagnostic.Logger) {
414453
if len(failures) == 0 {
415454
return
416455
}
@@ -445,12 +484,14 @@ func printLaunchWarnings(entryByEnvVar map[string]credential.Entry, failures map
445484
)
446485
skipped = append(skipped, entry.Provider)
447486
default:
448-
fmt.Fprintf(os.Stderr, "⚠ %s was skipped (%v)\n", entry.EnvVar, err)
487+
diagnostics.Printf("credential %s skipped: %v\n", entry.EnvVar, err)
488+
fmt.Fprintf(os.Stderr, "⚠ %s was skipped (%s)\n", entry.EnvVar, credentialFailureSummary(err))
449489
}
450490
continue
451491
}
452492

453-
fmt.Fprintf(os.Stderr, "⚠ %s was skipped (%v)\n", entry.EnvVar, err)
493+
diagnostics.Printf("credential %s skipped: %v\n", entry.EnvVar, err)
494+
fmt.Fprintf(os.Stderr, "⚠ %s was skipped (%s)\n", entry.EnvVar, credentialFailureSummary(err))
454495
}
455496

456497
if len(skipped) > 0 {
@@ -794,7 +835,7 @@ func supportedAgents() []string {
794835

795836
// newSessionTokenSource returns a TokenSource that transparently refreshes
796837
// the OIDC access token when it expires, so long-running sessions keep working.
797-
func newSessionTokenSource(ctx context.Context, session *auth.Session) backend.TokenSource {
838+
func newSessionTokenSource(ctx context.Context, session *auth.Session, diagnostics diagnostic.Logger) backend.TokenSource {
798839
mu := &sync.Mutex{}
799840
return func() (string, error) {
800841
mu.Lock()
@@ -811,7 +852,8 @@ func newSessionTokenSource(ctx context.Context, session *auth.Session) backend.T
811852

812853
// Persist so other processes (and the next `kontext start`) see the new token
813854
if saveErr := auth.SaveSession(refreshed); saveErr != nil {
814-
fmt.Fprintf(os.Stderr, "⚠ Could not persist refreshed session: %v\n", saveErr)
855+
diagnostics.Printf("persist refreshed session failed: %v\n", saveErr)
856+
fmt.Fprintln(os.Stderr, "⚠ Could not persist refreshed session.")
815857
}
816858

817859
// Update the shared session pointer for subsequent calls

0 commit comments

Comments
 (0)