Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ jobs:
./dist/chunk skill install
echo "OK: skill content embedded correctly"
head -5 ~/.claude/skills/chunk-review/SKILL.md
- run:
name: Cross-compile for all release targets
command: |
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
GOOS=${target%/*} GOARCH=${target#*/} CGO_ENABLED=0 go build -o /dev/null . \
&& echo "OK: $target" \
|| { echo "FAIL: $target"; exit 1; }
done

workflows:
ci:
Expand Down
2 changes: 1 addition & 1 deletion .circleci/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- run:
name: Smoke test
command: |
go build -o ~/chunk .
CGO_ENABLED=0 go build -o ~/chunk .
~/chunk --version
- run: task release VERSION=<< pipeline.parameters.version >>
- persist_to_workspace:
Expand Down
38 changes: 5 additions & 33 deletions acceptance/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,35 +147,6 @@ func TestAuthStatusMaskExactlyFourChars(t *testing.T) {
"expected chars 5-8 from end to be masked, got: %s", combined)
}

// auth status reads key from config file when no env var is set
func TestAuthStatusFromConfigFile(t *testing.T) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to revert these if people think the fallback resolution tests are of dire importance. But a) we want to ditch config file as even an option and b) the keychain dep is harder to test and the code around it is simple.

anthropic := fakes.NewFakeAnthropic()
srv := httptest.NewServer(anthropic)
defer srv.Close()

env := testenv.NewTestEnv(t)
env.AnthropicURL = srv.URL
env.AnthropicKey = "" // no env var
env.CircleToken = "" // Anthropic-only test
env.GithubToken = ""

// Store key in config file
t.Setenv("XDG_CONFIG_HOME", filepath.Join(env.HomeDir, ".config"))
assert.NilError(t, config.Save(config.UserConfig{AnthropicAPIKey: "sk-ant-config-only-XYZW"}))

result := binary.RunCLI(t, []string{"auth", "status"}, env, env.HomeDir)

assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr)
combined := result.Stdout + result.Stderr
assert.Assert(t, strings.Contains(combined, "Config file"),
"expected config file source, got: %s", combined)
assert.Assert(t, strings.Contains(combined, "Valid"),
"expected key valid message, got: %s", combined)
// Last 4 chars visible
assert.Assert(t, strings.Contains(combined, "XYZW"),
"expected last 4 chars of key, got: %s", combined)
}

// auth status validates via /v1/messages/count_tokens, not /v1/messages
func TestAuthStatusUsesCountTokensEndpoint(t *testing.T) {
anthropic := fakes.NewFakeAnthropic()
Expand Down Expand Up @@ -293,10 +264,11 @@ func TestAuthRemoveWithStoredKey(t *testing.T) {
"expected removal prompt or --force suggestion, got: %s", combined)
assert.Assert(t, strings.Contains(combined, env.HomeDir), "expected config path in output, got: %s", combined)

// Key should not have been removed — cancelled remove leaves config intact.
showResult := binary.RunCLI(t, []string{"config", "show"}, env, env.HomeDir)
assert.Equal(t, showResult.ExitCode, 0, "config show failed after cancelled remove: %s", showResult.Stderr)
assert.Assert(t, strings.Contains(showResult.Stdout, "1234"), "expected stored key (masked) in config output, got: %s", showResult.Stdout)
// Key should not have been removed — load config directly to avoid env-var override in Resolve.
cfg, err := config.Load()
assert.NilError(t, err, "config.Load failed after cancelled remove")
assert.Assert(t, strings.Contains(cfg.AnthropicAPIKey, "stored-key-1234"),
"expected stored key to be intact after cancelled remove, got: %q", cfg.AnthropicAPIKey)
}

// auth remove with both env var and config key.
Expand Down
55 changes: 0 additions & 55 deletions acceptance/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,25 +56,6 @@ func TestConfigShowMasksLastFourChars(t *testing.T) {
"expected first chars of API key to be masked, got: %s", combined)
}

// API key stored in config file (no env var) is resolved and shown
func TestConfigShowFromConfigFile(t *testing.T) {
env := testenv.NewTestEnv(t)
env.AnthropicKey = "" // no env var

t.Setenv("XDG_CONFIG_HOME", filepath.Join(env.HomeDir, ".config"))
err := config.Save(config.UserConfig{AnthropicAPIKey: "sk-ant-stored-key-ZZZZ"})
assert.NilError(t, err)

result := binary.RunCLI(t, []string{"config", "show"}, env, env.HomeDir)
assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr)

combined := result.Stdout + result.Stderr
assert.Check(t, cmp.Contains(combined, "user config"),
"expected apiKey source to be 'user config'")
assert.Check(t, cmp.Contains(combined, "ZZZZ"),
"expected last 4 chars of stored key visible")
}

