Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:

- uses: actions/setup-go@v5
with:
go-version: "1.23"
go-version-file: go.mod

- run: go build ./...

Expand Down
18 changes: 17 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:

permissions:
contents: write
id-token: write # npm trusted publishing and provenance

jobs:
release:
Expand All @@ -18,7 +19,7 @@ jobs:

- uses: actions/setup-go@v5
with:
go-version: "1.23"
go-version-file: go.mod

- uses: goreleaser/goreleaser-action@v6
with:
Expand All @@ -30,3 +31,18 @@ jobs:
WEBEX_CLIENT_SECRET: ${{ secrets.WEBEX_CLIENT_SECRET }}
WEBEX_SCOPES: ${{ secrets.WEBEX_SCOPES }}
WEBEX_READ_ONLY_SCOPES: ${{ secrets.WEBEX_READ_ONLY_SCOPES }}

- uses: actions/setup-node@v4
with:
node-version: "22"
registry-url: "https://registry.npmjs.org"

# npm uses trusted publishing (OIDC), configured per package on npmjs.com
# for this workflow, and falls back to NPM_TOKEN only when OIDC is not set up.
- name: Publish npm packages
run: |
npm install -g npm@11.6.2
node npm/stage.mjs --dist dist --out npm/build
sh npm/publish.sh npm/build
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Build artifacts
webex
webex-cli
/webex
/webex-cli
*.exe
dist/
webex-mcp.dxt
webex.skill
npm/build/

# Codegen intermediates
codegen/api_outline.json
Expand Down
16 changes: 14 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ Releases are handled by a GitHub Actions workflow triggered by pushing a tag.
Do **not** run `goreleaser` locally — it requires secrets (e.g. `WEBEX_CLIENT_ID`)
that are only available in CI. Version and OAuth defaults are injected via ldflags.

The same workflow publishes the npm packages: `npm/stage.mjs` turns goreleaser's
`dist/` into `@cloverhound/webex-cli` plus one `@cloverhound/webex-cli-<os>-<cpu>`
package per binary, and `npm/publish.sh` publishes them with provenance. It
authenticates with npm trusted publishing (configured per package on npmjs.com
for `release.yml`), or with the `NPM_TOKEN` secret when that is not set up.
Tags with a prerelease suffix (`v0.X.0-rc.1`) publish under the `next` dist-tag.

To check the packaging without secrets, run `goreleaser build --snapshot --clean`
with dummy `WEBEX_CLIENT_*` values, then `node npm/stage.mjs` and `npm pack` the
folders in `npm/build/`.

```bash
git tag v0.X.0
git push origin v0.X.0
Expand All @@ -68,10 +79,11 @@ gh release edit v0.X.0 --title "v0.X.0" --notes "## Improvements

- `cmd/root.go` — Root cobra command, global flags (token, org, output, debug, dry-run, paginate, 429 retries, region)
- `cmd/auth.go` — Auth subcommand (status, list, switch, set-org, clear-org)
- `cmd/login.go` / `cmd/logout.go` — OAuth PKCE flow
- `cmd/login.go` / `cmd/logout.go` — OAuth PKCE and device login
- `cmd/mcp.go` / `cmd/mcp/` — MCP serve/stop, tools, resources, usage log
- `cmd/update.go` / `cmd/postinstall.go` / `cmd/skillupdate.go` — self-update and skill install
- `internal/auth/` — Token storage (OS keyring), OAuth, org resolution
- `internal/auth/` — Token storage (OS keyring, credentials file fallback), OAuth, device login, org resolution
- `npm/` — npm wrapper package, staging, and publish scripts
- `internal/client/` — HTTP client, request builder, pagination, 429 Retry-After
- `internal/appconfig/` — Config file (`~/.webex-cli/config.json`)
- `internal/config/` — Process-local flags (token, org, debug, dry-run, retry)
Expand Down
43 changes: 36 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,21 @@ curl -fsSL https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.
irm https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.ps1 | iex
```

**npm (any platform with Node.js 18+):**
```bash
npm install -g @cloverhound/webex-cli # or: npx -y @cloverhound/webex-cli <command>
```

