Skip to content
6 changes: 6 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -1929,13 +1929,15 @@ FLAG basecamp auth login --agent type=bool
FLAG basecamp auth login --cache-dir type=string
FLAG basecamp auth login --count type=bool
FLAG basecamp auth login --device-code type=bool
FLAG basecamp auth login --expect-identity type=string
FLAG basecamp auth login --help type=bool
FLAG basecamp auth login --hints type=bool
FLAG basecamp auth login --ids-only type=bool
FLAG basecamp auth login --in type=string
FLAG basecamp auth login --jq type=string
FLAG basecamp auth login --json type=bool
FLAG basecamp auth login --local type=bool
FLAG basecamp auth login --login-hint type=string
FLAG basecamp auth login --markdown type=bool
FLAG basecamp auth login --md type=bool
FLAG basecamp auth login --no-browser type=bool
Expand All @@ -1950,6 +1952,7 @@ FLAG basecamp auth login --stats type=bool
FLAG basecamp auth login --styled type=bool
FLAG basecamp auth login --todolist type=string
FLAG basecamp auth login --verbose type=count
FLAG basecamp auth login --with-token type=bool
FLAG basecamp auth logout --account type=string
FLAG basecamp auth logout --agent type=bool
FLAG basecamp auth logout --cache-dir type=string
Expand Down Expand Up @@ -10660,13 +10663,15 @@ FLAG basecamp login --agent type=bool
FLAG basecamp login --cache-dir type=string
FLAG basecamp login --count type=bool
FLAG basecamp login --device-code type=bool
FLAG basecamp login --expect-identity type=string
FLAG basecamp login --help type=bool
FLAG basecamp login --hints type=bool
FLAG basecamp login --ids-only type=bool
FLAG basecamp login --in type=string
FLAG basecamp login --jq type=string
FLAG basecamp login --json type=bool
FLAG basecamp login --local type=bool
FLAG basecamp login --login-hint type=string
FLAG basecamp login --markdown type=bool
FLAG basecamp login --md type=bool
FLAG basecamp login --no-browser type=bool
Expand All @@ -10681,6 +10686,7 @@ FLAG basecamp login --stats type=bool
FLAG basecamp login --styled type=bool
FLAG basecamp login --todolist type=string
FLAG basecamp login --verbose type=count
FLAG basecamp login --with-token type=bool
FLAG basecamp logout --account type=string
FLAG basecamp logout --agent type=bool
FLAG basecamp logout --cache-dir type=string
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,33 @@ basecamp auth login --scope full # Full read+write access (default; ignored by L
basecamp auth token # Print token for scripts
```

`--expect-identity <id>` makes any login assert who it authenticated as: the
new credential is checked before it is stored, and on a mismatch nothing is
written (a profile's previous credential is untouched) and the command exits
non-zero. `--login-hint <email>` names the account to sign in as on the
device-flow approval page; this build tells you the hint rather than sending
it to the server, so the approval page is not preselected.

### Personal access tokens

A [personal access token](https://app.basecamp.com/my/access_tokens) can be
imported instead of running OAuth — the shape for bots, CI, and any machine
that should never sign in interactively. The token is read from stdin (never
an argument), verified against the server — who it authenticates as, and that
it can reach the profile's account — and only then stored under a named
profile, with whatever expiry the server reports for it:

```bash
op read "op://Vault/Item/credential" | basecamp auth login --with-token -P bot --account 999
op read "op://Vault/Item/credential" | basecamp auth login --with-token -P bot --account 999 --expect-identity 12345 --json
```

`--account` is required when the profile does not exist yet. `--json` returns
an envelope with the profile, account, identity and person, `oauth_type`,
`scope`, and `expires_at` (the expiry the server reports for the token, or
`null` when it reports none). A token has no refresh token, so near a reported
expiry the CLI refuses it and asks for a fresh import.

### Multiple Identities

Use named profiles when the same machine or agent gateway needs more than one Basecamp identity. Each profile has its own stored OAuth credentials and can be selected per command:
Expand All @@ -218,6 +245,12 @@ To use your own OAuth app (e.g., a custom Launchpad integration):

Both `BASECAMP_OAUTH_CLIENT_ID` and `BASECAMP_OAUTH_CLIENT_SECRET` must be set together.

`BASECAMP_OAUTH_ISSUER=https://app.basecamp.com` pins the OAuth authorization
server and skips discovery, so `basecamp auth login` reaches a server that is
serving piloted clients but not yet advertising itself (discovery still 404s).
It is a temporary escape hatch for that dark pilot, not a configuration
surface, and will be removed once the server advertises its metadata.