// config show must not display analyzeModel or promptModel
func TestConfigShowNoModelConstants(t *testing.T) {
env := testenv.NewTestEnv(t)
Expand Down Expand Up @@ -201,24 +182,6 @@ func TestConfigShowCircleCITokenEnvPrecedenceOverFile(t *testing.T) {
"expected env var source")
}

func TestConfigShowCircleCITokenFromFile(t *testing.T) {
env := testenv.NewTestEnv(t)
env.CircleToken = "" // no env var

t.Setenv("XDG_CONFIG_HOME", filepath.Join(env.HomeDir, ".config"))
err := config.Save(config.UserConfig{CircleCIToken: "stored-circle-FTOK"})
assert.NilError(t, err)

result := binary.RunCLI(t, []string{"config", "show"}, env, env.HomeDir)
assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr)

combined := result.Stdout + result.Stderr
assert.Check(t, cmp.Contains(combined, "FTOK"),
"expected file token last 4 chars")
assert.Check(t, cmp.Contains(combined, "user config"),
"expected config file source")
}

func TestConfigShowCircleTokenEnvPrecedenceOverCircleCIToken(t *testing.T) {
env := testenv.NewTestEnv(t)
env.CircleToken = "" // clear the default CIRCLE_TOKEN
Expand Down Expand Up @@ -259,24 +222,6 @@ func TestConfigShowGitHubTokenEnvPrecedenceOverFile(t *testing.T) {
"expected env var source")
}

func TestConfigShowGitHubTokenFromFile(t *testing.T) {
env := testenv.NewTestEnv(t)
env.GithubToken = "" // no env var

t.Setenv("XDG_CONFIG_HOME", filepath.Join(env.HomeDir, ".config"))
err := config.Save(config.UserConfig{GitHubToken: "stored-github-GHTK"})
assert.NilError(t, err)

result := binary.RunCLI(t, []string{"config", "show"}, env, env.HomeDir)
assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr)

combined := result.Stdout + result.Stderr
assert.Check(t, cmp.Contains(combined, "GHTK"),
"expected file token last 4 chars")
assert.Check(t, cmp.Contains(combined, "user config"),
"expected config file source")
}

func TestConfigShowModelFileOverDefault(t *testing.T) {
env := testenv.NewTestEnv(t)

Expand Down
28 changes: 0 additions & 28 deletions acceptance/task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -539,34 +539,6 @@ func TestTaskRunStatsFieldWithBranchOverride(t *testing.T) {
assert.Equal(t, stats["checkout_branch"], "feature/custom")
}

func TestTaskRunCircleCITokenFallback(t *testing.T) {
// Verify CIRCLECI_TOKEN works when CIRCLE_TOKEN is empty
cci := fakes.NewFakeCircleCI()
srv := httptest.NewServer(cci)
defer srv.Close()

workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo")
writeRunConfig(t, workDir)

env := testenv.NewTestEnv(t)
env.CircleCIURL = srv.URL
env.CircleToken = "" // clear primary token
env.Extra["CIRCLECI_TOKEN"] = "fallback-circle-token"

result := binary.RunCLI(t, []string{
"task", "run",
"--definition", "dev",
"--prompt", "Test fallback",
}, env, workDir)

assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr)

reqs := cci.Recorder.AllRequests()
runReqs := filterByPathPrefix(reqs, "/api/v2/agents/org/")
assert.Equal(t, len(runReqs), 1)
assert.Equal(t, runReqs[0].Header.Get("Circle-Token"), "fallback-circle-token")
}