Or download from [Releases](https://github.com/Cloverhound/webex-cli/releases).

The npm package installs only the prebuilt binary for your platform, with no install script and no GitHub download, so it works in sandboxes that allow only package registries. Update it with npm rather than `webex update`.

The installers verify the download against the release's `checksums.txt` and find the latest version without the GitHub API. For CI or sandboxed agents:

- Pin a version with `WEBEX_CLI_VERSION=0.16.0` (or `sh -s -- -v 0.16.0`); `webex update` honors it too.
- Set `INSTALL_DIR` to install somewhere other than `~/.local/bin` (Windows: `%LOCALAPPDATA%\webex-cli`).
- Without a terminal, the installer does not prompt: it prints the PATH line to add and installs the agent skill for detected agents. Run `webex post-install --yes` to accept the defaults (including editing your shell profile), `--no-skills` to skip skills, or set `WEBEX_CLI_NONINTERACTIVE=1` to turn prompts off.

## Quick Start

```bash
Expand Down Expand Up @@ -128,30 +141,46 @@ See the [full refresh inventory](docs/command-inventory.md), [every renamed comm
## Authentication

- **OAuth PKCE flow** — `webex login` opens a browser, no client secret needed on the user side
- **OS keyring storage** — tokens stored securely in macOS Keychain / Linux keyring / Windows Credential Manager
- **Device login** — `webex login --device` prints a URL and code to approve from any browser; used automatically over SSH, in CI, on Linux without a display, or when no browser opens
- **Token storage** — tokens stored securely in macOS Keychain / Linux keyring / Windows Credential Manager, with a plain-text file fallback when no keyring is available
- **Auto-refresh** — expired tokens are refreshed automatically
- **Multi-user** — log in with multiple Webex accounts and switch between them

```bash
webex login # Login (opens browser)
webex login # Login (opens browser, or device login when headless)
webex login --device # Login by approving a code from any browser
webex login --browser # Force the browser flow
webex login --read-only # Login with read scopes only and turn on read-only mode
webex logout # Remove stored tokens
webex auth status # Show current user and token status
webex auth status # Show current user, token status, and token store
webex auth list # List all authenticated users
webex auth switch <email> # Switch default user
webex auth set-org <org-id> # Set a persistent org override
webex auth clear-org # Clear the org override
webex auth export # Print the refresh token for $WEBEX_REFRESH_TOKEN
```

Token resolution order: `--token` flag > `$WEBEX_TOKEN` env var > OS keyring.
Token resolution order: `--token` flag > `$WEBEX_TOKEN` env var > `$WEBEX_REFRESH_TOKEN` env var > stored login.

### Headless and Unattended Use

Device login needs no local browser: `webex login` shows `Open <url> and enter <code>` (and a QR code on a terminal), and you approve from a browser on any device. With `--output json`, the URL, code, and expiry are printed as JSON on stdout so an agent can relay them. `WEBEX_LOGIN_MODE=device|browser` picks the flow without a flag. A custom integration needs the redirect URIs `https://oauth-helper-{a,r,k,d}.wbx2.com/helperservice/v1/actions/device/callback` for device login.

Without an OS keyring (containers, SSH sessions, CI), tokens are saved in plain text to `$XDG_CONFIG_HOME/webex-cli/credentials.json` (`~/.config/webex-cli/` by default, `%APPDATA%\webex-cli\` on Windows) with mode `0600`. `WEBEX_TOKEN_STORE=keyring|file` forces a store; `webex auth status` shows which one holds the token.

For CI and unattended agents where nobody can approve a code:

- `WEBEX_TOKEN` takes a personal access token (12 hours) for short jobs.
- `WEBEX_REFRESH_TOKEN` takes a refresh token; the CLI mints access tokens from it as needed. Create one by logging in on your laptop and running `webex auth export`. Set `WEBEX_CLIENT_ID` and `WEBEX_CLIENT_SECRET` if the token came from your own integration. Webex refresh tokens last about 90 days from last use. The CLI caches the access token and any rotated refresh token in the credentials file when it is writable; otherwise each process mints a new access token.
- For organization-level automation, prefer a [service app](https://developer.webex.com/docs/service-apps) or a bot token over a person's login.

### Read-only Mode

`webex login --read-only` requests only read scopes, so Webex itself rejects writes made with the token. Use it when an AI agent should read but not change your Webex data. While read-only mode is on:

- Stored logins with write access are deleted from the keyring, because any program running as you can read it.
- Stored logins with write access are deleted from the keyring and credentials file, because any program running as you can read them.
- `PUT`, `PATCH`, `DELETE`, and uploads are refused before they are sent. `POST` is allowed only for a fixed list of query endpoints that take their filters in the request body, such as `cc search` and recording queries.
- `--token` and `$WEBEX_TOKEN` are refused, and `auth switch`, `--user`, and folder defaults accept only users who logged in with `--read-only`.
- `--token`, `$WEBEX_TOKEN`, and `$WEBEX_REFRESH_TOKEN` are refused, and `auth switch`, `--user`, and folder defaults accept only users who logged in with `--read-only`.
- `webex mcp serve` does not register `webex_write`.

Leaving read-only mode requires `webex login` from an interactive terminal, with a confirmation prompt. `WEBEX_READ_ONLY=1` turns the same checks on for one process and never turns them off. If your OAuth integration lacks some of the default read scopes, set your own list with `webex config set read-only-scopes "<scopes>"`.
Expand Down Expand Up @@ -297,7 +326,7 @@ A set of skill files in `skill/` enables AI coding agents (Claude Code, Claude C

Each sub-skill also contains an auto-generated **Command Reference** section (updated by `make codegen`) that lists every command and its flags, keeping documentation in sync with the API as Postman collections change.

`webex post-install` offers to install all skill files automatically. They are also updated by `webex update`.
The skill files are embedded in the binary, so the installed skill always matches the installed version. `webex post-install` offers to install them, and `webex update` updates installed copies to match the new binary.

See the [docs](https://cloverhound.github.io/webex-cli/agent-skill/) for manual setup instructions.

Expand Down
114 changes: 109 additions & 5 deletions cmd/auth.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"errors"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -46,16 +47,39 @@ var authStatusCmd = &cobra.Command{
}

email := cfg.DefaultUser
envSet := false
for _, env := range []string{"WEBEX_TOKEN", auth.RefreshTokenEnv} {

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.

[P3] Report environment authentication before checking the default user

The new environment-status reporting is unreachable when cfg.DefaultUser is empty. That is the normal fresh-container case for WEBEX_REFRESH_TOKEN, so auth status reports “No authenticated user” even though API commands authenticate successfully. Inspect environment sources before the stored-user early return, and distinguish an available environment credential from a verified token without unnecessarily refreshing it.

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.

Valid; fixed in b3be81f. auth status now reports $WEBEX_TOKEN/$WEBEX_REFRESH_TOKEN before checking for a stored default user. With no stored user it says commands authenticate with the env value, and it no longer prints "No authenticated user" in that case. It does not refresh the token just to show status. Regression test: TestAuthStatusReportsEnvTokenWithoutStoredLogin.

if os.Getenv(env) == "" {
continue
}
envSet = true
switch {
case readOnlyMode(cfg):
fmt.Printf("Env: $%s is set; read-only mode refuses it, so commands fail until it is unset\n", env)
case email == "":
fmt.Printf("Env: $%s is set; commands authenticate with it\n", env)
default:
fmt.Printf("Env: $%s is set and overrides the stored login below\n", env)
}
break
}

if email == "" {
fmt.Println("No authenticated user. Run: webex login")
if !envSet {
fmt.Println("No authenticated user. Run: webex login")
}
return nil
}

userInfo := cfg.Users[email]
tok, err := auth.LoadToken(email)
tok, store, err := auth.LoadTokenWithStore(email)
if err != nil {
fmt.Printf("User: %s\n", email)
fmt.Println("Status: token not found in keyring")
fmt.Printf("User: %s\n", email)
if errors.Is(err, auth.ErrTokenNotFound) {
fmt.Println("Status: token not found in the keyring or credentials file")
} else {
fmt.Printf("Status: %v\n", err)
}
return nil
}

Expand All @@ -77,7 +101,11 @@ var authStatusCmd = &cobra.Command{
fmt.Printf("Org override: %s\n", cfg.DefaultOrgID)
}

fmt.Printf("Source: keyring\n")
if store == auth.StoreFile {
fmt.Printf("Store: file, plain text (%s)\n", auth.CredentialsPath())
} else {
fmt.Println("Store: OS keyring")
}

switch {
case cfg.ReadOnly:
Expand Down Expand Up @@ -121,6 +149,80 @@ var authStatusCmd = &cobra.Command{
},
}

var authExportCmd = &cobra.Command{
Use: "export [email]",
Short: "Print a stored refresh token for use as $WEBEX_REFRESH_TOKEN",
Long: `Prints the refresh token of a stored login so a login made on one machine can
seed a CI secret or an unattended agent through $WEBEX_REFRESH_TOKEN. The user
is the email given, else the one --user, $WEBEX_USER, the folder default, or the
default user selects. Anyone holding the token can act as that user until it
expires (about 90 days after its last use), so treat it as a password.

In read-only mode only read-only logins can be exported, and the consumer must
not be in read-only mode, which refuses $WEBEX_REFRESH_TOKEN.

The token is printed alone on stdout. Confirm at the prompt, or pass --yes
when there is no terminal.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
yes, _ := cmd.Flags().GetBool("yes")

cfg, err := appconfig.Load()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
flagUser, envUser := requestedUser(cmd)
email := cfg.DefaultUser
switch {
case len(args) > 0:
email = args[0]
case flagUser != "":
email = flagUser
case envUser != "":
email = envUser
}
if email == "" {
return fmt.Errorf("no authenticated user — run: webex login")
}
tok, err := auth.LoadToken(email)
if err != nil {
return fmt.Errorf("no stored login for %s — run: webex login", email)
}
// The command skips token resolution, which is where read-only mode
// normally rejects write-capable logins.
if readOnlyMode(cfg) && !tok.ReadOnly {
return fmt.Errorf("read-only mode: %s has no read-only login; only read-only logins can be exported", email)
}
if tok.RefreshToken == "" || tok.IsRefreshExpired() {
return fmt.Errorf("the stored login for %s has no usable refresh token — run: webex login", email)
}

if !yes {
if !canPrompt() {
return fmt.Errorf("refusing to print a refresh token without confirmation; pass --yes")
}
ok, err := confirm(fmt.Sprintf("Print the refresh token for %s?", email),
"Anyone with this token can act as you until it expires.",
"Print it", "Cancel")
if err != nil {
return err
}
if !ok {
return fmt.Errorf("cancelled")
}
}

if tok.ClientID != "" && tok.ClientID != appconfig.DefaultClientID {
fmt.Fprintf(os.Stderr, "This login uses its own OAuth integration; also set $%s and $%s.\n",
auth.ClientIDEnv, auth.ClientSecretEnv)
}
fmt.Fprintf(os.Stderr, "Webex may rotate refresh tokens on use. If this machine and the consumer of the export\n"+
"both refresh it, one of them can be signed out; prefer a separate login for each.\n")
fmt.Println(tok.RefreshToken)
return nil
},
}

