Skip to content
Merged
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
15 changes: 11 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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`.
Expand All @@ -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 `<owner>/<repo>` 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.

Expand All @@ -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-<os>-<arch>` 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-<suffix>` 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-<os>-<arch>` 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-<suffix>` 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-<os>-<arch>` 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 <X.Y.Z> --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.

Expand All @@ -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 <tool>` 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
Expand Down
47 changes: 44 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Comment on lines +270 to +278

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Qualify exit code 77 as explicit-expiry validation only.

The implementation maps code 77 to an already-passed OLK_ACCESS_TOKEN_EXPIRES_AT; tokens without supplied expiry use the nominal lifetime and a later Graph 401 can remain a runtime failure. Update the table so orchestrators do not assume every expired token produces 77.

Proposed wording
-| `77` | injected token expired, **no request was made** | mint a fresh token, retry |
+| `77` | supplied token expiry was already passed; **no request was made** | mint a fresh token, retry |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 |
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` | supplied token expiry was already passed; **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
```
Comment on lines +280 to +285

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Pass token and safety variables to the MCP example.

The preceding env ... npx assignment applies only to that one process. This standalone command therefore does not inject OLK_ACCESS_TOKEN or the documented guard variables, so it may use stored-account/keyring mode instead of the promised ephemeral token mode.

Repeat the environment block here or provide a wrapper command that preserves it.


### 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:
Expand Down
22 changes: 22 additions & 0 deletions docs/npm-publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<owner>/<repo>`. 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
Expand Down
Loading