func TestTaskRunMissingDefinitionFlag(t *testing.T) {
// Cobra required flag --definition omitted
workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo")
Expand Down
10 changes: 8 additions & 2 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ Complete command reference for the `chunk` CLI.
chunk
├── auth
│ ├── set <provider> # Store credential (circleci | anthropic | github)
│ │ --insecure-storage # Save to config file instead of system keychain
│ │ --force, -f # Overwrite existing credentials without confirmation
│ ├── status # Check authentication status (CircleCI, Anthropic, GitHub)
│ └── remove <provider> # Remove stored credential (circleci | anthropic | github)
│ --force, -f # Skip confirmation prompt
├── build-prompt # Mine PR comments → analyze → generate prompt
│ --org <org> # GitHub org (auto-detected from git remote)
Expand Down Expand Up @@ -127,8 +130,11 @@ chunk
- Commands that require a CircleCI token (`task run`, `task config`, `sidecar *`,
`validate --sidecar-id`) prompt for it inline at the point of need rather than
failing with an error.
- `chunk auth set github` stores a GitHub token in the config file; previously
only the `GITHUB_TOKEN` environment variable was supported.
- `chunk auth set <provider>` saves credentials to the system keychain by default
(macOS Keychain / Linux secret-service). Pass `--insecure-storage` to write to
the config file instead. Use `--force` / `-f` to overwrite without confirmation.
- `chunk auth remove <provider>` removes credentials from both keychain and config
file. Use `--force` / `-f` to skip the confirmation prompt.

## Flag Conventions

Expand Down
2 changes: 1 addition & 1 deletion docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Check status at any time:
chunk auth status
```

Credentials are stored in `~/.config/chunk/config.json` (respects `XDG_CONFIG_HOME`). You can also set them as environment variables:
Credentials are saved to the system keychain (macOS Keychain, Linux secret-service) by default. Add `--insecure-storage` to save to `~/.config/chunk/config.json` instead. You can also set them as environment variables:

| Variable | Used by |
|---|---|
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/sethvargo/go-envconfig v1.3.0
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.51.0
golang.org/x/term v0.43.0
gotest.tools/v3 v3.5.2
Expand Down Expand Up @@ -85,6 +86,7 @@ require (
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
Expand Down Expand Up @@ -115,6 +117,7 @@ require (
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+f
github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88=
github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ=
github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY=
github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc=
github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
Expand Down Expand Up @@ -281,6 +283,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM=
github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
Expand Down Expand Up @@ -696,6 +700,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=
gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8=
go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=
Expand Down
55 changes: 40 additions & 15 deletions internal/authprompt/authprompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/CircleCI-Public/chunk-cli/internal/config"
"github.com/CircleCI-Public/chunk-cli/internal/github"
hc "github.com/CircleCI-Public/chunk-cli/internal/httpcl"
"github.com/CircleCI-Public/chunk-cli/internal/keyring"
"github.com/CircleCI-Public/chunk-cli/internal/version"
)

Expand Down Expand Up @@ -114,43 +115,67 @@ func ResolveGitHubClient(rc config.ResolvedConfig, logStatus func(string)) (*git
})
}

// SaveCircleCIToken persists a CircleCI token to the config file.
func SaveCircleCIToken(token string) error {
// SaveCircleCIToken persists a CircleCI token to the system keychain, or to
// the config file when insecureStorage is true or the keychain is unavailable.
// baseURL is used to scope the keychain entry to the CircleCI host.
// Returns true if saved to the keychain.
func SaveCircleCIToken(token, baseURL string, insecureStorage bool) (bool, error) {
if !insecureStorage {
if err := keyring.Set(keyring.CircleCITokenKey(baseURL), token); err == nil {
return true, nil
}
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
return false, fmt.Errorf("load config: %w", err)
}
cfg.CircleCIToken = token
if err := config.Save(cfg); err != nil {
return fmt.Errorf("save token: %w", err)
return false, fmt.Errorf("save token: %w", err)
}
return nil
return false, nil
}

// SaveAnthropicKey persists an Anthropic API key to the config file.
func SaveAnthropicKey(key string) error {
// SaveAnthropicKey persists an Anthropic API key to the system keychain, or to
// the config file when insecureStorage is true or the keychain is unavailable.
// baseURL is used to scope the keychain entry to the Anthropic host.
// Returns true if saved to the keychain.
func SaveAnthropicKey(key, baseURL string, insecureStorage bool) (bool, error) {
if !insecureStorage {
if err := keyring.Set(keyring.AnthropicKeyKey(baseURL), key); err == nil {
return true, nil
}
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
return false, fmt.Errorf("load config: %w", err)
}
cfg.AnthropicAPIKey = key
if err := config.Save(cfg); err != nil {
return fmt.Errorf("save API key: %w", err)
return false, fmt.Errorf("save API key: %w", err)
}
return nil
return false, nil
}

// SaveGitHubToken persists a GitHub token to the config file.
func SaveGitHubToken(token string) error {
// SaveGitHubToken persists a GitHub token to the system keychain, or to the
// config file when insecureStorage is true or the keychain is unavailable.
// apiURL is used to scope the keychain entry to the GitHub host.
// Returns true if saved to the keychain.
func SaveGitHubToken(token, apiURL string, insecureStorage bool) (bool, error) {
if !insecureStorage {
if err := keyring.Set(keyring.GitHubTokenKey(apiURL), token); err == nil {
return true, nil
}
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
return false, fmt.Errorf("load config: %w", err)
}
cfg.GitHubToken = token
if err := config.Save(cfg); err != nil {
return fmt.Errorf("save token: %w", err)
return false, fmt.Errorf("save token: %w", err)
}
return nil
return false, nil
}

// ValidateGitHubToken calls GET /user to confirm the token is accepted.
Expand Down
Loading