## AI Agent Integration

`basecamp` works with any AI agent that can run shell commands.
Expand Down
29 changes: 29 additions & 0 deletions e2e/auth.bats
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,35 @@ load test_helper
assert_output_contains "default full"
}

@test "basecamp auth login --help shows --with-token, --expect-identity, and --login-hint" {
run basecamp auth login --help
assert_success
assert_output_contains "--with-token"
assert_output_contains "--expect-identity"
assert_output_contains "--login-hint"
assert_output_contains "op read"
}

@test "basecamp auth login --with-token requires a profile" {
run env -u BASECAMP_PROFILE basecamp auth login --with-token </dev/null
assert_failure
assert_json_value '.error' '--with-token stores the token under a named profile'
assert_json_value '.code' 'usage'
}

@test "basecamp auth login --with-token needs --account to create the profile" {
run basecamp auth login --with-token -P bot </dev/null
assert_failure
assert_json_value '.error' 'Profile "bot" does not exist'
assert_json_value '.code' 'usage'
}

@test "basecamp auth login --with-token rejects --device-code" {
run basecamp auth login --with-token --device-code </dev/null
assert_failure
assert_output_contains "with-token"
}

@test "basecamp auth login rejects --device-code --local" {
run basecamp auth login --device-code --local
assert_failure
Expand Down
33 changes: 24 additions & 9 deletions internal/appctx/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ type App struct {

// Flags holds the global flag values
Flags GlobalFlags

// SDKOptions are the client options SDK was built with, so a second
// client — one verifying a credential that is not stored yet — rides
// the same transport, hooks, and user agent.
SDKOptions []basecamp.ClientOption
}

// GlobalFlags holds values for global CLI flags.
Expand Down Expand Up @@ -126,11 +131,12 @@ func NewApp(cfg *config.Config) *App {
CacheDir: cfg.CacheDir,
CacheEnabled: cfg.CacheEnabled,
}
sdkClient := basecamp.NewClient(sdkCfg, &authAdapter{mgr: authMgr},
sdkOptions := []basecamp.ClientOption{
basecamp.WithHooks(hooks),
basecamp.WithTransport(transport),
basecamp.WithUserAgent(version.UserAgent()+" "+basecamp.DefaultUserAgent),
)
basecamp.WithUserAgent(version.UserAgent() + " " + basecamp.DefaultUserAgent),
}
sdkClient := basecamp.NewClient(sdkCfg, &authAdapter{mgr: authMgr}, sdkOptions...)

// Create name resolver using SDK client and account ID
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)
Expand All @@ -147,19 +153,28 @@ func NewApp(cfg *config.Config) *App {
}

return &App{
Config: cfg,
Auth: authMgr,
SDK: sdkClient,
Names: nameResolver,
Collector: collector,
Hooks: cliHooks,
Config: cfg,
Auth: authMgr,
SDK: sdkClient,
SDKOptions: sdkOptions,
Names: nameResolver,
Collector: collector,
Hooks: cliHooks,
Output: output.New(output.Options{
Format: format,
Writer: os.Stdout,
}),
}
}

// SDKClientFor returns a client configured like SDK but authenticating with
// the given provider instead of the stored credential — the way to exercise
// a token before deciding whether to keep it.
func (a *App) SDKClientFor(provider basecamp.TokenProvider) *basecamp.Client {
cfg := a.SDK.Config()
return basecamp.NewClient(&cfg, provider, a.SDKOptions...)
Comment on lines +173 to +175
}

// ApplyFlags applies global flag values to the app configuration.
func (a *App) ApplyFlags() {
// Apply output format from flags (order matters: specific modes first)
Expand Down
Loading
Loading