var authListCmd = &cobra.Command{
Use: "list",
Short: "List all authenticated users",
Expand Down Expand Up @@ -358,6 +460,8 @@ func init() {
authCmd.AddCommand(authTokenCmd)
authCmd.AddCommand(authStatusCmd)
authCmd.AddCommand(authListCmd)
authExportCmd.Flags().Bool("yes", false, "Print without asking for confirmation")
authCmd.AddCommand(authExportCmd)
authCmd.AddCommand(authSwitchCmd)
authCmd.AddCommand(authSetOrgCmd)
authCmd.AddCommand(authClearOrgCmd)
Expand Down
46 changes: 46 additions & 0 deletions cmd/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package cmd

import (
"io"
"os"
"strings"
"testing"

"github.com/Cloverhound/webex-cli/internal/auth"
)

func captureStdout(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
old := os.Stdout
os.Stdout = w
fn()
os.Stdout = old
w.Close()
out, _ := io.ReadAll(r)
return string(out)
}

func TestAuthStatusReportsEnvTokenWithoutStoredLogin(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv(readOnlyEnv, "")
t.Setenv("WEBEX_TOKEN", "")
t.Setenv(auth.RefreshTokenEnv, "rt")

out := captureStdout(t, func() {
if err := authStatusCmd.RunE(authStatusCmd, nil); err != nil {
t.Error(err)
}
})
if !strings.Contains(out, "$"+auth.RefreshTokenEnv+" is set") {
t.Errorf("status output does not mention the env token:\n%s", out)
}
if strings.Contains(out, "No authenticated user") {
t.Errorf("status claims no user although commands authenticate from the env:\n%s", out)
}
}
Loading
Loading