diff --git a/AGENTS.md b/AGENTS.md index 185a26b..eba4f82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Project Structure - `cmd/olk/`: CLI entrypoint — minimal, delegates to `internal/cmd.Execute()`. -- `internal/cmd/`: Command implementations using kong structs. Each command group has its own file(s): `mail_*.go`, `calendar*.go`, `todo.go`, `todo_checklist.go`, `todo_attachments.go`, `todo_links.go`, `whoami.go`, etc. +- `internal/cmd/`: Command implementations using kong structs. Each command group has its own file(s): `mail_*.go`, `calendar*.go`, `todo.go`, `todo_checklist.go`, `todo_attachments.go`, `todo_links.go`, `whoami.go`, etc. `token.go` implements token mode (credential injection) and `root.go` wires it into `RunContext`. - `internal/msauth/`: Microsoft OAuth2 implementation — device code flow, authorization-code + PKCE browser flow (loopback listener), token refresh, credential bridge. - `internal/graphapi/`: Microsoft Graph API wrapper — mail, calendar, contacts, todo (tasks, checklist items, attachments, linked resources), availability, mailbox settings, mail rules, people. Includes the `targetUser` helper that routes reads to `/me` or `/users/{id}` for delegated mailbox access, and error-shaping helpers (`graphErrorMessage`, `enterpriseError`, `scopeUpgradeError`) in `validate.go`. - `internal/config/`: Configuration paths via `os.UserConfigDir` (`~/.config/olk/` on Linux, `~/Library/Application Support/olk/` on macOS, `%AppData%\olk\` on Windows). @@ -34,7 +34,7 @@ ## Testing Guidelines - Unit tests: stdlib `testing` package. Test files go next to the code they test (`*_test.go`). -- Existing coverage: `internal/outfmt` (formatting + untrusted-wrapping), `internal/config`, `internal/msauth/scopes`, `internal/graphapi` (validate + capability guards), and `internal/cmd` (paging filters + mailbox-target validation, MCP server/registry, schema generation, argv building, output capture). Coverage is partial — many command and Graph-wrapper paths remain untested. +- Existing coverage: `internal/outfmt` (formatting + untrusted-wrapping), `internal/config`, `internal/msauth/scopes`, `internal/graphapi` (validate + capability guards), and `internal/cmd` (paging filters + mailbox-target validation, MCP server/registry, schema generation, argv building, output capture, token mode in `token_test.go`). Coverage is partial — many command and Graph-wrapper paths remain untested. - Integration tests require a valid OAuth token + live Graph access — run manually, not in CI. - **macOS validation:** each `make build` produces a fresh (ad-hoc) binary identity, so the first run of the new `./bin/olk` that reads stored tokens triggers a macOS Keychain access prompt — a human must click **"Always Allow"**. An automated/agent run can't dismiss the dialog, so a hang on the first post-build command is expected (not a code bug); surface it for manual approval. - New tests should run cleanly under `go test -race -count=1 ./...` and pass `golangci-lint run`. @@ -44,7 +44,7 @@ - `.github/workflows/ci.yml` runs on every pull request, on push to `main`, and via `workflow_dispatch` (manual trigger — this repo is a fork, and forks start with workflows suppressed). - The `test` job runs `go mod tidy` drift check → `go vet ./...` → `go build ./...` → `go test -race -count=1 ./...` using the Go version pinned in `go.mod`. - The `lint` job runs `golangci-lint` **v2.11.4** against `.golangci.yml`. Do not downgrade: golangci-lint refuses a module whose target Go version is newer than its own build toolchain, so v2.5.0 (built with go1.25) cannot lint this module (`go 1.26.4`). -- The `package` job runs `scripts/test-npm-package.sh` — builds a host binary, stamps + packs the npm wrapper packages, installs the tarballs, and runs `olk version` through the JS launcher in both unscoped and scoped layouts. This makes npm packaging breakage a PR failure instead of a tag-time failure. +- The `package` job runs `scripts/test-npm-package.sh` — builds a host binary, stamps + packs the npm wrapper packages, installs the tarballs, and runs `olk version` through the JS launcher in both unscoped and scoped layouts. This makes npm packaging breakage a PR failure instead of a tag-time failure. It also runs `scripts/test-bootstrap-npm.sh`, which covers the publish-argument shape: absolute tarball paths (npm parses a bare relative path as a `/` git shorthand), an explicit dist-tag on every publish (npm ≥ 11 rejects an untagged prerelease, and every fork version is a prerelease), the publish order, and the tarball-set validation. Both scripts restore `npm/*/package.json` on exit; the job then asserts a clean working tree. - **Runners:** all Linux jobs use `blacksmith-4vcpu-ubuntu-2404` with `useblacksmith/checkout` + `useblacksmith/setup-go` (restored Go build cache: a cold cross-compile of this module costs ~8 min per target, warm ~3 s). Two exceptions, both deliberate: macOS jobs stay on `macos-latest` (Blacksmith has no macOS runners, and the darwin build needs CGO for the Keychain), and any **npm publish** job stays on `ubuntu-latest` (npm Trusted Publishing accepts cloud-hosted runners only; a Blacksmith runner reports itself as self-hosted in the OIDC claims). - All jobs use pinned action SHAs. @@ -53,7 +53,7 @@ - A `vX.Y.Z` tag triggers `release.yml`, publishing to three channels: **Homebrew** (goreleaser → `rlrghb/tap` cask, with a quarantine-strip postflight), **npm** (the `olkcli` package + six `olk--` per-platform packages under `npm/`; `scripts/build-npm.mjs` stamps + publishes), and the **MCP Registry** (`server.json` → `io.github.rlrghb/outlook`, published via `mcp-publisher`). - **No publish secrets:** npm uses **Trusted Publishing (OIDC)** (`id-token: write`, npm ≥ 11.5.1, SLSA provenance); the registry uses GitHub OIDC. The npm package is `olkcli`; the binary is `olk`. - The official MCP registry has no in-place edit — `server.json` description/version changes apply on the **next release** (versions are CI-stamped from the tag). `npm-publish`/`registry-publish` are gated on the `PUBLISH_NPM` repo variable. -- **Fork npm distribution (`@planmonster`) — `publish-npm.yml`, this fork only.** An `npm-vX.Y.Z-` tag (or a `workflow_dispatch` with `dry_run`) builds all six binaries (linux/windows on Blacksmith with `CGO_ENABLED=0`; darwin on `macos-latest` with `CGO_ENABLED=1`) and publishes `@planmonster/olkcli` + six `@planmonster/olk--` packages to npmjs.org via Trusted Publishing. The tag prefix is `npm-v`, not `v`, so it does not also fire `release.yml`. Versions **must** carry a fork suffix (e.g. `0.9.5-pm.1`); the workflow rejects a bare `X.Y.Z` because upstream owns those numbers. `scripts/build-npm.mjs` gained `--scope`, `--registry`, `--repository`, `--access`, and `--skip-binary-check` (bootstrap only) — re-scoping is idempotent. See `docs/npm-publishing.md` for the one-time bootstrap (npm Trusted Publishing can only be configured on a package that already exists, so the seven names must be created by hand once). +- **Fork npm distribution (`@planmonster`) — `publish-npm.yml`, this fork only.** An `npm-vX.Y.Z-` tag (or a `workflow_dispatch` with `dry_run`) builds all six binaries (linux/windows on Blacksmith with `CGO_ENABLED=0`; darwin on `macos-latest` with `CGO_ENABLED=1`) and publishes `@planmonster/olkcli` + six `@planmonster/olk--` packages to npmjs.org via Trusted Publishing. The tag prefix is `npm-v`, not `v`, so it does not also fire `release.yml`. Versions **must** carry a fork suffix (e.g. `1.10.0-pm.1`); the workflow rejects a bare `X.Y.Z` because upstream owns those numbers. Because the suffix makes every version a semver prerelease, the dist-tag must always be explicit — `build-npm.mjs` defaults it to `latest` and stamps `publishConfig.tag`. `scripts/build-npm.mjs` also takes `--scope`, `--registry`, `--repository`, `--access`, and `--skip-binary-check` (bootstrap only); re-scoping is idempotent. All seven names now exist on npmjs.org (bootstrapped at `1.10.0-pm.1` with `scripts/bootstrap-npm.sh`). See `docs/npm-publishing.md`. - `release.yml` is the **upstream** pipeline and cannot run on this fork: the unscoped npm names belong to upstream, `TAP_GITHUB_TOKEN` does not exist here, and `io.github.rlrghb/outlook` is not our MCP namespace. Leave it inert rather than repurposing it. - **ClawHub (OpenClaw skill) — manual, separate from the tag pipeline.** olk is listed on ClawHub as a **skill** from `SKILL.md`. Publish with the `clawhub` CLI (publisher `rlrghb`; `clawhub whoami` / `clawhub login`) from a folder containing **only `SKILL.md`** — `mkdir -p /tmp/olk-skill && cp SKILL.md /tmp/olk-skill/`, then `clawhub skill publish /tmp/olk-skill --slug olk --name Outlook --version --tags calendar,contacts,drive,latest,mail,microsoft,onedrive,outlook,tasks --changelog '…'`. The skill version is **independent of the binary** — align it to the release. Display name is `Outlook` (pass `--name`; `SKILL.md`'s `name: olk` is only the slug). The summary comes from `SKILL.md` `description:`; **category** ("DATA & APIS") is web-UI only. **No `--dry-run`** — verify the live entry with `clawhub inspect olk` and confirm before publishing. @@ -66,6 +66,13 @@ - **Lazy client init**: `RunContext.GraphClient()` initializes on first call — auth commands don't need a Graph client. - **Delegated mailbox routing**: read paths in `internal/graphapi/{mail,calendar,contacts}.go` take a `target string` first parameter and route through `c.targetUser(target)`. Empty target preserves `/me` behavior; a non-empty value hits `/users/{target}/…`. The CLI exposes this as the global `--mailbox` flag (env `OLK_MAILBOX`), validated once via `resolveMailboxTarget` in `internal/cmd/paging.go`. New read methods should follow the same shape; write paths intentionally stay on `/me` for now. - **MCP server**: `olk mcp` (`internal/cmd/mcp*.go`) exposes a curated, read-first allowlist of tools over stdio — not the whole CLI. Tool calls reparse argv and run in-process with stdout captured. Read-only by default; `--allow-write ` exposes a named curated safe-write tool (per-tool opt-in). No HTTP transport (deliberate scope choice). To expose a command, add it to `curatedTools` (read or non-destructive write only). +- **Token mode (credential injection)** — `internal/cmd/token.go`. When `--access-token` / `OLK_ACCESS_TOKEN` is set, olk becomes a pure consumer of an externally minted **delegated Graph access token**: it never touches the keyring, the account files, or the default-account config; it never refreshes; it never persists the token. The token's lifetime is the process's lifetime. This exists so a disposable environment (CI, container, agent sandbox) holds no durable refresh token — a container-local keyring would both leak a long-lived credential and race Microsoft's refresh-token rotation across concurrent containers. + - `newTokenMode()` **fails closed**: an unparseable or already-passed `OLK_ACCESS_TOKEN_EXPIRES_AT` is an error raised *before* any network call and before any credential store is touched. A nil `*tokenMode` means account mode (the normal keyring path) — preserve that distinction when adding flags. + - Expiry maps to the dedicated exit code **77** (`exitTokenExpired`) via the `errTokenExpired` sentinel, so an orchestrator can mint a fresh token instead of treating the run as a hard failure. Do not fold it into the generic exit 1. + - With no supplied expiry, the Azure SDK is told `nominalTokenLifetime` (30 min) because a zero expiry makes its credential pipeline refuse to send the request; Graph's `401` is then the authority. + - `auth login|logout|clean|list` refuse to run in this mode (they manage *stored* accounts); `auth status` reports the injected token. `--account` is rejected; `--mailbox` still works. Every capability guard composes normally. + - The token must never reach output, errors, verbose HTTP logs (the `Authorization` header is redacted), or the argv the MCP server builds. `token_test.go` asserts this — keep it true when touching logging or MCP argv construction. + - This is the mechanism the fork's npm distribution is built for: `npx @planmonster/olkcli` in a sandbox, credentials injected by the control plane. See the README section "Access-Token Injection" and `docs/npm-publishing.md`. - **Capability guards**: `--no-write`/`--no-send` enforced once at the `graphapi.Client` layer so the guarantee covers CLI, MCP, and scripts; command allow/deny lists (`--enable-commands[-exact]`, `--disable-commands`) gate dispatch via `commandAllowed()`. `--wrap-untrusted` (forced on under MCP) wraps `untrusted:"true"`-tagged fields for prompt-injection defense. ## Commit & Pull Request Guidelines diff --git a/README.md b/README.md index e3ce3eb..b4d6b11 100644 --- a/README.md +++ b/README.md @@ -93,13 +93,19 @@ brew install rlrghb/tap/olk ```bash # Install globally (provides the `olk` command) -npm install -g olkcli +npm install -g @planmonster/olkcli # …or run on demand without installing -npx olkcli mail list +npx @planmonster/olkcli mail list ``` -> The npm package is named **`olkcli`**; the installed binary is **`olk`**. +> This fork publishes **`@planmonster/olkcli`**; the installed binary is **`olk`**. +> Upstream publishes the unscoped **`olkcli`**. Pin an exact version in an agent +> runner — every fork version carries a `-pm.N` suffix (e.g. `1.10.0-pm.1`), and +> `@latest` makes a run non-reproducible. See [docs/npm-publishing.md](docs/npm-publishing.md). + +Only the binary for your platform is downloaded: the launcher is a ~2 KB JS shim +and the binary ships in a per-platform optional dependency (~23 MB compressed). ### macOS notes @@ -243,6 +249,41 @@ Notes: - Under `olk mcp`, every tool call rebuilds the credential from the environment, so the same variables apply with no extra configuration. Because the token is not refreshed, keep the server's lifetime shorter than the token's. - The token never appears in output, errors, verbose HTTP logs (the `Authorization` header is redacted), or the argv the MCP server builds. +#### Ephemeral runner example (npx + injected token) + +No install step, no keyring, no configuration directory — suitable for a +disposable sandbox driven by a control plane: + +```bash +# The control plane mints the token, then starts the CLI. Pass the token in the +# environment, never on the command line: argv is visible to other processes. +env \ + OLK_ACCESS_TOKEN="$GRAPH_TOKEN" \ + OLK_ACCESS_TOKEN_EXPIRES_AT="$GRAPH_TOKEN_EXPIRY" \ + OLK_ACCOUNT_EMAIL="user@example.com" \ + OLK_JSON=1 OLK_NO_INPUT=1 OLK_CONCISE=1 \ + OLK_NO_WRITE=1 OLK_NO_SEND=1 OLK_WRAP_UNTRUSTED=1 \ + OLK_ENABLE_COMMANDS=mail,calendar \ + npx -y @planmonster/olkcli@1.10.0-pm.1 mail list --json +``` + +Read the JSON envelope from stdout; `stderr` carries human hints only. Exit codes +an orchestrator should branch on: + +| Exit | Meaning | Orchestrator action | +|------|---------|---------------------| +| `0` | success | parse stdout | +| `77` | injected token expired, **no request was made** | mint a fresh token, retry | +| `80` | usage error (bad flag or command) | fix the invocation; do not retry | +| `1` | runtime failure (Graph error, network) | inspect stderr, retry if transient | + +For a tool-calling agent, run the MCP server instead — read-only by default, with +untrusted-content wrapping forced on: + +```bash +npx -y @planmonster/olkcli@1.10.0-pm.1 mcp +``` + ### Custom App Registration If your organization blocks the default client ID, or your admin requires apps to be registered under your tenant, you'll need to create your own app registration: diff --git a/docs/npm-publishing.md b/docs/npm-publishing.md index 67e9af2..add6d7c 100644 --- a/docs/npm-publishing.md +++ b/docs/npm-publishing.md @@ -154,6 +154,28 @@ A consumer therefore never sees a launcher whose optional dependencies are absent. The step skips a version that already exists, so a re-run after a partial failure is safe. +## Troubleshooting + +**`npm view` returns 404 immediately after a publish.** This is normal. The full +packument is the slowest read path to replicate — measured at ~3.5 minutes on the +bootstrap publish, while the tarball, the version manifest, and the dist-tags +endpoint all served immediately. Do not re-publish. Check a fast path instead: + +```sh +curl -s "https://registry.npmjs.org/-/package/@planmonster%2folkcli/dist-tags" +curl -s "https://registry.npmjs.org/-/v1/search?text=planmonster" +``` + +**`npm error code 128 ... git@github.com: Permission denied (publickey)`.** npm +parsed a bare relative path such as `dist-npm/pkg.tgz` as the GitHub shorthand +`/`. Pass an absolute path or a `./` prefix. `bootstrap-npm.sh` +resolves this itself. + +**`You must specify a tag using --tag when publishing a prerelease version`.** +Every fork version is a semver prerelease, so the dist-tag must be explicit. +`build-npm.mjs` defaults it to `latest` and stamps `publishConfig.tag`; pass +`--tag` to override. + ## Consuming the package ### From a Daytona sandbox