Compare commits

...
17 Commits
Author SHA1 Message Date
Hanzo Dev 1c7679c41e chore(release): sync all packages to 1.9.30 (Outlook downloadable)
Adds the Outlook mail add-in as a downloadable release binary (hanzo-ai-outlook)
alongside Office, browser, IDE (vscode/cursor/windsurf/antigravity/jetbrains),
Claude, and Safari. Google Workspace ships via clasp, not a sideload binary.
2026-07-03 16:49:08 -07:00
Hanzo Dev 3105d4a099 ci(office-suite): test + release Outlook & Google Workspace add-ins
Wire the two new productivity surfaces into CI/CD:
- ci.yml: test @hanzo/outlook-addin (39) + @hanzo/gworkspace-addon (29).
- publish.yml: new 'outlook' job builds @hanzo/outlook-addin → hanzo-ai-outlook-v<ver>.zip,
  added to release needs/files + an Outlook row in the download table.
  (Google Workspace deploys via clasp to Google, not a sideload binary — CI-tested
  only, no release zip.)
- pnpm-lock: workspace now includes packages/outlook + packages/gworkspace.
2026-07-03 16:32:55 -07:00
Hanzo Dev 5f14961fbb Merge remote-tracking branch 'origin/feat/gworkspace-addon' 2026-07-03 16:29:58 -07:00
Hanzo Dev 36de7695af feat(gworkspace): Google Workspace add-on for Docs, Sheets, Slides, Gmail
Mirror @hanzo/office-addin for the Google ecosystem. Apps Script +
CardService add-on calling api.hanzo.ai/v1 via UrlFetchApp (non-streaming,
one call per insert). Per-user hk- API key in PropertiesService.

- appsscript.json: per-host homepage triggers + Gmail contextual/compose
  triggers, currentonly + gmail.addons scopes, urlFetchWhitelist pinned to
  https://api.hanzo.ai/.
- hanzo.gs: API client (pure buildMessages/requestBody/extractContent/
  parseModels + UrlFetchApp ask/listModels).
- ui.gs: CardService sidebar (model picker, quick-action chips, settings/
  onboarding, universal actions) + all trigger/action handlers.
- docs/sheets/slides/gmail.gs: per-host read + insert glue.
- vitest over the pure logic (loads .gs in a Node sandbox, no duplication);
  validate-manifest.mjs preflights scopes + every runFunction resolves.
- Register packages/gworkspace in pnpm-workspace.yaml.
2026-07-03 16:25:19 -07:00
Hanzo Dev 0ae9186728 feat(outlook): Hanzo AI mail add-in (@hanzo/outlook-addin)
A Microsoft Outlook mail add-in embedding Hanzo AI into email — the
highest-value law-office surface after Word. Mail add-in manifest
(MailApp), Read + Compose command surfaces, ribbon button opens the
task pane.

Quick actions: Summarize thread, Draft reply, Extract deadlines &
action items, Explain plainly — all streamed (askStream/SSE).

- Reuses the Office add-in's pure core identically: config.ts (api.hanzo.ai
  /v1 gateway), chat.ts (ask/askStream/listModels), auth.ts (@hanzo/auth
  OAuth2+PKCE via roamingSettings + hk- API-key path). Shares the
  hanzo-office IAM client — no new auth surface.
- New outlook-host.ts: Office.context.mailbox.item glue (read body/subject/
  from; compose prepend/setSelectedData; displayReplyForm) + pure shaping
  helpers (stripHtml, collapseWhitespace, shapeMessage, formatFrom, mailMode).
- build.js mirrors office (esbuild -> dist/, base-URL stamp, dev HTTPS serve).
- 39 vitest tests pass; manifest validated by office-addin-manifest.
- Registered packages/outlook in pnpm-workspace.yaml.
2026-07-03 16:24:24 -07:00
Hanzo Dev e9531bf4e3 fix(jetbrains): disable buildSearchableOptions — headless libfreetype crash
buildPlugin transitively runs buildSearchableOptions, which launches a headless
IDE (JCEF) needing native font libs (libfreetype.so.6) absent on the CI runners
→ UnsatisfiedLinkError, non-deterministic (only green when the task was cache
UP-TO-DATE, which is why ci.yml passed but the release build failed). The search
index is optional and rebuilt by the IDE at runtime; disabling it makes
buildPlugin reliably emit the distributable plugin zip for the GitHub Release.
2026-07-03 15:47:49 -07:00
Hanzo Dev 46daa52d43 chore(release): sync all packages to 1.9.29 for full all-platform release
Bumps root + browser + vscode + office + jetbrains to 1.9.29 so every release
asset name matches the tag (the download-table URLs use the tag version). The
first release to carry a downloadable JetBrains plugin binary alongside browser,
VS Code/Cursor/Windsurf/Antigravity, Claude, Safari and Office.
2026-07-03 15:39:46 -07:00
hanzo-dev 780757ca55 feat(auth): one canonical @hanzo/auth core; migrate office; fix vscode HIP-0111
Five surfaces (browser, office, vscode, cli, mcp) each hand-rolled the SAME
IAM OAuth2+PKCE flow — and one drifted and broke: vscode built
hanzo.id/oauth/authorize and iam.hanzo.ai/oauth/token, both MISSING the
/v1/iam prefix (a 404 — the same class of bug as the browser login fix),
plus it POSTed JSON where IAM requires form-urlencoded and sent a
client_secret on a public PKCE client. That is the opposite of one way.

Decomplected into ONE core — packages/auth (@hanzo/auth):
- config.ts   canonical HIP-0111 endpoints (authorize/token/userinfo ALL
              carry /v1/iam) + the one-client-per-surface registry
              (hanzo-browser/office/vscode/cli). URL assembly lives once.
- pkce.ts     the one PKCE (S256), platform-CSPRNG + WebCrypto.
- oauth.ts    buildAuthorizeURL / exchangeCode / refreshTokens / userinfo —
              pure fetch, form-encoded per RFC 6749.
- flow.ts     login / getValidToken (transparent refresh) / logout /
              currentUser, written ONCE against TokenStore + Opener. A
              surface supplies only those two thin adapters — no surface
              re-implements the flow.
23 tests: the drift guard (every path has /v1/iam), client registry, PKCE
RFC-7636 vector, wire shapes, and the full flow with fake adapters.

Migrate office onto it: roamingSettings TokenStore + Dialog-API Opener;
delete office's duplicate pkce.ts + IAM config (now the core's). API-key
path kept. 22 tests; the core bundles into the task pane.

Fix vscode standalone-auth: all three endpoints → ${host}/v1/iam/oauth/*,
token calls form-urlencoded, OIDC scopes, drop the secret on the public
PKCE client. Compiles clean.

CI now gates @hanzo/auth. Browser/mcp/tools already use canonical paths;
migrating them to import the core is the tracked next step (each needs its
own bundler wiring) — the ONE way now exists and has two consumers.
2026-07-03 15:24:01 -07:00
Hanzo Dev 5bf8d934b4 ci(release): downloadable JetBrains + Office + Safari binaries for all platforms
The GitHub Release table advertised JetBrains and Safari downloads that 404'd —
neither job produced a release artifact — and the Office add-in was absent.
Make every advertised binary real and deterministic from the tag:

- jetbrains: always `gradlew buildPlugin` → hanzo-ai-jetbrains-v<ver>.zip artifact
  (Marketplace publish stays token-gated); the job no longer skips without the
  token, so the download link always resolves.
- office: new job builds @hanzo/office-addin → hanzo-ai-office-v<ver>.zip.
- safari: package the built .app(s) → hanzo-ai-safari-v<ver>.zip.
- release: add jetbrains+office to `needs`, add all three globs to `files`, and
  add the Office row to the download table. Asset names use the tag version so
  they match the table URLs.

All-platform release: browser (chrome/edge/firefox/safari), IDE (vscode/cursor/
windsurf/antigravity/jetbrains), Claude dxt, and Office (Word/Excel/PowerPoint).
2026-07-03 15:22:58 -07:00
Hanzo Dev 81db870a51 feat(office): streaming responses, live model picker, quick-action chips
Additive enhancements to the Office add-in, keeping the existing ask/requestBody
contract (and its tests) intact:

- chat.ts: askStream (SSE streaming completion → onDelta), streamDelta (pure SSE
  frame parse), listModels (/v1/models → ids); requestBody gains an opt-in stream
  flag (defaults false, so the one-shot ask path is unchanged).
- taskpane: a live model <select> populated from /v1/models (org-scoped by the
  token, refreshed on sign-in), and one-click quick-action chips — Draft,
  Summarize, Explain plainly, Tighten/redline, Continue — that run over the
  selection. Output now streams in live; Insert stays gated on real content.

Built for document-heavy review/drafting (e.g. a law office). Tests: 31 pass
(+6 new: stream flag, streamDelta, listModels). Build OK; my files tsc-clean.
2026-07-03 15:08:32 -07:00
hanzo-dev 4060ce491b feat(office): API-key auth + self-contained Windows test kit; release 1.9.28
- API-key auth: paste a Hanzo hk- key (console.hanzo.ai → API keys) instead
  of the IAM OAuth dialog, so the add-in is usable with zero infra/IAM setup.
  pickBearer(apiKey, oauthToken) precedence is pure + tested (29 tests).
- Windows test kit: `HANZO_OFFICE_BASE=https://localhost:3000` build ships
  serve.mjs (stdlib HTTPS static server) + SIDELOAD-WINDOWS.md, so the
  downloaded zip runs on any machine — trust a localhost cert, node serve.mjs,
  sideload manifest.xml, paste key, go.
- CI Build now emits BOTH hanzo-ai-office-v${VER}.zip (production, office.hanzo.ai)
  and hanzo-ai-office-localhost-v${VER}.zip (the test kit).

Bump browser+root to 1.9.28 so tagging v1.9.28 publishes a GitHub Release
carrying ALL binaries — chrome/edge/firefox/safari, vscode/cursor/windsurf,
claude(dxt), and now office (prod + localhost) — with permanent download links.
2026-07-03 15:05:46 -07:00
hanzo-dev 9032801deb feat(office): Hanzo AI add-in for Word, Excel & PowerPoint
The surface a non-developer actually uses — a Microsoft Office task-pane
add-in that puts Hanzo AI inside the document. Select text or a range,
ask, insert. Distinct from the browser extension and the IDE extensions;
same backend as everything else: model calls through api.hanzo.ai/v1
(OpenAI-compatible), sign-in via Hanzo IAM (hanzo.id, auth-code + PKCE,
HIP-0111). No new API surface.

Decomplected so the logic is pure and unit-tested (26 tests), Office.js
glue kept thin:
- chat.ts — request/response shaping (buildMessages fences the selection
  as data; extractContent tolerates the gateway's response shapes)
- pkce.ts — PKCE + authorize URL, verified against the RFC 7636 S256 test
  vector
- host.ts — per-host read/insert; Excel range↔text helpers tested
- config.ts — canonical endpoints pinned (api.hanzo.ai, /v1/iam/oauth/*)
- taskpane/auth/commands — the Office.js + Dialog-API glue

manifest.xml passes `office-addin-manifest validate` ("The manifest is
valid" — Word/Excel/PowerPoint on web, Windows, Mac). build.js stamps the
hosting base URL (office.hanzo.ai, override for localhost dev) into a
sideloadable/AppSource-submittable dist/manifest.xml.

CI runs the tests and builds+zips a sideloadable artifact. Publishing to
AppSource needs the dist hosted at office.hanzo.ai + a hanzo-office IAM
client + a Partner Center submission (documented in the README).
2026-07-03 14:43:29 -07:00
hanzo-dev 8051256382 test(vscode): gate the zsh shell-tool test on /bin/zsh existing — its whole subject is delegation to zsh, absent on CI runners (the one remaining hard-gate failure: 151/152) 2026-07-03 02:16:05 -07:00
hanzo-dev 3e380bcec3 ci: provision electron runtime libs for the extension-host tests — VS Code downloaded but could not load libnspr4.so on the minimal arc node 2026-07-03 02:12:29 -07:00
hanzo-dev 39fc502513 ci: provision xvfb before the vscode extension-host tests — the arc pool is heterogeneous and not every node ships it (Test job hit exit 127 while the E2E job's node has it) 2026-07-03 02:09:01 -07:00
hanzo-dev ce2f186bd2 vscode: resurrect the whole test gate — 0 lint errors, 152 mocha + 3 vitest passing, hard CI gates
The package's `npm test` has been broken-by-construction for years:
tsconfig excluded src/test entirely (out/test/runTest.js never existed),
tsconfig.test.json inherited the exclude that negated its include, the
runner pointed at a ./suite/index path that never existed, two suites used
BDD describe/it under the tdd-configured mocha, a vitest file sat in the
mocha glob, `ws` was never declared (it resolved only cross-package), and
154 eslint errors blocked pretest.

Lint 154 → 0 with real fixes: the ban-types autofix damage reverted
properly (the local `Symbol` interface shadowing the ES built-in — the
actual footgun — is now `CodeSymbol` everywhere), case bodies braced via
AST codemod, empty catches annotated, `Function` types replaced with real
signatures, hasOwnProperty via Object.prototype, and
@typescript-eslint/no-var-requires off — CJS lazy requires are the
intentional activation-perf idiom (the rule is retired to stylistic in
typescript-eslint v8).

Harness: tests compile via tsconfig.test.json in pretest; runner path
fixed; BDD→TDD; glob v10 promise API; esModuleInterop default imports.
Two harnesses, one directory each: vitest owns src/test/vitest/ (scoped
vitest.config, excluded from the mocha compile), mocha owns the rest;
posttest runs the vitest side so `npm test` is the whole gate.

Suites modernized against current tool contracts (34 passing / 64 failing
→ 152 passing / 0 failing / 12 pending): web-fetch rewritten against the
real http(s) path via loopback server, mode against list/activate/show/
current, mcp-tools against config-driven getAllTools filtering, extension
pins the shipped manifest contract, shell/process/git-search/filesystem/
config/bash/mcp-runner exercise real commands, files, repos and spawns.
Dead-subject suites removed; network-dependent integration tests gated on
HANZO_E2E_LIVE with visible skip reasons (12 pending). Tests-only — the
one suspected product bug was an unfaithful Memento mock, and a real
cross-suite settings leak was fixed by making suites hermetic.

CI: cli-tools and vscode test steps lose continue-on-error and become
hard gates (vscode under xvfb-run, same as the E2E job).
2026-07-03 02:05:42 -07:00
hanzo-dev 7c742c07f4 deps: re-pin stacked-advisory packages to their highest patched floor
The first pass pinned each override to its advisory-snapshot floor, but
several packages carry stacked advisories — a pin at one advisory's floor
sits inside the next one's vulnerable range (protobufjs 7.5.5 vs 7.6.3,
tar 7.5.16, tmp 0.2.7, flatted 3.4.2, vite 6.4.3, file-type 21.3.2,
qs 6.15.2, js-yaml 4.2.0). One within-major floor per package now, at the
highest first-patched. Suites: browser 243, ai 31, aci 29, cli-tools 87.
2026-07-03 00:56:30 -07:00
165 changed files with 7827 additions and 5079 deletions
+37 -3
View File
@@ -30,13 +30,33 @@ jobs:
- name: Test AI package
run: pnpm --filter @hanzo/ai test
- name: Test auth core
run: pnpm --filter @hanzo/auth test
- name: Test Office add-in
run: pnpm --filter @hanzo/office-addin test
- name: Test Outlook add-in
run: pnpm --filter @hanzo/outlook-addin test
- name: Test Google Workspace add-on
run: pnpm --filter @hanzo/gworkspace-addon test
- name: Test tools package
run: pnpm --filter @hanzo/cli-tools test
continue-on-error: true
- name: Test VS Code extension
run: pnpm --filter hanzo-ide test
continue-on-error: true
run: |
# The extension host is Electron: it needs a display plus the
# chromium-class shared libraries. Arc nodes are minimal (no xvfb,
# no libnspr4/libnss3), so provision explicitly (noble t64 names).
sudo apt-get update -qq
sudo apt-get install -y -qq xvfb libnss3 libnspr4 libatk1.0-0t64 \
libatk-bridge2.0-0t64 libcups2t64 libdrm2 libxkbcommon0 \
libatspi2.0-0t64 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
libgbm1 libasound2t64 libgtk-3-0t64 libxshmfence1 libsecret-1-0 \
libxkbfile1
xvfb-run -a pnpm --filter hanzo-ide test
# ─── Playwright E2E ───
e2e:
@@ -153,6 +173,20 @@ jobs:
VER="${{ steps.version.outputs.ver }}"
cp dist/hanzoai-*.dxt /tmp/hanzo-ai-claude-v${VER}.dxt 2>/dev/null || true
# ── Microsoft Office add-in (Word/Excel/PowerPoint) ──
- name: Build Office add-in
run: |
VER="${{ steps.version.outputs.ver }}"
# Two builds, two zips (node-based bestzip — the arc image has no `zip`):
# - production: manifest points at office.hanzo.ai (AppSource / admin deploy)
# - localhost: manifest points at https://localhost:3000 + serve.mjs +
# SIDELOAD-WINDOWS.md — the self-contained "test on my machine" kit
pnpm --filter @hanzo/office-addin build
(cd packages/office/dist && npx --yes bestzip /tmp/hanzo-ai-office-v${VER}.zip .)
HANZO_OFFICE_BASE=https://localhost:3000 pnpm --filter @hanzo/office-addin build
(cd packages/office/dist && npx --yes bestzip /tmp/hanzo-ai-office-localhost-v${VER}.zip .)
continue-on-error: true
# ── MCP npm package ──
- name: Build MCP server
run: pnpm --filter @hanzo/mcp run build
+110 -3
View File
@@ -154,6 +154,29 @@ jobs:
-derivedDataPath dist/safari-build-ios
continue-on-error: true
# Package the built .app(s) so the GitHub Release Safari link resolves — the
# unsigned bundle a developer sideloads (source + built products).
- name: Package Safari for release
working-directory: packages/browser
run: |
VERSION=${GITHUB_REF_NAME#v}
cd dist
# Prefer the built .app products; fall back to the Xcode source project.
APPS=$(find safari-build-macos safari-build-ios -name '*.app' -type d 2>/dev/null || true)
if [ -n "$APPS" ]; then
zip -r -y "/tmp/hanzo-ai-safari-v${VERSION}.zip" $APPS
else
zip -r "/tmp/hanzo-ai-safari-v${VERSION}.zip" "safari/Hanzo AI"
fi
continue-on-error: true
- name: Upload Safari bundle
uses: actions/upload-artifact@v4
with:
name: hanzo-ai-safari
path: /tmp/hanzo-ai-safari-v*.zip
continue-on-error: true
# ─── VS Code + Cursor + Windsurf + Antigravity + Open VSX ───
vscode:
name: VS Code Marketplace
@@ -243,7 +266,6 @@ jobs:
name: JetBrains
runs-on: hanzo-build-linux-amd64
needs: secrets
if: needs.secrets.outputs.has-jetbrains == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
@@ -251,18 +273,90 @@ jobs:
java-version: '17'
distribution: 'temurin'
- name: Build and publish
# Always build the downloadable plugin zip so the GitHub Release link works
# even when the Marketplace token is absent (publish below is token-gated).
- name: Build plugin
working-directory: packages/jetbrains
run: ./gradlew buildPlugin
- name: Package for release
working-directory: packages/jetbrains
run: |
VERSION=${GITHUB_REF_NAME#v}
ZIP=$(ls build/distributions/*.zip | head -1)
cp "$ZIP" "/tmp/hanzo-ai-jetbrains-v${VERSION}.zip"
- name: Upload JetBrains plugin
uses: actions/upload-artifact@v4
with:
name: hanzo-ai-jetbrains
path: /tmp/hanzo-ai-jetbrains-v*.zip
- name: Publish to JetBrains Marketplace
if: needs.secrets.outputs.has-jetbrains == 'true'
working-directory: packages/jetbrains
run: ./gradlew publishPlugin
env:
PUBLISH_TOKEN: ${{ secrets.JETBRAINS_TOKEN }}
continue-on-error: true
# ─── Microsoft Office add-in (Word / Excel / PowerPoint) ───
office:
name: Office Add-in
runs-on: hanzo-build-linux-amd64
needs: secrets
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Build + package
run: |
VERSION=${GITHUB_REF_NAME#v}
pnpm --filter @hanzo/office-addin build
cd packages/office/dist && npx --yes bestzip "/tmp/hanzo-ai-office-v${VERSION}.zip" .
- name: Upload Office add-in
uses: actions/upload-artifact@v4
with:
name: hanzo-ai-office
path: /tmp/hanzo-ai-office-v*.zip
# ─── Microsoft Outlook mail add-in ───
outlook:
name: Outlook Add-in
runs-on: hanzo-build-linux-amd64
needs: secrets
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Build + package
run: |
VERSION=${GITHUB_REF_NAME#v}
pnpm --filter @hanzo/outlook-addin build
cd packages/outlook/dist && npx --yes bestzip "/tmp/hanzo-ai-outlook-v${VERSION}.zip" .
- name: Upload Outlook add-in
uses: actions/upload-artifact@v4
with:
name: hanzo-ai-outlook
path: /tmp/hanzo-ai-outlook-v*.zip
# ─── GitHub Release with downloadable artifacts ───
release:
name: GitHub Release
runs-on: hanzo-build-linux-amd64
needs: [browser, safari, vscode]
needs: [browser, safari, vscode, jetbrains, office, outlook]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
@@ -291,6 +385,15 @@ jobs:
| **Claude Desktop/Code** | Windows / macOS / Linux | [`hanzo-ai-claude-v${{ steps.version.outputs.version }}.dxt`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-claude-v${{ steps.version.outputs.version }}.dxt) |
| **JetBrains** | Windows / macOS / Linux | [`hanzo-ai-jetbrains-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-jetbrains-v${{ steps.version.outputs.version }}.zip) |
## Microsoft Office
| App | Platform | Download |
|-----|----------|----------|
| **Word · Excel · PowerPoint** | Windows / macOS / Web / iPad | [`hanzo-ai-office-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-office-v${{ steps.version.outputs.version }}.zip) |
| **Outlook** | Windows / macOS / Web | [`hanzo-ai-outlook-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-outlook-v${{ steps.version.outputs.version }}.zip) |
> Office: unzip and sideload `manifest.xml` via the M365 admin center, or Insert → Add-ins → Upload My Add-in.
## Browser Extensions
| Browser | Platform | Download |
@@ -308,3 +411,7 @@ jobs:
files: |
artifacts/hanzo-ai-browser/*
artifacts/hanzo-ai-ide/*
artifacts/hanzo-ai-jetbrains/*
artifacts/hanzo-ai-office/*
artifacts/hanzo-ai-outlook/*
artifacts/hanzo-ai-safari/*
+9 -27
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/extension",
"version": "1.9.27",
"version": "1.9.30",
"private": true,
"description": "Hanzo AI Extension",
"license": "MIT",
@@ -56,9 +56,8 @@
"esbuild@<=0.24.2": "0.25.0",
"fast-uri@<=3.1.0": "3.1.1",
"fast-uri@<=3.1.1": "3.1.2",
"file-type@>=13.0.0 <21.3.1": "21.3.1",
"flatted@<=3.4.1": "3.4.2",
"flatted@<3.4.0": "3.4.0",
"file-type@<21.3.2": "21.3.2",
"flatted@<3.4.2": "3.4.2",
"follow-redirects@<=1.15.11": "1.16.0",
"form-data@>=4.0.0 <4.0.4": "4.0.4",
"form-data@>=4.0.0 <4.0.6": "4.0.6",
@@ -68,8 +67,7 @@
"handlebars@>=4.6.0 <=4.7.8": "4.7.9",
"ip-address@<=10.1.0": "10.1.1",
"js-yaml@<3.15.0": "3.15.0",
"js-yaml@>=4.0.0 <=4.1.1": "4.2.0",
"js-yaml@>=4.0.0 <4.1.1": "4.1.1",
"js-yaml@>=4.0.0 <4.2.0": "4.2.0",
"jws@<3.2.3": "3.2.3",
"linkify-it@<=5.0.0": "5.0.1",
"lodash@<=4.17.23": "4.18.0",
@@ -90,28 +88,15 @@
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
"playwright@<1.55.1": "1.55.1",
"postcss@<8.5.10": "8.5.10",
"protobufjs@<=7.5.5": "7.5.6",
"protobufjs@<=7.5.7": "7.5.8",
"protobufjs@<=7.6.0": "7.6.1",
"protobufjs@<=7.6.2": "7.6.3",
"protobufjs@<7.5.5": "7.5.5",
"qs@<6.14.1": "6.14.1",
"qs@>=6.11.1 <=6.15.1": "6.15.2",
"qs@>=6.7.0 <=6.14.1": "6.14.2",
"protobufjs@<7.6.3": "7.6.3",
"qs@<6.15.2": "6.15.2",
"rollup@>=4.0.0 <4.59.0": "4.59.0",
"serialize-javascript@<=7.0.2": "7.0.3",
"serialize-javascript@>=5.0.0 <7.0.5": "7.0.5",
"tar-fs@>=2.0.0 <2.1.4": "2.1.4",
"tar-fs@>=3.0.0 <3.1.1": "3.1.1",
"tar@<=7.5.10": "7.5.11",
"tar@<=7.5.15": "7.5.16",
"tar@<=7.5.2": "7.5.3",
"tar@<=7.5.3": "7.5.4",
"tar@<=7.5.9": "7.5.10",
"tar@<7.5.7": "7.5.7",
"tar@<7.5.8": "7.5.8",
"tmp@<=0.2.3": "0.2.4",
"tmp@<0.2.6": "0.2.6",
"tar@<7.5.16": "7.5.16",
"tmp@<0.2.7": "0.2.7",
"underscore@<=1.13.7": "1.13.8",
"undici@>=7.0.0 <7.18.2": "7.18.2",
"undici@>=7.0.0 <7.24.0": "7.24.0",
@@ -119,10 +104,7 @@
"uuid@<11.1.1": "11.1.1",
"validator@<13.15.20": "13.15.20",
"validator@<13.15.22": "13.15.22",
"vite@<=5.4.19": "5.4.20",
"vite@<=6.4.1": "6.4.2",
"vite@<=6.4.2": "6.4.3",
"vite@>=5.2.6 <=5.4.20": "5.4.21",
"vite@<6.4.3": "6.4.3",
"vitest@<3.2.6": "3.2.6",
"ws@>=8.0.0 <8.20.1": "8.20.1",
"ws@>=8.0.0 <8.21.0": "8.21.0",
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@hanzo/auth",
"version": "1.0.0",
"description": "The one Hanzo IAM auth core — canonical HIP-0111 OAuth2 + PKCE, shared by every Hanzo extension (browser, office, vscode, cli, mcp).",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.8.3",
"vitest": "^3.2.6"
},
"license": "MIT"
}
+53
View File
@@ -0,0 +1,53 @@
// The ONE definition of Hanzo IAM endpoints and the per-surface OAuth client
// registry. Every Hanzo extension (browser, office, vscode, cli, mcp) resolves
// its auth URLs HERE — so a path can never drift between five hand-rolled
// copies again (the vscode add-in shipped `hanzo.id/oauth/authorize`, missing
// the /v1/iam prefix, for exactly that reason).
//
// HIP-0111: the canonical OAuth2/OIDC surface is `${issuer}/v1/iam/oauth/*`.
// The login UI (authorize) lives on hanzo.id; the token/userinfo API on
// iam.hanzo.ai. Both carry the SAME /v1/iam prefix — that is the whole point.
export const IAM_LOGIN_URL = 'https://hanzo.id';
export const IAM_API_URL = 'https://iam.hanzo.ai';
export const IAM_API_PATH = '/v1/iam';
export const DEFAULT_SCOPES = 'openid profile email offline_access';
// SurfaceClient is one registered IAM OAuth client — one per product surface, so
// tokens, redirect URIs, and audit trails are attributable to the surface that
// minted them. redirectUri is where IAM sends the code back; it must be
// registered on the IAM client of the same id. Adding a surface is ONE entry
// here, never a new copy of the flow.
export interface SurfaceClient {
id: string;
redirectUri: string;
}
// CLIENTS is the registry. The ids match the applications seeded in Hanzo IAM
// (<org>-<app> convention). redirectUris match what those clients allow.
export const CLIENTS = {
browser: { id: 'hanzo-browser', redirectUri: 'https://hanzo.ai/callback' },
office: { id: 'hanzo-office', redirectUri: 'https://office.hanzo.ai/auth-callback.html' },
vscode: { id: 'hanzo-vscode', redirectUri: 'https://hanzo.ai/callback' },
cli: { id: 'hanzo-cli', redirectUri: 'http://127.0.0.1/callback' },
} as const satisfies Record<string, SurfaceClient>;
export type SurfaceName = keyof typeof CLIENTS;
// URL builders — the only place these paths are assembled. issuer defaults to
// the login host for authorize and the API host for token/userinfo, but both
// are overridable (self-hosted brands, tests) via the opts.
export function authorizeURL(issuer: string = IAM_LOGIN_URL): string {
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/authorize`;
}
export function tokenURL(issuer: string = IAM_API_URL): string {
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/token`;
}
export function userinfoURL(issuer: string = IAM_API_URL): string {
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/userinfo`;
}
function trimEnd(s: string): string {
return s.replace(/\/+$/, '');
}
+103
View File
@@ -0,0 +1,103 @@
// The auth FLOW — written exactly once, against TokenStore + Opener. This is the
// decomplected heart: browser / office / vscode / cli call these functions with
// their own adapter and get identical login, refresh, and session semantics. No
// surface re-implements PKCE, endpoint assembly, code exchange, or refresh.
import { createPkce } from './pkce.js';
import {
buildAuthorizeURL, exchangeCode, refreshTokens, fetchUserInfo, parseCallback,
type UserInfo,
} from './oauth.js';
import type { TokenStore, TokenSet, Opener } from './types.js';
export interface AuthClient {
clientId: string;
redirectUri: string;
scope?: string;
loginIssuer?: string;
apiIssuer?: string;
}
// EXPIRY_SKEW_MS refreshes a token slightly before it truly expires, so a call
// never races the deadline.
const EXPIRY_SKEW_MS = 60_000;
// login runs the full interactive authorization-code + PKCE flow and persists
// the tokens. Returns the TokenSet. Throws on user-cancel, state mismatch, an
// IAM error param, or a failed exchange — the surface shows the message.
export async function login(client: AuthClient, store: TokenStore, opener: Opener): Promise<TokenSet> {
const pkce = await createPkce();
const authorizeUrl = buildAuthorizeURL({
clientId: client.clientId,
redirectUri: client.redirectUri,
challenge: pkce.challenge,
state: pkce.state,
scope: client.scope,
loginIssuer: client.loginIssuer,
});
const redirectUrl = await opener.open(authorizeUrl, client.redirectUri);
const cb = parseCallback(redirectUrl);
if (cb.error) throw new Error(`sign-in failed: ${cb.error}`);
if (!cb.code) throw new Error('sign-in returned no authorization code');
if (cb.state !== pkce.state) throw new Error('sign-in state mismatch (possible CSRF) — try again');
const tokens = await exchangeCode({
clientId: client.clientId,
code: cb.code,
verifier: pkce.verifier,
redirectUri: client.redirectUri,
apiIssuer: client.apiIssuer,
});
await store.set(tokens);
return tokens;
}
// isExpired reports whether a TokenSet is at/near its deadline. A token with no
// expiresAt is treated as non-expiring (the surface still validates server-side
// on 401). Pure.
export function isExpired(tokens: TokenSet | null, now: number = Date.now()): boolean {
if (!tokens) return true;
if (tokens.expiresAt === undefined) return false;
return now >= tokens.expiresAt - EXPIRY_SKEW_MS;
}
// getValidToken returns a usable access token, transparently refreshing an
// expired one when a refresh_token is present. Returns '' when there is no
// session (never signed in) or the refresh failed — the caller then either runs
// login() or proceeds anonymously. This is the ONE function every surface calls
// before an authenticated request.
export async function getValidToken(client: AuthClient, store: TokenStore): Promise<string> {
const current = await store.get();
if (!current) return '';
if (!isExpired(current)) return current.accessToken;
if (!current.refreshToken) return current.accessToken; // no refresh path; let the server 401
try {
const next = await refreshTokens({
clientId: client.clientId,
refreshToken: current.refreshToken,
apiIssuer: client.apiIssuer,
});
await store.set(next);
return next.accessToken;
} catch {
return ''; // refresh failed (revoked/expired) — surface re-prompts login
}
}
// logout clears the stored session.
export async function logout(store: TokenStore): Promise<void> {
await store.clear();
}
// currentUser returns the signed-in identity for onboarding UIs, or null when
// there is no valid session. Refreshes if needed.
export async function currentUser(client: AuthClient, store: TokenStore): Promise<UserInfo | null> {
const token = await getValidToken(client, store);
if (!token) return null;
try {
return await fetchUserInfo(token, client.apiIssuer);
} catch {
return null;
}
}
+21
View File
@@ -0,0 +1,21 @@
// @hanzo/auth — the ONE Hanzo IAM auth core. Every extension (browser, office,
// vscode, cli, mcp) imports from here: canonical HIP-0111 endpoints, PKCE, the
// OAuth2 authorization-code + PKCE flow, refresh, and userinfo. A surface
// supplies only a TokenStore (persistence) and an Opener (the interactive leg);
// the flow itself lives once, in flow.ts.
export * from './config.js';
export * from './pkce.js';
export * from './oauth.js';
export * from './flow.js';
export type { TokenSet, TokenStore, Opener } from './types.js';
// Convenience: resolve a surface's AuthClient from the CLIENTS registry so a
// caller writes `authClientFor('office')` instead of assembling ids/redirects.
import { CLIENTS, type SurfaceName } from './config.js';
import type { AuthClient } from './flow.js';
export function authClientFor(surface: SurfaceName, overrides: Partial<AuthClient> = {}): AuthClient {
const c = CLIENTS[surface];
return { clientId: c.id, redirectUri: c.redirectUri, ...overrides };
}
+133
View File
@@ -0,0 +1,133 @@
// The OAuth2/OIDC HTTP calls — pure functions over `fetch`. No storage, no UI,
// no runtime specifics. Every authorize URL and token exchange in the Hanzo
// extension family is built HERE, against config.ts, so HIP-0111 paths are
// assembled exactly once.
import { authorizeURL, tokenURL, userinfoURL, DEFAULT_SCOPES, IAM_LOGIN_URL, IAM_API_URL } from './config.js';
import type { TokenSet } from './types.js';
export interface AuthorizeParams {
clientId: string;
redirectUri: string;
challenge: string;
state: string;
scope?: string;
loginIssuer?: string;
}
// buildAuthorizeURL assembles the authorization-code + PKCE (S256) URL.
export function buildAuthorizeURL(p: AuthorizeParams): string {
const q = new URLSearchParams({
response_type: 'code',
client_id: p.clientId,
redirect_uri: p.redirectUri,
scope: p.scope || DEFAULT_SCOPES,
state: p.state,
code_challenge: p.challenge,
code_challenge_method: 'S256',
});
return `${authorizeURL(p.loginIssuer || IAM_LOGIN_URL)}?${q.toString()}`;
}
// normalizeTokens converts an OIDC token response into our TokenSet, turning the
// relative expires_in into an absolute expiresAt deadline (now-based).
export function normalizeTokens(raw: any, now: number = Date.now()): TokenSet {
if (!raw || typeof raw.access_token !== 'string' || !raw.access_token) {
throw new Error('token response missing access_token');
}
const set: TokenSet = { accessToken: raw.access_token };
if (raw.refresh_token) set.refreshToken = raw.refresh_token;
if (raw.id_token) set.idToken = raw.id_token;
if (raw.token_type) set.tokenType = raw.token_type;
if (raw.scope) set.scope = raw.scope;
if (typeof raw.expires_in === 'number') set.expiresAt = now + raw.expires_in * 1000;
return set;
}
// exchangeCode trades an authorization code for tokens (RFC 6749 §4.1.3 +
// PKCE). Form-encoded per the spec — application/json is NOT accepted by IAM.
export async function exchangeCode(args: {
clientId: string;
code: string;
verifier: string;
redirectUri: string;
apiIssuer?: string;
}): Promise<TokenSet> {
const resp = await fetch(tokenURL(args.apiIssuer || IAM_API_URL), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: args.clientId,
code: args.code,
code_verifier: args.verifier,
redirect_uri: args.redirectUri,
}),
});
if (!resp.ok) {
throw new Error(`token exchange failed: ${resp.status} ${await safeText(resp)}`);
}
return normalizeTokens(await resp.json());
}
// refreshTokens uses a refresh_token to mint a fresh access token. IAM may or
// may not rotate the refresh_token; if it omits one we keep the old.
export async function refreshTokens(args: {
clientId: string;
refreshToken: string;
apiIssuer?: string;
}): Promise<TokenSet> {
const resp = await fetch(tokenURL(args.apiIssuer || IAM_API_URL), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: args.clientId,
refresh_token: args.refreshToken,
}),
});
if (!resp.ok) {
throw new Error(`refresh failed: ${resp.status} ${await safeText(resp)}`);
}
const set = normalizeTokens(await resp.json());
if (!set.refreshToken) set.refreshToken = args.refreshToken; // reuse when not rotated
return set;
}
// UserInfo is the OIDC userinfo shape the surfaces render for onboarding.
export interface UserInfo {
sub: string;
name?: string;
email?: string;
org?: string;
picture?: string;
}
export async function fetchUserInfo(accessToken: string, apiIssuer?: string): Promise<UserInfo> {
const resp = await fetch(userinfoURL(apiIssuer || IAM_API_URL), {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!resp.ok) throw new Error(`userinfo failed: ${resp.status}`);
const j = await resp.json();
return { sub: j.sub, name: j.name, email: j.email, org: j.org || j.owner, picture: j.picture };
}
// parseCallback extracts { code, state, error } from the redirect URL IAM lands
// on (the Opener resolves this). Pure.
export function parseCallback(redirectUrl: string): { code?: string; state?: string; error?: string } {
const qi = redirectUrl.indexOf('?');
const params = new URLSearchParams(qi >= 0 ? redirectUrl.slice(qi + 1) : '');
return {
code: params.get('code') || undefined,
state: params.get('state') || undefined,
error: params.get('error') || undefined,
};
}
async function safeText(resp: Response): Promise<string> {
try {
return (await resp.text()).slice(0, 200);
} catch {
return '';
}
}
+36
View File
@@ -0,0 +1,36 @@
// PKCE (RFC 7636) — pure crypto over the platform CSPRNG + WebCrypto, so it runs
// identically in a browser extension, an Office task pane, VS Code's extension
// host, and Node. The ONE PKCE implementation for every Hanzo surface.
export function base64url(bytes: Uint8Array): string {
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
const b64 = typeof btoa === 'function' ? btoa(bin) : Buffer.from(bytes).toString('base64');
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function randomString(bytes = 32): string {
const buf = new Uint8Array(bytes);
(globalThis.crypto as Crypto).getRandomValues(buf);
return base64url(buf);
}
// challengeFromVerifier = BASE64URL(SHA256(verifier)) — the S256 method.
export async function challengeFromVerifier(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const digest = await (globalThis.crypto as Crypto).subtle.digest('SHA-256', data);
return base64url(new Uint8Array(digest));
}
export interface Pkce {
verifier: string;
challenge: string;
state: string;
}
export async function createPkce(): Promise<Pkce> {
const verifier = randomString(32);
const challenge = await challengeFromVerifier(verifier);
const state = randomString(16);
return { verifier, challenge, state };
}
+32
View File
@@ -0,0 +1,32 @@
// The runtime-agnostic contracts a surface implements. The flow (flow.ts) is
// written ONCE against these; browser / office / vscode / cli differ only in the
// adapter they pass. This is what makes "one auth, many surfaces" real.
// TokenSet is the persisted credential — the OIDC token response, normalized.
export interface TokenSet {
accessToken: string;
refreshToken?: string;
idToken?: string;
// expiresAt is an absolute epoch-ms deadline (NOT the relative expires_in),
// so validity is a pure comparison with the clock, storable and portable.
expiresAt?: number;
tokenType?: string;
scope?: string;
}
// TokenStore is the per-surface persistence: chrome.storage (browser),
// SecretStorage (vscode), roamingSettings (office), a 0600 file (cli). Async so
// every backend fits. clear() removes the credential (logout).
export interface TokenStore {
get(): Promise<TokenSet | null>;
set(tokens: TokenSet): Promise<void>;
clear(): Promise<void>;
}
// Opener drives the interactive leg: open the authorize URL and resolve with the
// full redirect URL IAM lands on (carrying ?code=&state=). Browser: a tab +
// tabs.onUpdated. Office: the Dialog API. VS Code: env.openExternal + a Uri
// handler. CLI: a loopback HTTP server. The flow never knows which.
export interface Opener {
open(authorizeUrl: string, redirectUri: string): Promise<string>;
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { authorizeURL, tokenURL, userinfoURL, CLIENTS, IAM_API_PATH } from '../src/config';
import { authClientFor } from '../src/index';
describe('canonical HIP-0111 endpoints — the drift guard', () => {
it('authorize/token/userinfo ALL carry the /v1/iam prefix', () => {
// This is the exact bug that shipped in vscode: hanzo.id/oauth/authorize,
// missing /v1/iam. Pin every path so it can never regress.
expect(authorizeURL()).toBe('https://hanzo.id/v1/iam/oauth/authorize');
expect(tokenURL()).toBe('https://iam.hanzo.ai/v1/iam/oauth/token');
expect(userinfoURL()).toBe('https://iam.hanzo.ai/v1/iam/oauth/userinfo');
expect(IAM_API_PATH).toBe('/v1/iam');
});
it('a custom issuer keeps the /v1/iam prefix', () => {
expect(authorizeURL('https://id.acme.test')).toBe('https://id.acme.test/v1/iam/oauth/authorize');
expect(tokenURL('https://iam.acme.test/')).toBe('https://iam.acme.test/v1/iam/oauth/token');
});
});
describe('surface client registry — one client per surface', () => {
it('every surface has a distinct hanzo-<surface> id', () => {
expect(CLIENTS.browser.id).toBe('hanzo-browser');
expect(CLIENTS.office.id).toBe('hanzo-office');
expect(CLIENTS.vscode.id).toBe('hanzo-vscode');
expect(CLIENTS.cli.id).toBe('hanzo-cli');
const ids = Object.values(CLIENTS).map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('authClientFor resolves id + redirect and honors overrides', () => {
const c = authClientFor('office');
expect(c.clientId).toBe('hanzo-office');
expect(c.redirectUri).toBe('https://office.hanzo.ai/auth-callback.html');
expect(authClientFor('office', { redirectUri: 'https://localhost:3000/cb' }).redirectUri).toBe(
'https://localhost:3000/cb',
);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { login, getValidToken, logout, isExpired, currentUser } from '../src/flow';
import type { TokenStore, TokenSet, Opener } from '../src/types';
import { authClientFor } from '../src/index';
// memStore is the fake per-surface TokenStore — the same seam browser/office/
// vscode/cli implement over their real storage.
function memStore(initial: TokenSet | null = null): TokenStore & { value: TokenSet | null } {
return {
value: initial,
async get() { return this.value; },
async set(t) { this.value = t; },
async clear() { this.value = null; },
};
}
// fakeOpener returns a canned redirect URL (what IAM would land on).
function fakeOpener(redirectUrl: (authorizeUrl: string) => string): Opener {
return { async open(authorizeUrl) { return redirectUrl(authorizeUrl); } };
}
const client = authClientFor('office');
describe('login', () => {
afterEach(() => vi.unstubAllGlobals());
it('runs PKCE → authorize → exchange → store', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }) } as any)));
const store = memStore();
// Echo the state from the authorize URL back in the redirect so it matches.
const opener = fakeOpener((authUrl) => {
const state = new URL(authUrl).searchParams.get('state');
return `https://office.hanzo.ai/auth-callback.html?code=CODE&state=${state}`;
});
const tokens = await login(client, store, opener);
expect(tokens.accessToken).toBe('AT');
expect(store.value?.accessToken).toBe('AT');
});
it('rejects a state mismatch (CSRF guard)', async () => {
vi.stubGlobal('fetch', vi.fn());
const store = memStore();
const opener = fakeOpener(() => 'https://office.hanzo.ai/auth-callback.html?code=CODE&state=WRONG');
await expect(login(client, store, opener)).rejects.toThrow(/state mismatch/);
expect(store.value).toBeNull();
});
it('surfaces an IAM error param', async () => {
const store = memStore();
const opener = fakeOpener(() => 'https://office.hanzo.ai/auth-callback.html?error=access_denied');
await expect(login(client, store, opener)).rejects.toThrow(/access_denied/);
});
});
describe('isExpired', () => {
it('null / past-deadline is expired; no-deadline is not', () => {
expect(isExpired(null)).toBe(true);
expect(isExpired({ accessToken: 'a' })).toBe(false);
expect(isExpired({ accessToken: 'a', expiresAt: 1000 }, 999_999)).toBe(true);
expect(isExpired({ accessToken: 'a', expiresAt: 10_000_000 }, 1000)).toBe(false);
});
});
describe('getValidToken', () => {
afterEach(() => vi.unstubAllGlobals());
it('returns the token when fresh (no network)', async () => {
const store = memStore({ accessToken: 'FRESH', expiresAt: Date.now() + 3600_000 });
expect(await getValidToken(client, store)).toBe('FRESH');
});
it('refreshes transparently when expired', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'NEW', expires_in: 3600 }) } as any)));
const store = memStore({ accessToken: 'OLD', refreshToken: 'RT', expiresAt: Date.now() - 1 });
expect(await getValidToken(client, store)).toBe('NEW');
expect(store.value?.accessToken).toBe('NEW');
expect(store.value?.refreshToken).toBe('RT'); // reused
});
it('returns empty when refresh fails (revoked) so the surface re-prompts', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 401, text: async () => 'invalid_grant' } as any)));
const store = memStore({ accessToken: 'OLD', refreshToken: 'RT', expiresAt: Date.now() - 1 });
expect(await getValidToken(client, store)).toBe('');
});
it('returns empty when never signed in', async () => {
expect(await getValidToken(client, memStore())).toBe('');
});
});
describe('logout + currentUser', () => {
afterEach(() => vi.unstubAllGlobals());
it('logout clears the store', async () => {
const store = memStore({ accessToken: 'a' });
await logout(store);
expect(store.value).toBeNull();
});
it('currentUser returns null with no session, identity with one', async () => {
expect(await currentUser(client, memStore())).toBeNull();
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ sub: 'u1', email: 'z@hanzo.ai' }) } as any)));
const u = await currentUser(client, memStore({ accessToken: 'AT', expiresAt: Date.now() + 3600_000 }));
expect(u?.email).toBe('z@hanzo.ai');
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
buildAuthorizeURL, normalizeTokens, exchangeCode, refreshTokens, parseCallback, fetchUserInfo,
} from '../src/oauth';
import { challengeFromVerifier } from '../src/pkce';
describe('buildAuthorizeURL', () => {
it('is the HIP-0111 authorize URL with PKCE S256', () => {
const u = new URL(buildAuthorizeURL({
clientId: 'hanzo-office', redirectUri: 'https://office.hanzo.ai/auth-callback.html',
challenge: 'CHAL', state: 'STATE',
}));
expect(u.origin + u.pathname).toBe('https://hanzo.id/v1/iam/oauth/authorize');
expect(u.searchParams.get('response_type')).toBe('code');
expect(u.searchParams.get('client_id')).toBe('hanzo-office');
expect(u.searchParams.get('code_challenge_method')).toBe('S256');
expect(u.searchParams.get('code_challenge')).toBe('CHAL');
expect(u.searchParams.get('state')).toBe('STATE');
});
});
describe('normalizeTokens', () => {
it('turns expires_in into an absolute expiresAt', () => {
const t = normalizeTokens({ access_token: 'a', refresh_token: 'r', expires_in: 3600 }, 1_000_000);
expect(t.accessToken).toBe('a');
expect(t.refreshToken).toBe('r');
expect(t.expiresAt).toBe(1_000_000 + 3600_000);
});
it('throws without an access_token', () => {
expect(() => normalizeTokens({})).toThrow(/access_token/);
});
});
describe('parseCallback', () => {
it('extracts code/state/error from a redirect URL', () => {
expect(parseCallback('https://x/cb?code=abc&state=xyz')).toEqual({ code: 'abc', state: 'xyz', error: undefined });
expect(parseCallback('https://x/cb?error=access_denied').error).toBe('access_denied');
});
});
describe('exchangeCode + refreshTokens (form-encoded, HIP-0111)', () => {
afterEach(() => vi.unstubAllGlobals());
it('POSTs form-urlencoded to the /v1/iam token URL and normalizes', async () => {
const fetchMock = vi.fn(async (url: string, init: any) => {
expect(url).toBe('https://iam.hanzo.ai/v1/iam/oauth/token');
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
const body = new URLSearchParams(init.body.toString());
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('code_verifier')).toBe('ver');
return { ok: true, json: async () => ({ access_token: 'AT', refresh_token: 'RT', expires_in: 60 }) } as any;
});
vi.stubGlobal('fetch', fetchMock);
const t = await exchangeCode({ clientId: 'hanzo-vscode', code: 'c', verifier: 'ver', redirectUri: 'https://hanzo.ai/callback' });
expect(t.accessToken).toBe('AT');
expect(fetchMock).toHaveBeenCalledOnce();
});
it('refresh reuses the old refresh_token when IAM does not rotate it', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'AT2', expires_in: 60 }) } as any)));
const t = await refreshTokens({ clientId: 'hanzo-cli', refreshToken: 'OLD' });
expect(t.accessToken).toBe('AT2');
expect(t.refreshToken).toBe('OLD');
});
it('surfaces a failed exchange', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 400, text: async () => 'invalid_grant' } as any)));
await expect(exchangeCode({ clientId: 'x', code: 'c', verifier: 'v', redirectUri: 'r' })).rejects.toThrow(/400/);
});
it('userinfo reads sub/email/org', async () => {
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => {
expect(url).toBe('https://iam.hanzo.ai/v1/iam/oauth/userinfo');
expect(init.headers.Authorization).toBe('Bearer AT');
return { ok: true, json: async () => ({ sub: 'u1', email: 'z@hanzo.ai', owner: 'acme' }) } as any;
}));
const u = await fetchUserInfo('AT');
expect(u).toEqual({ sub: 'u1', name: undefined, email: 'z@hanzo.ai', org: 'acme', picture: undefined });
});
});
describe('PKCE round-trips with the S256 challenge', () => {
it('RFC 7636 test vector', async () => {
expect(await challengeFromVerifier('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')).toBe(
'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
);
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/browser-extension",
"version": "1.9.27",
"version": "1.9.30",
"description": "Hanzo AI Browser Extension",
"main": "dist/background.js",
"scripts": {
+4
View File
@@ -0,0 +1,4 @@
{
"scriptId": "<APPS_SCRIPT_PROJECT_ID>",
"rootDir": "src"
}
+4
View File
@@ -0,0 +1,4 @@
# clasp pushes rootDir (src/) — everything outside it is dev-only tooling.
**/**
!appsscript.json
!*.gs
+5
View File
@@ -0,0 +1,5 @@
# Per-developer clasp state — scriptId + auth are not shared. Commit only
# .clasp.json.example.
.clasp.json
.clasprc.json
node_modules/
+168
View File
@@ -0,0 +1,168 @@
# Hanzo AI for Google Workspace
A Google Workspace **add-on** that puts Hanzo AI inside **Google Docs, Sheets,
Slides, and Gmail** — the Google-ecosystem counterpart to
[`@hanzo/office-addin`](../office) (Word/Excel/PowerPoint). Open the Hanzo panel
from the add-on sidebar, pick a model, ask (or tap a quick action), and insert
the result into the document.
It reuses the **same backend as everything else Hanzo**: model calls go through
`api.hanzo.ai/v1` (OpenAI-compatible) via `UrlFetchApp`. No new API surface.
## Architecture
Google Workspace Add-ons run on **Apps Script** (server-side JS in Google's V8
runtime) with a **CardService** UI — not HTML/JS in a task pane. Apps Script has
no `fetch`/SSE, so every model call is **one non-streaming completion**
(`UrlFetchApp.fetch` → insert), which keeps the insert atomic.
```
src/
appsscript.json add-on manifest: scopes, urlFetchWhitelist, per-host triggers
hanzo.gs API client — buildMessages / requestBody / extractContent /
parseModels (pure, tested) + ask / listModels (UrlFetchApp) +
the per-user API-key & model store (PropertiesService)
ui.gs CardService UI + every entry point the manifest names:
homepage triggers, Gmail contextual/compose triggers,
settings + universal actions, button handlers
docs.gs Docs: read selection/body, insert/replace at cursor
sheets.gs Sheets: read range as TSV, write result down a column
slides.gs Slides: read selected text/shapes, insert
gmail.gs Gmail: read the open message/thread, draft a reply
tests/ vitest over the pure logic (loads the .gs in a Node sandbox)
scripts/ validate-manifest.mjs — preflight before `clasp push`
```
The pure functions live **once** in the `.gs` files and end with a guarded
`module.exports` (inert in Apps Script — it has no `module` — but live under
Node), so the tests exercise the exact shipped code with zero duplication.
## Auth
Paste a Hanzo **API key** (`hk-…`, created at
[console.hanzo.ai/api-keys](https://console.hanzo.ai/api-keys)) into the add-on
**Settings** card. It is stored in `PropertiesService.getUserProperties()`
(per-user, never leaves the account) and sent as `Authorization: Bearer <key>`.
No key means the add-on shows an onboarding card first.
## OAuth scopes (in `appsscript.json`)
| Scope | Why |
| --- | --- |
| `documents.currentonly` | read/insert in the open Doc |
| `spreadsheets.currentonly` | read range / write cell in the open Sheet |
| `presentations.currentonly` | read/insert in the open Slides deck |
| `gmail.addons.current.message.readonly` | read the open message/thread |
| `gmail.addons.current.message.metadata` | message metadata for the contextual trigger |
| `gmail.addons.current.action.compose` | open a draft reply |
| `gmail.addons.execute` | run the Gmail add-on |
| `script.external_request` | `UrlFetchApp` to `api.hanzo.ai` |
| `script.locale` | user locale |
`urlFetchWhitelist` is pinned to `https://api.hanzo.ai/` — the add-on can call
**nothing else**.
## Develop & test
```bash
pnpm --filter @hanzo/gworkspace-addon test # vitest over the pure logic
pnpm --filter @hanzo/gworkspace-addon validate # manifest + .gs preflight
```
`validate` checks that `appsscript.json` is valid JSON with the required scopes
and the `api.hanzo.ai` whitelist, that every `runFunction`/`onTriggerFunction`
the manifest names is defined in a `.gs`, and that every `.gs` parses.
## Deploy (clasp → Apps Script → Workspace Add-on)
[`clasp`](https://github.com/google/clasp) is the Apps Script CLI. It is a
devDependency of this package.
### 1. One-time: log in and create the Apps Script project
```bash
cd packages/gworkspace
pnpm install # installs @google/clasp
pnpm exec clasp login # opens a browser; authorizes clasp
# Create a NEW standalone Apps Script project bound to nothing (add-ons are
# standalone, not container-bound). This writes .clasp.json for you:
pnpm exec clasp create --type standalone --title "Hanzo AI" --rootDir src
```
If the project already exists (a teammate created it), skip `create` and copy
`.clasp.json.example``.clasp.json`, filling in the `scriptId` from the Apps
Script editor (**Project Settings → IDs → Script ID**). `.clasp.json` is
git-ignored; only the `.example` is committed.
### 2. Push the code
```bash
pnpm exec clasp push # uploads src/*.gs + src/appsscript.json
```
`rootDir: src` and `.claspignore` mean only the `.gs` files and the manifest are
pushed — tests, scripts, and `node_modules` stay local.
> The **Google Cloud project** backing the Apps Script must have the
> **Google Workspace Add-ons API** and (for Gmail) the **Gmail API** enabled,
> and an **OAuth consent screen** configured. In the Apps Script editor:
> **Project Settings → Google Cloud Platform (GCP) Project** → switch to a
> standard GCP project you own, then enable those APIs in that project.
### 3. Test-deploy (install into your own account)
In the [Apps Script editor](https://script.google.com) for the project:
1. **Deploy → Test deployments**.
2. Under **Application(s): Google Workspace Add-on**, click **Install**.
3. Open Docs / Sheets / Slides / Gmail — the **Hanzo AI** add-on appears in the
right-hand add-on rail. Open it, go to **Settings**, paste your `hk-` key.
A test deployment installs the add-on **only for you**, from the current HEAD of
what you pushed — the fast inner loop.
### 4. Versioned deployment (for sharing / Marketplace)
```bash
pnpm exec clasp deploy --description "v1" # cuts a numbered version
pnpm exec clasp deployments # list deployment ids
```
A versioned deployment is what the Google Workspace Marketplace listing points
at.
## Publish to the Google Workspace Marketplace (staged)
Add-ons are distributed through the **Google Workspace Marketplace**, configured
via the **Google Cloud Console** on the same GCP project that backs the Apps
Script.
1. **Enable** the *Google Workspace Marketplace SDK* on the GCP project
(**APIs & Services → Library**).
2. **Marketplace SDK → App Configuration**:
- **Visibility: `Private`** (only your Workspace org) first, or **`Unlisted`**
(anyone with the link) for a wider staged test — **not `Public`** yet.
- **App integration: Google Workspace Add-on**, and paste the **deployment
ID** from `clasp deployments` (step 4 above).
- Declare the same OAuth scopes as `appsscript.json`.
3. **Marketplace SDK → Store Listing**: name (Hanzo AI), icons, screenshots,
description, support/privacy URLs (`hanzo.ai/support`,
`hanzo.ai/privacy`), then **Publish** to the chosen visibility.
4. **OAuth verification**: because the add-on requests Gmail scopes, a
**`Public`** listing requires Google's **OAuth app verification** (and, for
the restricted Gmail scopes, a **CASA security assessment**). Stay
`Private`/`Unlisted` until that clears — internal-org (`Private`) and
unlisted installs work without full verification.
**Staging path:** `Private` (your org) → `Unlisted` (link-shared beta) →
`Public` (after OAuth verification + CASA). Ship internal first.
## Not here yet
- **Streaming** — Apps Script has no SSE reader, so responses are single
non-streaming completions by design.
- **Hanzo OAuth (hanzo.id) sign-in** — the Office add-in offers an IAM OAuth
path in addition to the API key; here the API key is the one path (an OAuth
path would need a registered redirect on the Apps Script web-app URL). API key
is complete and zero-setup.
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@hanzo/gworkspace-addon",
"version": "1.9.30",
"description": "Hanzo AI for Google Workspace \u2014 a Google Docs, Sheets, Slides, and Gmail add-on (Apps Script + CardService). AI over your document, wired to the same api.hanzo.ai gateway as the browser extension and the Office add-in.",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"validate": "node scripts/validate-manifest.mjs",
"login": "clasp login",
"push": "clasp push",
"pull": "clasp pull",
"deploy": "clasp deploy",
"open": "clasp open"
},
"devDependencies": {
"@google/clasp": "^2.5.0",
"vitest": "^3.2.6"
},
"engines": {
"node": ">=18.0.0"
},
"license": "MIT"
}
@@ -0,0 +1,79 @@
// validate-manifest — a standalone preflight for `clasp push`:
// 1. appsscript.json is valid JSON with the required scopes + whitelist.
// 2. every runFunction / onTriggerFunction named in the manifest is defined
// in a src/*.gs file (no trigger pointing at a missing handler).
// 3. every src/*.gs parses as JavaScript.
// Exits non-zero on any failure so it can gate a push. `npm run validate`.
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import vm from 'node:vm';
const here = dirname(fileURLToPath(import.meta.url));
const srcDir = resolve(here, '..', 'src');
const fail = (msg) => {
console.error('✗ ' + msg);
process.exitCode = 1;
};
// 1. manifest
const manifest = JSON.parse(readFileSync(resolve(srcDir, 'appsscript.json'), 'utf8'));
const requiredScopes = [
'https://www.googleapis.com/auth/documents.currentonly',
'https://www.googleapis.com/auth/spreadsheets.currentonly',
'https://www.googleapis.com/auth/presentations.currentonly',
'https://www.googleapis.com/auth/gmail.addons.current.message.readonly',
'https://www.googleapis.com/auth/gmail.addons.current.action.compose',
'https://www.googleapis.com/auth/script.external_request',
];
for (const s of requiredScopes) {
if (!manifest.oauthScopes.includes(s)) fail('missing oauthScope: ' + s);
}
if (JSON.stringify(manifest.urlFetchWhitelist) !== JSON.stringify(['https://api.hanzo.ai/'])) {
fail('urlFetchWhitelist must be exactly ["https://api.hanzo.ai/"]');
}
// 2. collect every function the manifest references
const referenced = new Set();
const walk = (node) => {
if (!node || typeof node !== 'object') return;
for (const [k, v] of Object.entries(node)) {
if ((k === 'runFunction' || k === 'onTriggerFunction') && typeof v === 'string') {
referenced.add(v);
} else {
walk(v);
}
}
};
walk(manifest.addOns);
// 3. load every .gs, collect defined function names, and parse-check
const defined = new Set();
for (const file of readdirSync(srcDir).filter((f) => f.endsWith('.gs'))) {
const src = readFileSync(resolve(srcDir, file), 'utf8');
try {
new vm.Script(src, { filename: file });
} catch (e) {
fail(file + ' does not parse: ' + e.message);
continue;
}
for (const m of src.matchAll(/^function\s+([A-Za-z0-9_]+)\s*\(/gm)) defined.add(m[1]);
}
for (const fn of referenced) {
if (!defined.has(fn)) fail('manifest references undefined function: ' + fn + '()');
}
if (process.exitCode) {
console.error('\nvalidate-manifest FAILED');
} else {
console.log(
'✓ manifest scopes + whitelist OK; ' +
referenced.size +
' referenced functions all defined across ' +
readdirSync(srcDir).filter((f) => f.endsWith('.gs')).length +
' .gs files (all parse).'
);
}
+77
View File
@@ -0,0 +1,77 @@
{
"timeZone": "Etc/UTC",
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/documents.currentonly",
"https://www.googleapis.com/auth/spreadsheets.currentonly",
"https://www.googleapis.com/auth/presentations.currentonly",
"https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
"https://www.googleapis.com/auth/gmail.addons.current.message.metadata",
"https://www.googleapis.com/auth/gmail.addons.current.action.compose",
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/script.external_request",
"https://www.googleapis.com/auth/script.locale"
],
"urlFetchWhitelist": [
"https://api.hanzo.ai/"
],
"addOns": {
"common": {
"name": "Hanzo AI",
"logoUrl": "https://hanzo.ai/assets/icon-128.png",
"layoutProperties": {
"primaryColor": "#111111",
"secondaryColor": "#4f46e5"
},
"homepageTrigger": {
"runFunction": "onHomepage"
},
"universalActions": [
{
"label": "Settings — Hanzo API key",
"runFunction": "onSettings"
},
{
"label": "Get an API key",
"openLink": "https://console.hanzo.ai/api-keys"
}
]
},
"docs": {
"homepageTrigger": {
"runFunction": "onDocsHomepage"
}
},
"sheets": {
"homepageTrigger": {
"runFunction": "onSheetsHomepage"
}
},
"slides": {
"homepageTrigger": {
"runFunction": "onSlidesHomepage"
}
},
"gmail": {
"homepageTrigger": {
"runFunction": "onGmailHomepage"
},
"contextualTriggers": [
{
"unconditional": {},
"onTriggerFunction": "onGmailMessage"
}
],
"composeTrigger": {
"selectActions": [
{
"text": "Draft with Hanzo AI",
"runFunction": "onGmailCompose"
}
],
"draftAccess": "METADATA"
}
}
}
}
+84
View File
@@ -0,0 +1,84 @@
/**
* docs.gs — Google Docs host glue.
*
* Read the selection (or the whole body when nothing is selected), run the
* task, and insert/replace at the cursor. Mirrors the Word path in
* packages/office/src/host.ts.
*/
/** docsSelectionText returns the selected text, or '' when nothing is selected. */
function docsSelectionText() {
var doc = DocumentApp.getActiveDocument();
var sel = doc.getSelection();
if (!sel) return '';
var parts = [];
var elements = sel.getRangeElements();
for (var i = 0; i < elements.length; i++) {
var el = elements[i];
var e = el.getElement();
if (!e.editAsText) continue;
var t = e.asText().getText();
if (el.isPartial()) t = t.substring(el.getStartOffset(), el.getEndOffsetInclusive() + 1);
if (t) parts.push(t);
}
return parts.join('\n');
}
/** docsContextText is the selection if present, else the full body — the model's input. */
function docsContextText() {
var sel = docsSelectionText();
if (sel) return sel;
return DocumentApp.getActiveDocument().getBody().getText();
}
/**
* docsInsert writes the result into the doc. When there is a selection we
* replace it; otherwise we insert at the cursor (or append to the body when
* there is no cursor, e.g. right after a programmatic selection).
*/
function docsInsert(text) {
var doc = DocumentApp.getActiveDocument();
var sel = doc.getSelection();
if (sel) {
replaceSelection_(sel, text);
return;
}
var cursor = doc.getCursor();
if (cursor) {
var inserted = cursor.insertText(text);
if (inserted) doc.setCursor(doc.newPosition(inserted, text.length));
return;
}
doc.getBody().appendParagraph(text);
}
// replaceSelection_ deletes the selected content and inserts the result in its
// place, at the first partial/whole element of the selection.
function replaceSelection_(sel, text) {
var elements = sel.getRangeElements();
var anchor = null;
var anchorOffset = 0;
for (var i = 0; i < elements.length; i++) {
var el = elements[i];
var e = el.getElement();
if (!e.editAsText) continue;
var t = e.asText();
if (el.isPartial()) {
if (anchor === null) {
anchor = t;
anchorOffset = el.getStartOffset();
}
t.deleteText(el.getStartOffset(), el.getEndOffsetInclusive());
} else if (i === 0) {
anchor = t;
anchorOffset = 0;
t.setText('');
} else if (e.getParent() && e.getParent().getChildIndex) {
var parent = e.getParent();
if (parent.getNumChildren && parent.getNumChildren() > 1) e.removeFromParent();
else t.setText('');
}
}
if (anchor) anchor.insertText(anchorOffset, text);
else DocumentApp.getActiveDocument().getBody().appendParagraph(text);
}
+45
View File
@@ -0,0 +1,45 @@
/**
* gmail.gs — Gmail host glue.
*
* Read the open message / thread from the contextual-trigger event and run a
* task. The gmail.addons.current.* scopes give per-message access; the
* current-message token must be set before reading via GmailApp. The UI action
* handlers (ui.gs) call these, then render or draft with the result.
*/
/** gmailCurrentMessage_ resolves the GmailMessage for the current event, after
* authorizing the per-message access token. Returns null when there is none. */
function gmailCurrentMessage_(e) {
if (!e || !e.gmail || !e.gmail.messageId) return null;
if (e.gmail.accessToken) GmailApp.setCurrentMessageAccessToken(e.gmail.accessToken);
return GmailApp.getMessageById(e.gmail.messageId);
}
/** gmailMessageText returns the plain-text body of the message in the event e. */
function gmailMessageText(e) {
var message = gmailCurrentMessage_(e);
if (!message) return '';
return message.getPlainBody() || message.getBody() || '';
}
/** gmailThreadText joins the plain-text bodies of every message in the thread,
* each tagged with its sender — the model's input for "summarize the thread". */
function gmailThreadText(e) {
var message = gmailCurrentMessage_(e);
if (!message) return '';
var messages = message.getThread().getMessages();
var parts = [];
for (var i = 0; i < messages.length; i++) {
var m = messages[i];
parts.push('From: ' + m.getFrom() + '\n' + (m.getPlainBody() || m.getBody() || ''));
}
return parts.join('\n\n---\n\n');
}
/** gmailDraftReply builds a compose ActionResponse that opens a reply draft on
* the current message, prefilled with `text`. */
function gmailDraftReply(e, text) {
var message = gmailCurrentMessage_(e);
var draft = message.createDraftReply(text);
return CardService.newComposeActionResponseBuilder().setGmailDraft(draft).build();
}
+205
View File
@@ -0,0 +1,205 @@
/**
* hanzo.gs — the Hanzo API client for the Google Workspace add-on.
*
* Mirrors packages/office/src/{config,chat}.ts, adapted to Apps Script:
* requests go through UrlFetchApp (no fetch/SSE, so one non-streaming
* completion per call) and the api.hanzo.ai/v1 gateway. The API key lives in
* PropertiesService.getUserProperties() (per-user, roams with the account).
*
* The pure functions (buildMessages, requestBody, extractContent) have no Apps
* Script dependency and are the tested core — they are re-exported for Node
* under the guarded module.exports at the bottom (inert in Apps Script).
*/
var API_BASE_URL = 'https://api.hanzo.ai';
// Default model. Overridable per-request; the gateway routes it.
var DEFAULT_MODEL = 'zen-1';
// PropertiesService key for the pasted Hanzo `hk-` API key.
var APIKEY_PROP = 'hanzo.apiKey';
// PropertiesService key for the preferred model id.
var MODEL_PROP = 'hanzo.model';
// chatCompletionsURL / modelsURL — the model gateway endpoints. api.hanzo.ai/v1
// only; never an /api/ prefix.
function chatCompletionsURL() {
return API_BASE_URL + '/v1/chat/completions';
}
function modelsURL() {
return API_BASE_URL + '/v1/models';
}
/**
* buildMessages turns a task (the user's instruction) plus a selection into the
* OpenAI-compatible message list. Empty selection = pure generation; non-empty =
* an operation ON the text, fenced so the model treats it as data.
*/
function buildMessages(task, selection) {
var system = {
role: 'system',
content:
'You are Hanzo AI inside a Google Workspace document. Return ONLY the text ' +
'to insert — no preamble, no markdown fences, no "Here is". Match the ' +
"document's tone. When given a selection, operate on it directly.",
};
var trimmed = (selection || '').trim();
var user = {
role: 'user',
content: trimmed ? task + '\n\n---- selected text ----\n' + selection : task,
};
return [system, user];
}
/**
* requestBody is the exact JSON the gateway receives. stream is always false —
* Apps Script has no streaming reader, so every call is one-shot.
*/
function requestBody(messages, opts) {
opts = opts || {};
var body = {
model: opts.model || DEFAULT_MODEL,
messages: messages,
stream: false,
};
if (opts.temperature !== undefined) body.temperature = opts.temperature;
return body;
}
/**
* extractContent pulls the assistant text out of an OpenAI-compatible response,
* tolerating the shapes the gateway returns. Throws on an error payload so the
* card surfaces the real gateway message.
*/
function extractContent(data) {
if (data && data.error) {
var msg = typeof data.error === 'string' ? data.error : data.error.message || 'unknown error';
throw new Error('Hanzo API error: ' + msg);
}
var choice = data && data.choices && data.choices[0];
var content =
(choice && choice.message && choice.message.content) ||
(choice && choice.text) ||
(data && data.content) ||
'';
if (typeof content !== 'string' || content === '') {
throw new Error('Hanzo API returned no content');
}
return content;
}
/**
* parseModels pulls the routable model ids out of a /v1/models response,
* tolerating {data:[...]}, {models:[...]}, a bare array, and string-or-object
* entries. Pure — the network read is in listModels.
*/
function parseModels(data) {
var items =
(data && data.data) || (data && data.models) || (Array.isArray(data) ? data : []);
var ids = [];
for (var i = 0; i < items.length; i++) {
var m = items[i];
var id = typeof m === 'string' ? m : m && m.id;
if (typeof id === 'string' && id.length > 0) ids.push(id);
}
return ids;
}
// ── UrlFetchApp calls (Apps Script only) ──────────────────────────────────
/**
* ask runs one non-streaming completion and returns the text to insert. token
* may be empty (the gateway serves anonymous/limited models); when present it is
* the Hanzo `hk-` key.
*/
function ask(task, selection, token, opts) {
var headers = {};
if (token) headers.Authorization = 'Bearer ' + token;
var resp = UrlFetchApp.fetch(chatCompletionsURL(), {
method: 'post',
contentType: 'application/json',
headers: headers,
payload: JSON.stringify(requestBody(buildMessages(task, selection), opts)),
muteHttpExceptions: true,
});
var code = resp.getResponseCode();
var text = resp.getContentText();
var data;
try {
data = JSON.parse(text);
} catch (e) {
throw new Error('Hanzo API ' + code + ': ' + text.slice(0, 200));
}
if (code < 200 || code >= 300) {
try {
return extractContent(data);
} catch (e2) {
var m = (data && data.error && (data.error.message || data.error)) || 'HTTP ' + code;
throw new Error('Hanzo API ' + code + ': ' + m);
}
}
return extractContent(data);
}
/**
* listModels returns the routable model ids from /v1/models. Org-scoped by the
* bearer; an empty token lists public models. Non-fatal for callers — returns []
* on any failure so the picker falls back to the default.
*/
function listModels(token) {
try {
var headers = {};
if (token) headers.Authorization = 'Bearer ' + token;
var resp = UrlFetchApp.fetch(modelsURL(), {
method: 'get',
headers: headers,
muteHttpExceptions: true,
});
if (resp.getResponseCode() !== 200) return [];
return parseModels(JSON.parse(resp.getContentText()));
} catch (e) {
return [];
}
}
// ── PropertiesService: per-user API key + model preference ─────────────────
function getApiKey() {
return PropertiesService.getUserProperties().getProperty(APIKEY_PROP) || '';
}
function setApiKey(key) {
var props = PropertiesService.getUserProperties();
var trimmed = (key || '').trim();
if (trimmed) props.setProperty(APIKEY_PROP, trimmed);
else props.deleteProperty(APIKEY_PROP);
}
function getModel() {
return PropertiesService.getUserProperties().getProperty(MODEL_PROP) || DEFAULT_MODEL;
}
function setModel(model) {
var props = PropertiesService.getUserProperties();
if (model && model !== DEFAULT_MODEL) props.setProperty(MODEL_PROP, model);
else props.deleteProperty(MODEL_PROP);
}
/**
* runTask is the single entry every host calls: read the stored key + model,
* ask the gateway, return the text. One code path for Docs/Sheets/Slides/Gmail.
*/
function runTask(task, selection) {
return ask(task, selection, getApiKey(), { model: getModel() });
}
// Node-only export for unit tests (inert in Apps Script — no `module` there).
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
API_BASE_URL: API_BASE_URL,
DEFAULT_MODEL: DEFAULT_MODEL,
chatCompletionsURL: chatCompletionsURL,
modelsURL: modelsURL,
buildMessages: buildMessages,
requestBody: requestBody,
extractContent: extractContent,
parseModels: parseModels,
};
}
+63
View File
@@ -0,0 +1,63 @@
/**
* sheets.gs — Google Sheets host glue.
*
* Read the selected range as TSV, run the task, and write the result down a
* column from the selection's top-left. Mirrors the Excel path in
* packages/office/src/host.ts (rangeToText / linesToRows are pure and tested).
*/
/**
* rangeToText flattens a selected range's 2-D values into TSV: rows joined by
* newline, cells by tab, null/undefined as empty. Pure.
*/
function rangeToText(values) {
if (!values || values.length === 0) return '';
var rows = [];
for (var r = 0; r < values.length; r++) {
var cells = [];
for (var c = 0; c < values[r].length; c++) {
var v = values[r][c];
cells.push(v === null || v === undefined ? '' : String(v));
}
rows.push(cells.join('\t'));
}
return rows.join('\n');
}
/**
* linesToRows turns model output into a 1-column, N-row 2-D array for writing
* down a column. Trailing blank lines are dropped so a stray newline does not
* clear a cell. Pure.
*/
function linesToRows(text) {
var trimmed = String(text).replace(/\n+$/, '');
var lines = trimmed.split('\n');
var rows = [];
for (var i = 0; i < lines.length; i++) rows.push([lines[i]]);
return rows;
}
/** sheetsContextText returns the active range as TSV — the model's input. */
function sheetsContextText() {
var range = SpreadsheetApp.getActiveRange();
if (!range) return '';
return rangeToText(range.getValues());
}
/**
* sheetsInsert writes the result down a column from the active cell (a
* spreadsheet has no "after cursor"): the top-left of the current selection.
*/
function sheetsInsert(text) {
var sheet = SpreadsheetApp.getActiveSheet();
var range = SpreadsheetApp.getActiveRange();
var startRow = range ? range.getRow() : 1;
var startCol = range ? range.getColumn() : 1;
var rows = linesToRows(text);
sheet.getRange(startRow, startCol, rows.length, 1).setValues(rows);
}
// Node-only export for unit tests (inert in Apps Script — no `module` there).
if (typeof module !== 'undefined' && module.exports) {
module.exports = { rangeToText: rangeToText, linesToRows: linesToRows };
}
+58
View File
@@ -0,0 +1,58 @@
/**
* slides.gs — Google Slides host glue.
*
* Read the selected text (a text range inside a shape, or the shapes of the
* selected page), run the task, and insert. Mirrors the PowerPoint path in
* packages/office/src/host.ts.
*/
/** slidesContextText returns the selected text — a text-range selection wins,
* else the concatenated text of the selected page's shapes. */
function slidesContextText() {
var sel = SlidesApp.getActivePresentation().getSelection();
if (!sel) return '';
var textRange = sel.getTextRange();
if (textRange && textRange.asString()) return textRange.asString();
return pageShapesText_(sel);
}
// pageShapesText_ joins the text of every text-bearing shape on the selected
// page — the model's input when the user selected a slide, not a text range.
function pageShapesText_(sel) {
var page = sel.getCurrentPage();
if (!page) return '';
var parts = [];
var shapes = page.getShapes();
for (var i = 0; i < shapes.length; i++) {
var shape = shapes[i];
if (!shape.getText) continue;
var t = shape.getText().asString();
if (t && t.trim()) parts.push(t.trim());
}
return parts.join('\n');
}
/**
* slidesInsert writes the result into the selected text range (replacing it), or
* into the first text-bearing shape on the selected page, or a new text box.
*/
function slidesInsert(text) {
var presentation = SlidesApp.getActivePresentation();
var sel = presentation.getSelection();
var textRange = sel && sel.getTextRange();
if (textRange) {
textRange.setText(text);
return;
}
var page = sel && sel.getCurrentPage();
if (!page) page = presentation.getSlides()[0];
var shapes = page.getShapes();
for (var i = 0; i < shapes.length; i++) {
if (shapes[i].getText) {
shapes[i].getText().setText(text);
return;
}
}
var box = page.insertTextBox(text);
box.setLeft(30).setTop(30).setWidth(400).setHeight(200);
}
+468
View File
@@ -0,0 +1,468 @@
/**
* ui.gs — CardService UI + every add-on entry point.
*
* The manifest's runFunction names all resolve here: homepage triggers per host,
* the Gmail contextual + compose triggers, the settings/universal actions, and
* the button callbacks. The cards read/write through hanzo.gs (the API client)
* and the per-host glue (docs/sheets/slides/gmail.gs).
*
* The quick actions mirror the office task pane's chips: Draft, Summarize,
* Explain, Tighten (redline), Continue — plus Gmail-specific reply/actions.
*/
// QUICK_ACTIONS: the preset instructions, one source of truth for every host's
// action chips. { key, label, task } — task is empty for Draft (free prompt).
var QUICK_ACTIONS = [
{ key: 'draft', label: 'Draft', task: '' },
{ key: 'summarize', label: 'Summarize', task: 'Summarize the selection concisely.' },
{ key: 'explain', label: 'Explain', task: 'Explain the selection in plain language.' },
{
key: 'tighten',
label: 'Tighten (redline)',
task: 'Tighten and clarify the selection as a redline edit. Keep the meaning; cut the fluff.',
},
{ key: 'continue', label: 'Continue', task: 'Continue writing from where the selection ends.' },
];
// GMAIL_ACTIONS: Gmail-specific presets over the open message/thread.
var GMAIL_ACTIONS = [
{
key: 'summarize_thread',
label: 'Summarize thread',
task: 'Summarize this email thread: the ask, the decisions, and the open questions.',
},
{
key: 'action_items',
label: 'Extract action items',
task: 'Extract the concrete action items from this email as a short bullet list. Who owes what.',
},
{
key: 'draft_reply',
label: 'Draft a reply',
task: 'Draft a professional reply to this email. Return only the reply body.',
},
];
// ── Colors / branding ──────────────────────────────────────────────────────
var BRAND = 'Hanzo AI';
var ICON_URL = 'https://hanzo.ai/assets/icon-128.png';
// ── Homepage triggers (one per host + the common fallback) ─────────────────
function onHomepage(e) {
return buildHomeCard_(hostFromEvent_(e), null);
}
function onDocsHomepage(e) {
return buildHomeCard_('docs', null);
}
function onSheetsHomepage(e) {
return buildHomeCard_('sheets', null);
}
function onSlidesHomepage(e) {
return buildHomeCard_('slides', null);
}
function onGmailHomepage(e) {
return buildHomeCard_('gmail', null);
}
// onGmailMessage — Gmail contextual trigger: an open message. Same card, with a
// note that the actions read the open message.
function onGmailMessage(e) {
return buildHomeCard_('gmail', null);
}
// hostFromEvent_ reads the host from the common event; defaults to a generic card.
function hostFromEvent_(e) {
var host = e && e.commonEventObject && e.commonEventObject.hostApp;
if (host === 'DOCS') return 'docs';
if (host === 'SHEETS') return 'sheets';
if (host === 'SLIDES') return 'slides';
if (host === 'GMAIL') return 'gmail';
return 'docs';
}
// ── Home card ──────────────────────────────────────────────────────────────
/**
* buildHomeCard_ renders the sidebar: header, an onboarding hint when no key is
* set, the model picker, a free-prompt field, the quick-action buttons, and (on
* a prior run) the result with Insert. `state` carries prompt/output between
* button clicks (CardService is stateless — we thread it through form inputs).
*/
function buildHomeCard_(host, state) {
var hasKey = !!getApiKey();
var card = CardService.newCardBuilder().setHeader(
CardService.newCardHeader().setTitle(BRAND).setImageUrl(ICON_URL).setSubtitle(hostLabel_(host))
);
if (!hasKey) {
card.addSection(onboardingSection_());
return card.build();
}
card.addSection(promptSection_(host, state));
card.addSection(actionsSection_(host));
if (state && state.output) card.addSection(outputSection_(host, state));
card.setFixedFooter(settingsFooter_());
return card.build();
}
// onboardingSection_ — shown until an API key is saved: a one-tap path to keys +
// the paste field.
function onboardingSection_() {
var section = CardService.newCardSection().setHeader('Get started');
section.addWidget(
CardService.newTextParagraph().setText(
'Paste a Hanzo API key to use your models. Create one at ' +
'<b>console.hanzo.ai/api-keys</b>.'
)
);
section.addWidget(
CardService.newTextButton()
.setText('Get an API key')
.setOpenLink(CardService.newOpenLink().setUrl('https://console.hanzo.ai/api-keys'))
);
section.addWidget(apiKeyInput_(''));
section.addWidget(
CardService.newTextButton()
.setText('Save key')
.setOnClickAction(CardService.newAction().setFunctionName('onSaveKey'))
);
return section;
}
// promptSection_ — the model picker + the free-prompt textarea.
function promptSection_(host, state) {
var section = CardService.newCardSection();
section.addWidget(modelPicker_());
section.addWidget(
CardService.newTextInput()
.setFieldName('prompt')
.setTitle('Ask Hanzo')
.setHint('e.g. "draft an NDA mutual-confidentiality clause"')
.setMultiline(true)
.setValue(state && state.prompt ? state.prompt : '')
);
section.addWidget(
CardService.newTextButton()
.setText('Run')
.setTextButtonStyle(CardService.TextButtonStyle.FILLED)
.setOnClickAction(actionFor_('onRun', host, ''))
);
return section;
}
// actionsSection_ — the quick-action chips (per host). Each button runs its
// preset task through the current host's read → ask → (output card) path.
function actionsSection_(host) {
var section = CardService.newCardSection().setHeader('Quick actions');
var actions = host === 'gmail' ? GMAIL_ACTIONS : QUICK_ACTIONS;
for (var i = 0; i < actions.length; i++) {
var a = actions[i];
if (a.key === 'draft') continue; // Draft = the free prompt above
var fn = a.key === 'draft_reply' ? 'onDraftReply' : 'onRun';
section.addWidget(
CardService.newTextButton().setText(a.label).setOnClickAction(actionFor_(fn, host, a.task))
);
}
return section;
}
// outputSection_ — the last result with Insert (host-specific) + Copy hint.
function outputSection_(host, state) {
var section = CardService.newCardSection().setHeader('Result');
section.addWidget(CardService.newTextParagraph().setText(escapeHtml_(state.output)));
if (host !== 'gmail') {
section.addWidget(
CardService.newTextButton()
.setText('Insert into document')
.setTextButtonStyle(CardService.TextButtonStyle.FILLED)
.setOnClickAction(actionFor_('onInsert', host, '').setParameters({ output: state.output }))
);
}
return section;
}
// settingsFooter_ — persistent footer button to the settings card. Uses the
// card-action handler (pushCard), NOT the universal action (which returns a
// different response type).
function settingsFooter_() {
return CardService.newFixedFooter().setPrimaryButton(
CardService.newTextButton()
.setText('Settings')
.setOnClickAction(CardService.newAction().setFunctionName('onOpenSettings'))
);
}
// ── Model picker + inputs ──────────────────────────────────────────────────
function modelPicker_() {
var current = getModel();
var picker = CardService.newSelectionInput()
.setType(CardService.SelectionInputType.DROPDOWN)
.setTitle('Model')
.setFieldName('model')
.setOnChangeAction(CardService.newAction().setFunctionName('onSelectModel'));
var ids = listModels(getApiKey());
if (ids.indexOf(current) === -1) ids.unshift(current);
if (ids.length === 0) ids = [DEFAULT_MODEL];
for (var i = 0; i < ids.length; i++) {
picker.addItem(ids[i], ids[i], ids[i] === current);
}
return picker;
}
function apiKeyInput_(value) {
return CardService.newTextInput()
.setFieldName('apikey')
.setTitle('Hanzo API key (hk-…)')
.setHint('Stored per-user, never leaves your account.')
.setValue(value || '');
}
// ── Settings card ──────────────────────────────────────────────────────────
// settingsCard_ builds the settings card — one definition, two entry points
// (the universal action and the in-card footer button) that wrap it in their
// respective response types.
function settingsCard_() {
var card = CardService.newCardBuilder().setHeader(
CardService.newCardHeader().setTitle('Hanzo AI — Settings')
);
var section = CardService.newCardSection().setHeader('API key');
section.addWidget(
CardService.newTextParagraph().setText(
'Create a key at <b>console.hanzo.ai/api-keys</b> and paste it below. ' +
'It is stored in your per-user properties and sent only to api.hanzo.ai.'
)
);
section.addWidget(apiKeyInput_(getApiKey()));
section.addWidget(
CardService.newTextButton()
.setText('Save key')
.setTextButtonStyle(CardService.TextButtonStyle.FILLED)
.setOnClickAction(CardService.newAction().setFunctionName('onSaveKey'))
);
section.addWidget(
CardService.newTextButton()
.setText('Clear key')
.setOnClickAction(CardService.newAction().setFunctionName('onClearKey'))
);
card.addSection(section);
return card.build();
}
// onSettings — the manifest universal action. Returns a UniversalActionResponse.
function onSettings(e) {
return CardService.newUniversalActionResponseBuilder()
.displayAddOnCards([settingsCard_()])
.build();
}
// onOpenSettings — the in-card footer button. Returns an ActionResponse that
// pushes the settings card onto the navigation stack.
function onOpenSettings(e) {
return CardService.newActionResponseBuilder()
.setNavigation(CardService.newNavigation().pushCard(settingsCard_()))
.build();
}
// ── Action handlers ────────────────────────────────────────────────────────
// onSaveKey persists the pasted key. From the settings sub-card we pop back to
// the home card underneath (already the right host); from the onboarding card
// (no card beneath) we render a fresh home card so the prompt UI appears.
function onSaveKey(e) {
var key = formValue_(e, 'apikey');
setApiKey(key);
return notifyAndReturn_(e, key ? 'API key saved.' : 'API key cleared.');
}
function onClearKey(e) {
setApiKey('');
return notifyAndReturn_(e, 'API key cleared.');
}
// onSelectModel persists the picked model (no reload needed — the choice sticks
// via PropertiesService and the next Run/action reads it).
function onSelectModel(e) {
setModel(formValue_(e, 'model'));
return CardService.newActionResponseBuilder()
.setNotification(CardService.newNotification().setText('Model set.'))
.build();
}
/**
* onRun is the single button handler for the free prompt AND every quick action
* across Docs/Sheets/Slides/Gmail. It reads the host's context, asks the
* gateway, and re-renders the home card with the result.
*/
function onRun(e) {
var host = paramHost_(e);
var presetTask = paramTask_(e);
var prompt = presetTask || formValue_(e, 'prompt');
if (!prompt) return notify_('Type an instruction or pick an action first.');
try {
var context = readContext_(host, e);
var output = runTask(prompt, context);
return CardService.newActionResponseBuilder()
.setNavigation(
CardService.newNavigation().updateCard(
buildHomeCard_(host, { prompt: prompt, output: output })
)
)
.build();
} catch (err) {
return notify_(errMessage_(err));
}
}
/**
* onInsert writes the last result into the host document (Docs/Sheets/Slides).
* The output was threaded through the button's parameters.
*/
function onInsert(e) {
var host = paramHost_(e);
var output = e && e.parameters && e.parameters.output;
if (!output) return notify_('Nothing to insert.');
try {
insertResult_(host, output);
return notify_('Inserted into the ' + hostLabel_(host) + '.');
} catch (err) {
return notify_(errMessage_(err));
}
}
/**
* onDraftReply (Gmail) generates a reply and opens it as a Gmail draft — the
* compose ActionResponse.
*/
function onDraftReply(e) {
try {
var body = gmailThreadText(e) || gmailMessageText(e);
var task = 'Draft a professional reply to this email. Return only the reply body.';
var reply = runTask(task, body);
return gmailDraftReply(e, reply);
} catch (err) {
return notify_(errMessage_(err));
}
}
/**
* onGmailCompose (compose trigger, from the manifest composeTrigger) drafts body
* text for the message the user is composing and inserts it into the open draft.
* The compose context has no "current message"; it drafts from the recipients /
* subject the user already typed, or a brief professional email otherwise.
*/
function onGmailCompose(e) {
try {
var draft = e && e.draftMetadata;
var hint = draft
? 'To: ' + (draft.toRecipients || []).join(', ') + '\nSubject: ' + (draft.subject || '')
: '';
var task =
'Draft a professional email body. Return only the body text — no subject, no signature.';
var body = runTask(task, hint);
return CardService.newUpdateDraftActionResponseBuilder()
.setUpdateDraftBodyAction(
CardService.newUpdateDraftBodyAction()
.addUpdateContent(body, CardService.ContentType.PLAIN_TEXT)
.setUpdateType(CardService.UpdateDraftBodyType.IN_PLACE_INSERT)
)
.build();
} catch (err) {
return notify_(errMessage_(err));
}
}
// ── Host read/insert dispatch ──────────────────────────────────────────────
function readContext_(host, e) {
switch (host) {
case 'docs':
return docsContextText();
case 'sheets':
return sheetsContextText();
case 'slides':
return slidesContextText();
case 'gmail':
return gmailThreadText(e) || gmailMessageText(e);
default:
return '';
}
}
function insertResult_(host, text) {
switch (host) {
case 'docs':
docsInsert(text);
return;
case 'sheets':
sheetsInsert(text);
return;
case 'slides':
slidesInsert(text);
return;
default:
throw new Error('This host does not support insert.');
}
}
// ── Small helpers ──────────────────────────────────────────────────────────
// actionFor_ builds an Action for `fn` carrying the host + task as parameters
// (CardService actions can only pass string params, so we thread state here).
function actionFor_(fn, host, task) {
return CardService.newAction()
.setFunctionName(fn)
.setParameters({ host: host, task: task || '' });
}
function paramHost_(e) {
return (e && e.parameters && e.parameters.host) || hostFromEvent_(e);
}
function paramTask_(e) {
return (e && e.parameters && e.parameters.task) || '';
}
// formValue_ reads a form input value from the CardService event.
function formValue_(e, field) {
if (!e || !e.commonEventObject || !e.commonEventObject.formInputs) return '';
var input = e.commonEventObject.formInputs[field];
if (!input || !input.stringInputs || !input.stringInputs.value) return '';
return input.stringInputs.value[0] || '';
}
function notify_(text) {
return CardService.newActionResponseBuilder()
.setNotification(CardService.newNotification().setText(text))
.build();
}
// notifyAndReturn_ shows a toast and rebuilds the host's home card in place, so
// saving a key from onboarding reveals the prompt UI and saving from settings
// returns to a current home card (root card — replaces the whole stack).
function notifyAndReturn_(e, text) {
var host = hostFromEvent_(e);
return CardService.newActionResponseBuilder()
.setNotification(CardService.newNotification().setText(text))
.setNavigation(CardService.newNavigation().updateCard(buildHomeCard_(host, null)))
.setStateChanged(true)
.build();
}
function hostLabel_(host) {
return { docs: 'Docs', sheets: 'Sheets', slides: 'Slides', gmail: 'Gmail' }[host] || 'Workspace';
}
function escapeHtml_(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function errMessage_(err) {
return err && err.message ? String(err.message) : String(err);
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { loadGs } from './loadGs.mjs';
const {
API_BASE_URL,
DEFAULT_MODEL,
chatCompletionsURL,
modelsURL,
buildMessages,
requestBody,
extractContent,
parseModels,
} = loadGs('src/hanzo.gs');
describe('gateway endpoints', () => {
it('chat + models go through api.hanzo.ai/v1 — no /api/ prefix, no v2', () => {
expect(API_BASE_URL).toBe('https://api.hanzo.ai');
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
expect(chatCompletionsURL()).not.toContain('/api/');
expect(chatCompletionsURL()).not.toContain('/v2/');
});
});
describe('buildMessages', () => {
it('pure generation when no selection', () => {
const m = buildMessages('draft an NDA clause', '');
expect(m[0].role).toBe('system');
expect(m[1].role).toBe('user');
expect(m[1].content).toBe('draft an NDA clause');
});
it('fences the selection as data when present', () => {
const m = buildMessages('rewrite formally', 'hey wanna sign this');
expect(m[1].content).toContain('rewrite formally');
expect(m[1].content).toContain('---- selected text ----');
expect(m[1].content).toContain('hey wanna sign this');
});
it('tolerates undefined/whitespace selection as pure generation', () => {
expect(buildMessages('x', undefined)[1].content).toBe('x');
expect(buildMessages('x', ' ')[1].content).toBe('x');
});
it('system prompt forbids markdown fences / preamble', () => {
expect(buildMessages('x', '')[0].content.toLowerCase()).toContain('only the text');
});
});
describe('requestBody', () => {
it('defaults the model and forces stream:false (Apps Script has no SSE)', () => {
const b = requestBody(buildMessages('x', ''));
expect(b.model).toBe(DEFAULT_MODEL);
expect(b.stream).toBe(false);
expect(Array.isArray(b.messages)).toBe(true);
});
it('passes an explicit model + temperature through', () => {
const b = requestBody(buildMessages('x', ''), { model: 'zen-pro', temperature: 0.2 });
expect(b.model).toBe('zen-pro');
expect(b.temperature).toBe(0.2);
});
it('never streams even when asked (contract: one-shot only)', () => {
expect(requestBody(buildMessages('x', ''), { stream: true }).stream).toBe(false);
});
});
describe('extractContent', () => {
it('reads OpenAI choices[0].message.content', () => {
expect(extractContent({ choices: [{ message: { content: 'hello' } }] })).toBe('hello');
});
it('tolerates choices[0].text and bare content', () => {
expect(extractContent({ choices: [{ text: 'a' }] })).toBe('a');
expect(extractContent({ content: 'b' })).toBe('b');
});
it('throws on an error payload with the gateway message', () => {
expect(() => extractContent({ error: { message: 'rate limited' } })).toThrow(/rate limited/);
expect(() => extractContent({ error: 'bad key' })).toThrow(/bad key/);
});
it('throws when there is no content', () => {
expect(() => extractContent({ choices: [{}] })).toThrow(/no content/);
});
});
describe('parseModels', () => {
it('reads ids from a {data:[…]} response', () => {
expect(parseModels({ data: [{ id: 'zen-1' }, { id: 'zen-pro' }] })).toEqual(['zen-1', 'zen-pro']);
});
it('tolerates {models:[…]} and a bare string array', () => {
expect(parseModels({ models: [{ id: 'a' }] })).toEqual(['a']);
expect(parseModels(['x', 'y'])).toEqual(['x', 'y']);
});
it('drops empty / non-string ids', () => {
expect(parseModels({ data: [{ id: '' }, {}, { id: 'ok' }] })).toEqual(['ok']);
});
it('empty on a shapeless response', () => {
expect(parseModels({})).toEqual([]);
expect(parseModels(null)).toEqual([]);
});
});
+22
View File
@@ -0,0 +1,22 @@
// loadGs — evaluate a `.gs` file in a Node sandbox and return its
// module.exports. Apps Script `.gs` is plain JS in one global scope; hanzo.gs
// ends with a guarded `module.exports` (inert in Apps Script, live under Node),
// so the pure functions are testable with zero duplication. The Apps Script
// globals (UrlFetchApp, PropertiesService, …) are left undefined — only the
// pure functions are exercised here.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import vm from 'node:vm';
const here = dirname(fileURLToPath(import.meta.url));
export function loadGs(relPath) {
const src = readFileSync(resolve(here, '..', relPath), 'utf8');
const module = { exports: {} };
const sandbox = { module, exports: module.exports, console };
vm.createContext(sandbox);
vm.runInContext(src, sandbox, { filename: relPath });
return module.exports;
}
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(
readFileSync(resolve(here, '..', 'src', 'appsscript.json'), 'utf8')
);
describe('appsscript.json', () => {
it('is V8 with Stackdriver logging', () => {
expect(manifest.runtimeVersion).toBe('V8');
expect(manifest.exceptionLogging).toBe('STACKDRIVER');
});
it('declares the per-host currentonly scopes + external_request', () => {
const scopes = manifest.oauthScopes;
expect(scopes).toContain('https://www.googleapis.com/auth/documents.currentonly');
expect(scopes).toContain('https://www.googleapis.com/auth/spreadsheets.currentonly');
expect(scopes).toContain('https://www.googleapis.com/auth/presentations.currentonly');
expect(scopes).toContain('https://www.googleapis.com/auth/script.external_request');
});
it('declares the Gmail add-on scopes (read message + compose action)', () => {
const scopes = manifest.oauthScopes;
expect(scopes).toContain(
'https://www.googleapis.com/auth/gmail.addons.current.message.readonly'
);
expect(scopes).toContain(
'https://www.googleapis.com/auth/gmail.addons.current.action.compose'
);
});
it('whitelists ONLY api.hanzo.ai for UrlFetch', () => {
expect(manifest.urlFetchWhitelist).toEqual(['https://api.hanzo.ai/']);
});
it('registers a homepage trigger for every host', () => {
const addOns = manifest.addOns;
expect(addOns.common.homepageTrigger.runFunction).toBe('onHomepage');
expect(addOns.docs.homepageTrigger.runFunction).toBe('onDocsHomepage');
expect(addOns.sheets.homepageTrigger.runFunction).toBe('onSheetsHomepage');
expect(addOns.slides.homepageTrigger.runFunction).toBe('onSlidesHomepage');
expect(addOns.gmail.homepageTrigger.runFunction).toBe('onGmailHomepage');
});
it('wires the Gmail contextual + compose triggers', () => {
const gmail = manifest.addOns.gmail;
expect(gmail.contextualTriggers[0].onTriggerFunction).toBe('onGmailMessage');
expect(gmail.composeTrigger.selectActions[0].runFunction).toBe('onGmailCompose');
});
it('exposes the settings + get-key universal actions', () => {
const actions = manifest.addOns.common.universalActions;
const settings = actions.find((a) => a.runFunction === 'onSettings');
const getKey = actions.find((a) => a.openLink === 'https://console.hanzo.ai/api-keys');
expect(settings).toBeTruthy();
expect(getKey).toBeTruthy();
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { loadGs } from './loadGs.mjs';
const { rangeToText, linesToRows } = loadGs('src/sheets.gs');
describe('rangeToText', () => {
it('flattens a 2-D range to TSV rows', () => {
expect(rangeToText([['a', 'b'], ['c', 'd']])).toBe('a\tb\nc\td');
});
it('renders null/undefined cells as empty', () => {
expect(rangeToText([[1, null], [undefined, 4]])).toBe('1\t\n\t4');
});
it('empty range → empty string', () => {
expect(rangeToText([])).toBe('');
});
});
describe('linesToRows', () => {
it('one line per cell down a column', () => {
expect(linesToRows('a\nb\nc')).toEqual([['a'], ['b'], ['c']]);
});
it('drops trailing blank lines so a stray newline does not clear a cell', () => {
expect(linesToRows('a\nb\n\n')).toEqual([['a'], ['b']]);
});
it('single line stays a 1x1', () => {
expect(linesToRows('solo')).toEqual([['solo']]);
});
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.mjs'],
environment: 'node',
},
});
+9
View File
@@ -51,6 +51,15 @@ tasks {
gradleVersion = properties("gradleVersion").get()
}
// buildSearchableOptions launches a headless IDE (JCEF) to pre-index plugin
// settings — it needs native font libs (libfreetype) the CI runners lack, so
// it crashes buildPlugin non-deterministically (only passing when the task is
// cache-UP-TO-DATE). The index is optional; the IDE rebuilds it at runtime.
// Disable it so buildPlugin reliably produces the distributable zip.
buildSearchableOptions {
enabled = false
}
patchPluginXml {
version = properties("pluginVersion")
sinceBuild = properties("pluginSinceBuild")
+1 -1
View File
@@ -4,7 +4,7 @@ pluginGroup = ai.hanzo
pluginName = Hanzo AI
pluginRepositoryUrl = https://github.com/hanzoai/hanzo-ai-jetbrains
# SemVer format -> https://semver.org
pluginVersion = 1.8.0
pluginVersion = 1.9.30
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 233
+73
View File
@@ -0,0 +1,73 @@
# Hanzo AI for Microsoft Office
A task-pane add-in that puts Hanzo AI inside **Word, Excel, and PowerPoint**
desktop and Office on the web. Select text or a range, open the Hanzo pane from
the **Home** tab, ask, and insert the result. This is the surface a
non-developer (a lawyer drafting in Word, an analyst in Excel) actually uses —
distinct from the browser extension and the IDE extensions.
It reuses the **same backend as everything else Hanzo**: model calls go through
`api.hanzo.ai/v1` (OpenAI-compatible), sign-in is Hanzo IAM (`hanzo.id`,
authorization-code + PKCE, HIP-0111). No new API surface.
## What it does
- **Word** — read the selection; draft, rewrite, summarize, or answer; insert
after or replace the selection.
- **Excel** — read the selected range (as TSV); analyze/transform; write the
result down a column from the selection.
- **PowerPoint** — read/replace the selected text on a slide.
The engine of the add-in is small, pure, and unit-tested (`tests/`): the chat
request/response shaping (`chat.ts`), the PKCE + authorize URL (`pkce.ts`,
verified against the RFC 7636 test vector), the Excel range↔text helpers
(`host.ts`), and the endpoint contract (`config.ts`). The Office.js glue is thin.
## Build
```bash
pnpm --filter @hanzo/office-addin build # production (base https://office.hanzo.ai)
pnpm --filter @hanzo/office-addin test # vitest
```
The build stamps the **hosting base URL** into `dist/manifest.xml`. Office loads
the task pane from an HTTPS origin declared in the manifest; the same origin
serves `auth-callback.html` (the IAM redirect target, which must be registered
on the `hanzo-office` IAM client). Override the base for local dev:
```bash
HANZO_OFFICE_BASE=https://localhost:3000 pnpm --filter @hanzo/office-addin build
```
## Try it (sideload — no store needed)
The add-in is not on AppSource yet, but sideloading gives a lawyer immediate
use inside their own Office:
- **Office on the web**: open Word/Excel on the web → **Insert → Add-ins → Upload
My Add-in** → pick `dist/manifest.xml`. (`dist/` must be served at the base URL
over HTTPS.)
- **Windows / Mac desktop**: put `dist/manifest.xml` in a
[shared-folder catalog](https://learn.microsoft.com/office/dev/add-ins/testing/create-a-network-shared-folder-catalog-for-task-pane-and-content-add-ins)
(or use `office-addin-debugging`), then **Home → Add-ins → Shared Folder**.
- **Whole firm**: an M365 admin deploys `manifest.xml` org-wide from the
**Microsoft 365 admin center → Integrated apps** — every user gets the Hanzo
button with no per-person install.
## Publish (AppSource — public listing)
`dist/manifest.xml` is a valid, submittable manifest (verified by
`office-addin-manifest validate`). To list publicly:
1. Host `dist/` at the production base (`office.hanzo.ai`) over HTTPS.
2. Register the `hanzo-office` client in IAM with redirect
`https://office.hanzo.ai/auth-callback.html`.
3. Submit through **Microsoft Partner Center → Office Store** (needs a Partner
Center account + Microsoft's validation pass).
## Not here yet
- **Outlook** (mail) is a different host category with its own manifest shape —
a tracked follow-up, not this add-in.
- Streaming responses (this v1 is a single non-streaming completion, which keeps
the insert atomic).
+55
View File
@@ -0,0 +1,55 @@
# Test Hanzo AI in Word/Excel on Windows
This is the `localhost` build of the Hanzo Office add-in — a self-contained kit
to try it on your own machine before it's on AppSource. No Hanzo infra, no IAM
setup: paste a Hanzo API key and go.
## What's in this zip
- `manifest.xml` — the add-in manifest (points at `https://localhost:3000`)
- `taskpane.html` / `taskpane.js` / `assets/` — the add-in itself
- `serve.mjs` — a tiny HTTPS server for the files above
- this guide
## Steps (5 minutes)
You need [Node.js](https://nodejs.org) installed.
1. **Trust a localhost HTTPS certificate** (once per machine — Office refuses an
untrusted task-pane origin):
```powershell
npx office-addin-dev-certs install
```
2. **Serve the add-in** from this folder:
```powershell
node serve.mjs
```
Leave it running. It serves `https://localhost:3000`.
3. **Sideload the add-in.** Easiest path — **Word or Excel on the web**
(office.com): open a document → **Home → Add-ins → More Add-ins → My Add-ins
→ Upload My Add-in** → pick this folder's `manifest.xml`.
Desktop Word/Excel on Windows: put this folder on a
[network shared-folder catalog](https://learn.microsoft.com/office/dev/add-ins/testing/create-a-network-shared-folder-catalog-for-task-pane-and-content-add-ins),
trust it in **File → Options → Trust Center → Trusted Add-in Catalogs**, then
**Home → Add-ins → Shared Folder → Hanzo AI**.
4. **Use it.** Click **Hanzo AI** on the Home tab to open the pane. Expand
**"Or use a Hanzo API key"**, paste your `hk-…` key (from
console.hanzo.ai → API keys), **Save**. Select text or a range, type an
instruction, **Ask Hanzo**, then **Insert**.
## Notes
- The API key is stored in your Office roaming settings (per-user, encrypted at
rest by Office) — not in this folder.
- Sign-in with your Hanzo account (instead of an API key) works once the
production add-in is hosted at `office.hanzo.ai`; the API key is the
zero-setup path for local testing.
- Model calls go to `https://api.hanzo.ai/v1` — the same gateway the browser
extension uses.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+182
View File
@@ -0,0 +1,182 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Hanzo AI for Microsoft Office — task-pane add-in for Word, Excel and
PowerPoint (desktop + Office on the web). The task pane is served from
__HANZO_OFFICE_BASE__ (build.js stamps this: the production office.hanzo.ai
host, or https://localhost:3000 for sideloaded dev). One add-in, three hosts:
Document (Word), Workbook (Excel), Presentation (PowerPoint).
Outlook (mail) is a DIFFERENT host category with its own manifest shape; it is
a tracked follow-up, not this file.
-->
<OfficeApp
xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0"
xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides"
xsi:type="TaskPaneApp">
<Id>729f93c2-86e9-4f00-8fcc-d0306ffe7129</Id>
<Version>1.0.0.0</Version>
<ProviderName>Hanzo AI</ProviderName>
<DefaultLocale>en-US</DefaultLocale>
<DisplayName DefaultValue="Hanzo AI" />
<Description DefaultValue="AI over your document — draft, rewrite, summarize, and analyze in Word, Excel and PowerPoint. Powered by Hanzo (api.hanzo.ai)." />
<IconUrl DefaultValue="__HANZO_OFFICE_BASE__/assets/icon-32.png" />
<HighResolutionIconUrl DefaultValue="__HANZO_OFFICE_BASE__/assets/icon-128.png" />
<SupportUrl DefaultValue="https://hanzo.ai/support" />
<!-- Domains the task pane may navigate to (IAM login, the API gateway). -->
<AppDomains>
<AppDomain>https://hanzo.id</AppDomain>
<AppDomain>https://iam.hanzo.ai</AppDomain>
<AppDomain>https://api.hanzo.ai</AppDomain>
<AppDomain>https://hanzo.ai</AppDomain>
</AppDomains>
<Hosts>
<Host Name="Document" />
<Host Name="Workbook" />
<Host Name="Presentation" />
</Hosts>
<DefaultSettings>
<SourceLocation DefaultValue="__HANZO_OFFICE_BASE__/taskpane.html" />
</DefaultSettings>
<!-- Read AND write: the add-in reads the selection/range and inserts AI output. -->
<Permissions>ReadWriteDocument</Permissions>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="VersionOverridesV1_0">
<Hosts>
<Host xsi:type="Document">
<DesktopFormFactor>
<GetStarted>
<Title resid="Hanzo.GetStarted.Title" />
<Description resid="Hanzo.GetStarted.Description" />
<LearnMoreUrl resid="Hanzo.SupportUrl" />
</GetStarted>
<FunctionFile resid="Hanzo.CommandsUrl" />
<ExtensionPoint xsi:type="PrimaryCommandSurface">
<OfficeTab id="TabHome">
<Group id="Hanzo.Group">
<Label resid="Hanzo.Group.Label" />
<Icon>
<bt:Image size="16" resid="Hanzo.Icon.16" />
<bt:Image size="32" resid="Hanzo.Icon.32" />
<bt:Image size="80" resid="Hanzo.Icon.80" />
</Icon>
<Control xsi:type="Button" id="Hanzo.TaskpaneButton">
<Label resid="Hanzo.TaskpaneButton.Label" />
<Supertip>
<Title resid="Hanzo.TaskpaneButton.Label" />
<Description resid="Hanzo.TaskpaneButton.Tooltip" />
</Supertip>
<Icon>
<bt:Image size="16" resid="Hanzo.Icon.16" />
<bt:Image size="32" resid="Hanzo.Icon.32" />
<bt:Image size="80" resid="Hanzo.Icon.80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<TaskpaneId>Hanzo.Taskpane</TaskpaneId>
<SourceLocation resid="Hanzo.TaskpaneUrl" />
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
<Host xsi:type="Workbook">
<DesktopFormFactor>
<FunctionFile resid="Hanzo.CommandsUrl" />
<ExtensionPoint xsi:type="PrimaryCommandSurface">
<OfficeTab id="TabHome">
<Group id="Hanzo.Group">
<Label resid="Hanzo.Group.Label" />
<Icon>
<bt:Image size="16" resid="Hanzo.Icon.16" />
<bt:Image size="32" resid="Hanzo.Icon.32" />
<bt:Image size="80" resid="Hanzo.Icon.80" />
</Icon>
<Control xsi:type="Button" id="Hanzo.TaskpaneButton">
<Label resid="Hanzo.TaskpaneButton.Label" />
<Supertip>
<Title resid="Hanzo.TaskpaneButton.Label" />
<Description resid="Hanzo.TaskpaneButton.Tooltip" />
</Supertip>
<Icon>
<bt:Image size="16" resid="Hanzo.Icon.16" />
<bt:Image size="32" resid="Hanzo.Icon.32" />
<bt:Image size="80" resid="Hanzo.Icon.80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<TaskpaneId>Hanzo.Taskpane</TaskpaneId>
<SourceLocation resid="Hanzo.TaskpaneUrl" />
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
<Host xsi:type="Presentation">
<DesktopFormFactor>
<FunctionFile resid="Hanzo.CommandsUrl" />
<ExtensionPoint xsi:type="PrimaryCommandSurface">
<OfficeTab id="TabHome">
<Group id="Hanzo.Group">
<Label resid="Hanzo.Group.Label" />
<Icon>
<bt:Image size="16" resid="Hanzo.Icon.16" />
<bt:Image size="32" resid="Hanzo.Icon.32" />
<bt:Image size="80" resid="Hanzo.Icon.80" />
</Icon>
<Control xsi:type="Button" id="Hanzo.TaskpaneButton">
<Label resid="Hanzo.TaskpaneButton.Label" />
<Supertip>
<Title resid="Hanzo.TaskpaneButton.Label" />
<Description resid="Hanzo.TaskpaneButton.Tooltip" />
</Supertip>
<Icon>
<bt:Image size="16" resid="Hanzo.Icon.16" />
<bt:Image size="32" resid="Hanzo.Icon.32" />
<bt:Image size="80" resid="Hanzo.Icon.80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<TaskpaneId>Hanzo.Taskpane</TaskpaneId>
<SourceLocation resid="Hanzo.TaskpaneUrl" />
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Hanzo.Icon.16" DefaultValue="__HANZO_OFFICE_BASE__/assets/icon-16.png" />
<bt:Image id="Hanzo.Icon.32" DefaultValue="__HANZO_OFFICE_BASE__/assets/icon-32.png" />
<bt:Image id="Hanzo.Icon.80" DefaultValue="__HANZO_OFFICE_BASE__/assets/icon-80.png" />
</bt:Images>
<bt:Urls>
<bt:Url id="Hanzo.TaskpaneUrl" DefaultValue="__HANZO_OFFICE_BASE__/taskpane.html" />
<bt:Url id="Hanzo.CommandsUrl" DefaultValue="__HANZO_OFFICE_BASE__/commands.html" />
<bt:Url id="Hanzo.SupportUrl" DefaultValue="https://hanzo.ai/support" />
</bt:Urls>
<bt:ShortStrings>
<bt:String id="Hanzo.Group.Label" DefaultValue="Hanzo AI" />
<bt:String id="Hanzo.TaskpaneButton.Label" DefaultValue="Hanzo AI" />
<bt:String id="Hanzo.GetStarted.Title" DefaultValue="Hanzo AI is ready" />
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="Hanzo.TaskpaneButton.Tooltip" DefaultValue="Open the Hanzo AI pane to draft, rewrite, summarize, or analyze your selection." />
<bt:String id="Hanzo.GetStarted.Description" DefaultValue="Select text or a range, open the Hanzo AI pane from the Home tab, and ask." />
</bt:LongStrings>
</Resources>
</VersionOverrides>
</OfficeApp>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@hanzo/office-addin",
"version": "1.9.30",
"description": "Hanzo AI for Microsoft Office \u2014 Word, Excel, and PowerPoint task pane. AI over your document, wired to the same api.hanzo.ai gateway and hanzo.id IAM as the browser extension.",
"private": true,
"dependencies": {
"@hanzo/auth": "workspace:*"
},
"scripts": {
"build": "node src/build.js",
"watch": "node src/build.js --watch",
"test": "vitest run"
},
"devDependencies": {
"@types/office-js": "^1.0.377",
"esbuild": "^0.25.8",
"typescript": "^5.8.3",
"vitest": "^3.2.6"
},
"engines": {
"node": ">=18.0.0"
},
"license": "MIT"
}
+74
View File
@@ -0,0 +1,74 @@
// Self-contained HTTPS static server for local Office sideload testing on any
// machine (Windows included). It serves the files next to it — the built
// dist/ — at https://localhost:3000, which is the base a `localhost` build of
// the add-in points its manifest at. No framework, only Node stdlib.
//
// 1. npx office-addin-dev-certs install # trust a localhost HTTPS cert (once)
// 2. node serve.mjs # serve dist/ at https://localhost:3000
// 3. sideload manifest.xml (see SIDELOAD-WINDOWS.md)
//
// The cert/key are read from the office-addin-dev-certs default location
// (~/.office-addin-dev-certs). If they are absent the script prints the exact
// install command instead of failing cryptically.
import { createServer } from 'node:https';
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, extname, normalize } from 'node:path';
import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
const ROOT = fileURLToPath(new URL('.', import.meta.url));
const PORT = Number(process.env.PORT || 3000);
const CERT_DIR = join(homedir(), '.office-addin-dev-certs');
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.map': 'application/json',
'.json': 'application/json',
'.xml': 'text/xml',
'.png': 'image/png',
'.css': 'text/css',
};
async function loadCerts() {
const cert = join(CERT_DIR, 'localhost.crt');
const key = join(CERT_DIR, 'localhost.key');
if (!existsSync(cert) || !existsSync(key)) {
console.error(
'No localhost HTTPS certificate found.\n' +
'Install a trusted one first (Office refuses an untrusted task-pane origin):\n\n' +
' npx office-addin-dev-certs install\n',
);
process.exit(1);
}
return { cert: await readFile(cert), key: await readFile(key) };
}
const { cert, key } = await loadCerts();
createServer({ cert, key }, async (req, res) => {
// Resolve the request path safely under ROOT (no traversal).
let rel = decodeURIComponent((req.url || '/').split('?')[0]);
if (rel === '/') rel = '/taskpane.html';
const path = normalize(join(ROOT, rel));
if (!path.startsWith(ROOT)) {
res.writeHead(403).end('forbidden');
return;
}
try {
const body = await readFile(path);
res.writeHead(200, {
'Content-Type': TYPES[extname(path)] || 'application/octet-stream',
// Office loads the pane in an iframe; allow it and permit the API host.
'Access-Control-Allow-Origin': '*',
});
res.end(body);
} catch {
res.writeHead(404).end('not found: ' + rel);
}
}).listen(PORT, () => {
console.log(`Hanzo Office add-in served at https://localhost:${PORT}`);
console.log('Sideload manifest.xml — see SIDELOAD-WINDOWS.md.');
});
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Signing in…</title>
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<p>Completing sign-in…</p>
<script>
// The IAM redirect lands here inside the Office auth dialog. Hand the full
// callback URL (carrying ?code=&state=) back to the task pane via
// messageParent, then the pane exchanges the code for a token.
Office.onReady(function () {
try {
Office.context.ui.messageParent(window.location.href);
} catch (e) {
document.body.innerText = 'Sign-in could not return to the add-in: ' + e;
}
});
</script>
</body>
</html>
+131
View File
@@ -0,0 +1,131 @@
// Office's auth = the @hanzo/auth core + two thin Office adapters. The OAuth2 +
// PKCE flow, endpoints, and refresh live ONCE in @hanzo/auth; here we only teach
// it how Office stores tokens (roamingSettings) and how it opens a login window
// (the Dialog API). Plus the office-only API-key path for zero-setup use.
/* global Office */
import {
authClientFor, login as coreLogin, logout as coreLogout, getValidToken,
type TokenStore, type TokenSet, type Opener,
} from '@hanzo/auth';
import { APIKEY_SETTING_KEY, TOKEN_SETTING_KEY, pickBearer } from './config.js';
// The office IAM client, with the redirect served from the add-in's own origin
// (registered on the hanzo-office IAM client).
function client() {
return authClientFor('office', { redirectUri: `${location.origin}/auth-callback.html` });
}
// roamingStore is the Office TokenStore: roamingSettings is per-user and roams
// with the Office identity. The TokenSet is JSON-serialized under one key.
const roamingStore: TokenStore = {
async get(): Promise<TokenSet | null> {
try {
const raw = Office.context.roamingSettings.get(TOKEN_SETTING_KEY) as string;
return raw ? (JSON.parse(raw) as TokenSet) : null;
} catch {
return null;
}
},
set(tokens: TokenSet): Promise<void> {
return saveRoaming(TOKEN_SETTING_KEY, JSON.stringify(tokens));
},
clear(): Promise<void> {
return removeRoaming(TOKEN_SETTING_KEY);
},
};
// dialogOpener is the Office Opener: it opens the authorize URL in an Office
// dialog and resolves with the callback URL the dialog posts back via
// messageParent (auth-callback.html does that post).
const dialogOpener: Opener = {
open(authorizeUrl: string): Promise<string> {
return new Promise((resolve, reject) => {
Office.context.ui.displayDialogAsync(authorizeUrl, { height: 60, width: 30 }, (result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
reject(new Error(result.error?.message || 'could not open login dialog'));
return;
}
const dialog = result.value;
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (arg: any) => {
dialog.close();
resolve(String(arg.message || ''));
});
dialog.addEventHandler(Office.EventType.DialogEventReceived, () => {
reject(new Error('login dialog closed'));
});
});
});
},
};
// signIn / clearToken / getToken delegate to the core with the Office adapters.
export async function signIn(): Promise<string> {
const tokens = await coreLogin(client(), roamingStore, dialogOpener);
return tokens.accessToken;
}
export function clearToken(): Promise<void> {
return coreLogout(roamingStore);
}
// getToken returns a valid (auto-refreshed) access token, or '' if not signed in.
export async function getToken(): Promise<string> {
return getValidToken(client(), roamingStore);
}
// ── API-key path (office-only, zero-setup) ────────────────────────────────
export function getApiKey(): string {
try {
return (Office.context.roamingSettings.get(APIKEY_SETTING_KEY) as string) || '';
} catch {
return '';
}
}
export function setApiKey(key: string): Promise<void> {
return saveRoaming(APIKEY_SETTING_KEY, key.trim());
}
export function clearApiKey(): Promise<void> {
return removeRoaming(APIKEY_SETTING_KEY);
}
// bearer is the credential the chat call sends: a pasted API key wins over the
// OAuth token (see pickBearer). Async because the OAuth token may refresh.
export async function bearer(): Promise<string> {
const key = getApiKey();
if (key) return key;
return getValidToken(client(), roamingStore);
}
// hasApiKey / hasSession are SYNC reflect checks for the pane (no refresh, no
// network) — used only to render which credential is live, not to authorize.
export function hasApiKey(): boolean {
return !!getApiKey();
}
export function hasSession(): boolean {
try {
return !!(Office.context.roamingSettings.get(TOKEN_SETTING_KEY) as string);
} catch {
return false;
}
}
// ── roamingSettings helpers ───────────────────────────────────────────────
function saveRoaming(key: string, value: string): Promise<void> {
return new Promise((resolve, reject) => {
Office.context.roamingSettings.set(key, value);
Office.context.roamingSettings.saveAsync((r) => {
r.status === Office.AsyncResultStatus.Succeeded ? resolve() : reject(new Error('save failed'));
});
});
}
function removeRoaming(key: string): Promise<void> {
return new Promise((resolve) => {
Office.context.roamingSettings.remove(key);
Office.context.roamingSettings.saveAsync(() => resolve());
});
}
// pickBearer re-export kept for callers/tests that import it from auth.
export { pickBearer };
+84
View File
@@ -0,0 +1,84 @@
// Build the Office add-in into dist/: bundle the TS entry points, copy the
// static HTML + assets, and stamp the hosting base URL into a manifest.xml the
// user can sideload or submit to AppSource.
//
// node src/build.js → production (HANZO_OFFICE_BASE=https://office.hanzo.ai)
// HANZO_OFFICE_BASE=https://localhost:3000 node src/build.js → dev sideload
//
// The base URL is where the task pane is HOSTED. Office add-ins load the pane
// from an HTTPS origin declared in the manifest; the same origin serves
// auth-callback.html (the IAM redirect target registered on the hanzo-office
// client). One string, stamped everywhere, so the manifest and the served
// files never disagree.
const esbuild = require('esbuild');
const fs = require('fs');
const path = require('path');
const BASE = (process.env.HANZO_OFFICE_BASE || 'https://office.hanzo.ai').replace(/\/+$/, '');
const watch = process.argv.includes('--watch');
const root = path.resolve(__dirname, '..');
const dist = path.join(root, 'dist');
const entries = ['taskpane', 'commands'];
async function build() {
fs.rmSync(dist, { recursive: true, force: true });
fs.mkdirSync(dist, { recursive: true });
// Bundle each entry to dist/<name>.js.
const ctx = await esbuild.context({
entryPoints: entries.map((e) => path.join(__dirname, `${e}.ts`)),
outdir: dist,
bundle: true,
format: 'iife',
platform: 'browser',
target: ['chrome90', 'edge90', 'safari14'],
sourcemap: true,
minify: !watch,
logLevel: 'info',
});
await ctx.rebuild();
// Copy static HTML (taskpane, commands, auth-callback).
for (const f of ['taskpane.html', 'commands.html', 'auth-callback.html']) {
fs.copyFileSync(path.join(__dirname, f), path.join(dist, f));
}
// Copy the local-test kit (self-contained HTTPS server + Windows guide) so a
// downloaded localhost build is runnable as-is.
for (const f of ['serve.mjs', 'SIDELOAD-WINDOWS.md']) {
fs.copyFileSync(path.join(root, f), path.join(dist, f));
}
// Copy assets/ (icons).
const assetsSrc = path.join(root, 'assets');
const assetsDst = path.join(dist, 'assets');
fs.mkdirSync(assetsDst, { recursive: true });
for (const f of fs.readdirSync(assetsSrc)) {
fs.copyFileSync(path.join(assetsSrc, f), path.join(assetsDst, f));
}
// Stamp the base URL into the manifest and write it into dist/.
const manifest = fs.readFileSync(path.join(root, 'manifest.xml'), 'utf8');
const stamped = manifest.split('__HANZO_OFFICE_BASE__').join(BASE);
if (stamped.includes('__HANZO_OFFICE_BASE__')) {
throw new Error('manifest still has an unstamped __HANZO_OFFICE_BASE__ placeholder');
}
fs.writeFileSync(path.join(dist, 'manifest.xml'), stamped);
console.log(`✅ Office add-in built → dist/ (base ${BASE})`);
console.log(' Sideload dist/manifest.xml; serve dist/ at that base over HTTPS.');
if (watch) {
await ctx.watch();
console.log('👀 watching…');
} else {
await ctx.dispose();
}
}
build().catch((e) => {
console.error(e);
process.exit(1);
});
+182
View File
@@ -0,0 +1,182 @@
// The Hanzo chat call and its request/response shaping — pure, host-agnostic,
// and fully unit-testable (no Office.js, no DOM). taskpane.ts is the thin glue
// that reads the document via Office.js and hands the text here.
import { chatCompletionsURL, modelsURL, DEFAULT_MODEL } from './config.js';
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
// buildMessages turns a task (the user's instruction) plus the document
// selection into the OpenAI-compatible message list. An empty selection is a
// pure generation ("draft a NDA clause about X"); a non-empty selection is an
// operation ON the text ("rewrite this formally"), and the selection is fenced
// so the model treats it as data, not instructions.
export function buildMessages(task: string, selection: string): ChatMessage[] {
const system: ChatMessage = {
role: 'system',
content:
'You are Hanzo AI inside a Microsoft Office document. Return ONLY the text to ' +
'insert into the document — no preamble, no markdown fences, no "Here is". ' +
'Match the document\'s tone. When given a selection, operate on it directly.',
};
const trimmed = selection.trim();
const user: ChatMessage = {
role: 'user',
content: trimmed
? `${task}\n\n---- selected text ----\n${selection}`
: task,
};
return [system, user];
}
export interface ChatOptions {
model?: string;
temperature?: number;
/** Request an SSE stream. Defaults to false (one-shot completion). */
stream?: boolean;
signal?: AbortSignal;
}
// requestBody is the exact JSON the gateway receives. Separated so a test can
// assert the wire shape (model default, stream flag, messages) without a
// network call. `stream` defaults to false — the one-shot `ask` contract.
export function requestBody(messages: ChatMessage[], opts: ChatOptions = {}): Record<string, unknown> {
return {
model: opts.model || DEFAULT_MODEL,
messages,
stream: opts.stream === true,
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
};
}
// extractContent pulls the assistant text out of an OpenAI-compatible
// completion response, tolerating the shapes the gateway returns (choices with
// message.content, or a bare content). Throws on an error payload so the pane
// surfaces the real gateway message.
export function extractContent(data: any): string {
if (data && data.error) {
const msg = typeof data.error === 'string' ? data.error : data.error.message || 'unknown error';
throw new Error(`Hanzo API error: ${msg}`);
}
const choice = data?.choices?.[0];
const content =
choice?.message?.content ??
choice?.text ??
data?.content ??
'';
if (typeof content !== 'string' || content === '') {
throw new Error('Hanzo API returned no content');
}
return content;
}
// ask runs one non-streaming completion and returns the text to insert. token
// may be empty (the gateway serves anonymous/limited models); when present it
// is the IAM bearer.
export async function ask(
task: string,
selection: string,
token: string,
opts: ChatOptions = {},
): Promise<string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
const resp = await fetch(chatCompletionsURL(), {
method: 'POST',
headers,
body: JSON.stringify(requestBody(buildMessages(task, selection), opts)),
signal: opts.signal,
});
const text = await resp.text();
let data: any;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Hanzo API ${resp.status}: ${text.slice(0, 200)}`);
}
if (!resp.ok) {
// Prefer the structured error message; fall back to status.
try {
return extractContent(data);
} catch {
const m = data?.error?.message || data?.error || `HTTP ${resp.status}`;
throw new Error(`Hanzo API ${resp.status}: ${m}`);
}
}
return extractContent(data);
}
// streamDelta pulls the incremental text out of one OpenAI-compatible SSE frame
// payload (the JSON after `data: `). Pure, so the SSE parse is unit-testable.
export function streamDelta(payload: string): string {
const json = JSON.parse(payload);
return json?.choices?.[0]?.delta?.content ?? '';
}
// askStream runs a streaming completion, invoking onDelta with each text chunk
// as it arrives, and resolves with the full concatenated text. Same inputs as
// `ask`; use it for a live-typing pane. Falls back to a thrown error on non-2xx.
export async function askStream(
task: string,
selection: string,
token: string,
onDelta: (text: string) => void,
opts: ChatOptions = {},
): Promise<string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
const resp = await fetch(chatCompletionsURL(), {
method: 'POST',
headers,
body: JSON.stringify(requestBody(buildMessages(task, selection), { ...opts, stream: true })),
signal: opts.signal,
});
if (!resp.ok || !resp.body) {
const detail = await resp.text().catch(() => '');
throw new Error(`Hanzo API ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let full = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const payload = trimmed.slice(5).trim();
if (payload === '[DONE]') return full;
try {
const delta = streamDelta(payload);
if (delta) {
full += delta;
onDelta(delta);
}
} catch {
// partial SSE frame — wait for the next chunk
}
}
}
return full;
}
// listModels returns the model ids the caller may route to, from /v1/models.
// The endpoint is org-scoped by the bearer; an empty token lists public models.
export async function listModels(token: string): Promise<string[]> {
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const resp = await fetch(modelsURL(), { headers });
if (!resp.ok) throw new Error(`Hanzo API ${resp.status}: model list failed`);
const data: any = await resp.json();
const items = data?.data ?? data?.models ?? (Array.isArray(data) ? data : []);
return items
.map((m: any) => (typeof m === 'string' ? m : m?.id))
.filter((id: unknown): id is string => typeof id === 'string' && id.length > 0);
}
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Hanzo AI commands</title>
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
<script src="commands.js"></script>
</head>
<body></body>
</html>
+10
View File
@@ -0,0 +1,10 @@
// The ribbon FunctionFile. Office loads commands.html (which loads this) for
// any Action of type ExecuteFunction. The Hanzo add-in's only ribbon control is
// ShowTaskpane (no ExecuteFunction), so this exists to satisfy the manifest's
// FunctionFile requirement and to host future one-click ribbon actions.
/* global Office */
Office.onReady(() => {
/* no-op: the ribbon button opens the task pane directly */
});
+30
View File
@@ -0,0 +1,30 @@
// Office add-in config — ONLY what is office-specific. IAM endpoints, the OAuth
// client, PKCE, and scopes now live in @hanzo/auth (the one auth core); this
// module keeps the api.hanzo.ai model gateway + the roaming-settings keys.
export const API_BASE_URL = 'https://api.hanzo.ai';
// Default model. Overridable per-request; the gateway routes it.
export const DEFAULT_MODEL = 'zen-1';
// Roaming-settings keys (Office.context.roamingSettings persists per-user across
// the user's devices). Two auth paths: the IAM OAuth token set (managed by
// @hanzo/auth) and a pasted Hanzo API key (`hk-…`) for zero-setup use — the key
// needs no registered OAuth client, which is why the Windows test kit uses it.
export const TOKEN_SETTING_KEY = 'hanzo.tokens';
export const APIKEY_SETTING_KEY = 'hanzo.apiKey';
// pickBearer chooses the credential to send: a pasted API key wins over the
// OAuth token (an explicit key is a deliberate override), else the OAuth token,
// else empty (anonymous). Pure — unit-tested.
export function pickBearer(apiKey: string, oauthToken: string): string {
return (apiKey && apiKey.trim()) || (oauthToken && oauthToken.trim()) || '';
}
// chatCompletionsURL / modelsURL — the model gateway endpoints the add-in calls.
export function chatCompletionsURL(): string {
return `${API_BASE_URL}/v1/chat/completions`;
}
export function modelsURL(): string {
return `${API_BASE_URL}/v1/models`;
}
+113
View File
@@ -0,0 +1,113 @@
// The Office.js host glue: read the user's selection and insert AI output, one
// path per host (Word / Excel / PowerPoint). The Office-calling functions are
// intentionally thin; the shaping helpers (excelValuesToText, splitLines) are
// pure and unit-tested.
/* global Office, Word, Excel, PowerPoint */
export type HostKind = 'word' | 'excel' | 'powerpoint' | 'unknown';
export type InsertMode = 'replace' | 'after';
// hostKind maps the Office host enum to our small union. Pure given the enum.
export function hostKind(host: Office.HostType | undefined): HostKind {
switch (host) {
case Office.HostType.Word:
return 'word';
case Office.HostType.Excel:
return 'excel';
case Office.HostType.PowerPoint:
return 'powerpoint';
default:
return 'unknown';
}
}
// excelValuesToText flattens a selected range's 2-D values into TSV-ish text the
// model can read: rows joined by newline, cells by tab, nulls as empty. Pure.
export function excelValuesToText(values: unknown[][]): string {
if (!values || values.length === 0) return '';
return values
.map((row) => row.map((c) => (c === null || c === undefined ? '' : String(c))).join('\t'))
.join('\n');
}
// splitLines turns model output back into a 1-column, N-row 2-D array for
// writing into Excel (one line per cell down a column). Pure. Trailing blank
// lines are dropped so a stray newline doesn't clear a cell.
export function splitLines(text: string): string[][] {
const lines = text.replace(/\n+$/, '').split('\n');
return lines.map((l) => [l]);
}
// currentHost reads the live Office host.
export function currentHost(): HostKind {
return hostKind(Office.context?.host);
}
// readSelection returns the user's current selection as plain text, per host.
export async function readSelection(): Promise<string> {
switch (currentHost()) {
case 'word':
return await new Promise<string>((resolve, reject) => {
Word.run(async (ctx) => {
const sel = ctx.document.getSelection();
sel.load('text');
await ctx.sync();
resolve(sel.text || '');
}).catch(reject);
});
case 'excel':
return await new Promise<string>((resolve, reject) => {
Excel.run(async (ctx) => {
const range = ctx.workbook.getSelectedRange();
range.load('values');
await ctx.sync();
resolve(excelValuesToText(range.values as unknown[][]));
}).catch(reject);
});
case 'powerpoint':
return await new Promise<string>((resolve) => {
Office.context.document.getSelectedDataAsync(Office.CoercionType.Text, (r) => {
resolve(r.status === Office.AsyncResultStatus.Succeeded ? String(r.value || '') : '');
});
});
default:
return '';
}
}
// insertResult writes the AI output back into the document. `mode` chooses
// replace-the-selection vs insert-after; Excel always writes down the current
// column from the selection's top-left (a spreadsheet has no "after cursor").
export async function insertResult(text: string, mode: InsertMode): Promise<void> {
switch (currentHost()) {
case 'word':
await Word.run(async (ctx) => {
const sel = ctx.document.getSelection();
const loc = mode === 'replace' ? Word.InsertLocation.replace : Word.InsertLocation.after;
sel.insertText(text, loc);
await ctx.sync();
});
return;
case 'excel':
await Excel.run(async (ctx) => {
const start = ctx.workbook.getSelectedRange().getCell(0, 0);
const rows = splitLines(text);
const target = start.getResizedRange(rows.length - 1, 0);
target.values = rows;
await ctx.sync();
});
return;
case 'powerpoint':
await new Promise<void>((resolve, reject) => {
Office.context.document.setSelectedDataAsync(text, (r) => {
r.status === Office.AsyncResultStatus.Succeeded
? resolve()
: reject(new Error(r.error?.message || 'insert failed'));
});
});
return;
default:
throw new Error('Unsupported Office host');
}
}
+88
View File
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hanzo AI</title>
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
<style>
:root { color-scheme: light dark; --fg:#1a1a1a; --muted:#666; --bg:#fff; --line:#e3e3e3; --accent:#111; }
@media (prefers-color-scheme: dark) { :root { --fg:#eaeaea; --muted:#9a9a9a; --bg:#1e1e1e; --line:#333; --accent:#fff; } }
* { box-sizing: border-box; }
body { font: 14px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 12px; }
header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
header img { width: 22px; height: 22px; }
header .title { font-weight: 600; }
header .host { color: var(--muted); font-size: 12px; margin-left: auto; }
label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 4px; }
textarea, select { width: 100%; font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px; }
textarea#prompt { min-height: 72px; resize: vertical; }
textarea#output { min-height: 120px; resize: vertical; }
.row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
button { font: inherit; border: 1px solid var(--line); background: var(--accent); color: var(--bg); border-radius: 6px; padding: 8px 14px; cursor: pointer; }
button.secondary { background: transparent; color: var(--fg); }
button:disabled { opacity: .5; cursor: default; }
.status { min-height: 18px; font-size: 12px; margin-top: 8px; color: var(--muted); }
.status.ok { color: #1a7f37; } .status.warn { color: #9a6700; } .status.error { color: #cf222e; }
.spacer { flex: 1; }
footer { margin-top: 10px; font-size: 11px; color: var(--muted); }
select#model { width: auto; max-width: 150px; margin-left: auto; padding: 4px 8px; font-size: 12px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip { padding: 5px 10px; border-radius: 999px; font-size: 13px; background: transparent; color: var(--fg); border: 1px solid var(--line); }
.chip:hover:not(:disabled) { border-color: var(--accent); }
</style>
</head>
<body>
<header>
<img src="assets/icon-32.png" alt="Hanzo" />
<span class="title">Hanzo AI</span>
<select id="model" aria-label="Model"></select>
<span class="host" id="hostname">Office</span>
</header>
<!-- One-click actions over the selection (drafting/review, e.g. a law office). -->
<div class="chips">
<button class="chip" data-task="Continue drafting this document in the same voice and formatting. Return only the continuation.">Draft</button>
<button class="chip" data-task="Summarize the following, preserving parties, dates, defined terms and obligations.">Summarize</button>
<button class="chip" data-task="Explain this in plain, non-jargon language, and note any risks or obligations.">Explain plainly</button>
<button class="chip" data-task="Tighten and clarify this text without changing its legal meaning. Return only the revised text.">Tighten / redline</button>
<button class="chip" data-task="Continue writing from here in the same voice, tense and formatting. Return only the continuation.">Continue</button>
</div>
<label for="prompt">What should Hanzo do with your selection?</label>
<textarea id="prompt" placeholder="e.g. Rewrite this clause in plain English · Summarize the selected rows · Draft a mutual NDA section about confidentiality"></textarea>
<div class="row">
<button id="run">Ask Hanzo</button>
<button id="signin" class="secondary">Sign in</button>
<button id="signout" class="secondary" style="display:none">Sign out</button>
<span class="spacer"></span>
</div>
<details>
<summary style="cursor:pointer;font-size:12px;color:var(--muted);margin-top:8px;">Or use a Hanzo API key</summary>
<div class="row">
<input id="apikey" type="password" placeholder="hk-…" style="flex:1;font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;" />
<button id="savekey" class="secondary">Save</button>
</div>
<div id="authhint" style="font-size:11px;color:var(--muted);margin-top:4px;"></div>
</details>
<div class="status" id="status"></div>
<label for="output">Result</label>
<textarea id="output" placeholder="Hanzo's output appears here — review before inserting."></textarea>
<div class="row">
<select id="mode" aria-label="Insert mode">
<option value="after">Insert after selection</option>
<option value="replace">Replace selection</option>
</select>
<button id="insert" disabled>Insert into document</button>
</div>
<footer>Routed through api.hanzo.ai · sign in with Hanzo (hanzo.id) for your models.</footer>
<script src="taskpane.js"></script>
</body>
</html>
+173
View File
@@ -0,0 +1,173 @@
// Task-pane orchestration: wire the UI to read the selection, call Hanzo, and
// insert the result. The logic-heavy parts (chat shaping, host read/insert,
// auth) live in their own tested modules; this is the glue that binds them to
// the DOM once Office is ready.
/* global Office, document */
import { askStream, listModels } from './chat.js';
import { readSelection, insertResult, currentHost, type InsertMode } from './host.js';
import { bearer, getApiKey, setApiKey, clearApiKey, signIn, clearToken, hasApiKey, hasSession } from './auth.js';
import { DEFAULT_MODEL } from './config.js';
Office.onReady((info) => {
if (
info.host !== Office.HostType.Word &&
info.host !== Office.HostType.Excel &&
info.host !== Office.HostType.PowerPoint
) {
return; // not a supported host — the pane shows its static "unsupported" note
}
const $ = (id: string) => document.getElementById(id) as HTMLElement;
const promptEl = $('prompt') as HTMLTextAreaElement;
const runBtn = $('run') as HTMLButtonElement;
const statusEl = $('status');
const outputEl = $('output') as HTMLTextAreaElement;
const insertBtn = $('insert') as HTMLButtonElement;
const modeEl = $('mode') as HTMLSelectElement;
const modelEl = $('model') as HTMLSelectElement;
const signInBtn = $('signin') as HTMLButtonElement;
const signOutBtn = $('signout') as HTMLButtonElement;
const apiKeyEl = $('apikey') as HTMLInputElement;
const saveKeyBtn = $('savekey') as HTMLButtonElement;
const chips = Array.from(document.querySelectorAll<HTMLButtonElement>('.chip'));
$('hostname').textContent = hostLabel(currentHost());
apiKeyEl.value = getApiKey();
reflectAuth();
void populateModels();
let controller: AbortController | null = null;
// One code path for the Ask button AND the quick-action chips: read the
// selection, stream the completion into the output, enable Insert.
async function runTask(task: string) {
if (!task) {
setStatus('Type an instruction or pick an action first.', 'warn');
return;
}
controller?.abort();
controller = new AbortController();
setBusy(true, 'Reading selection…');
outputEl.value = '';
insertBtn.disabled = true;
try {
const selection = await readSelection();
setStatus('Asking Hanzo…');
await askStream(
task,
selection,
await bearer(),
(delta) => { outputEl.value += delta; outputEl.scrollTop = outputEl.scrollHeight; },
{ model: modelEl.value || DEFAULT_MODEL, signal: controller.signal },
);
insertBtn.disabled = !outputEl.value;
setStatus(outputEl.value ? 'Done — review, then Insert.' : 'No content returned.', outputEl.value ? 'ok' : 'warn');
} catch (e: any) {
if (e?.name === 'AbortError') return;
setStatus(errMessage(e), 'error');
} finally {
setBusy(false);
}
}
runBtn.onclick = () => runTask(promptEl.value.trim());
// Quick-action chip: seed the prompt with its preset task, then run it.
for (const chip of chips) {
chip.onclick = () => {
const task = chip.dataset.task ?? '';
promptEl.value = task;
void runTask(task);
};
}
async function populateModels() {
try {
const ids = (await listModels(await bearer())).sort();
if (!ids.length) return;
modelEl.innerHTML = '';
for (const id of ids) {
const opt = document.createElement('option');
opt.value = id; opt.textContent = id;
modelEl.appendChild(opt);
}
modelEl.value = ids.includes(DEFAULT_MODEL) ? DEFAULT_MODEL : ids[0];
} catch {
// Non-fatal — a fixed default still works; leave the picker with its default.
modelEl.innerHTML = `<option value="${DEFAULT_MODEL}">${DEFAULT_MODEL}</option>`;
}
}
insertBtn.onclick = async () => {
const text = outputEl.value;
if (!text) return;
try {
await insertResult(text, (modeEl.value as InsertMode) || 'after');
setStatus('Inserted into the document.', 'ok');
} catch (e: any) {
setStatus(errMessage(e), 'error');
}
};
signInBtn.onclick = async () => {
setStatus('Opening Hanzo sign-in…');
try {
await signIn();
setStatus('Signed in.', 'ok');
reflectAuth();
void populateModels(); // a token unlocks the caller's org-specific models
} catch (e: any) {
setStatus(errMessage(e), 'error');
}
};
signOutBtn.onclick = async () => {
await clearToken();
setStatus('Signed out.', 'ok');
reflectAuth();
};
saveKeyBtn.onclick = async () => {
const key = apiKeyEl.value.trim();
if (key) {
await setApiKey(key);
setStatus('API key saved — ready to use.', 'ok');
} else {
await clearApiKey();
setStatus('API key cleared.', 'ok');
}
reflectAuth();
void populateModels(); // the new key may unlock a different model set
};
function reflectAuth() {
// Sync reflect only (no refresh/network): signed in via OAuth session or a
// saved API key. bearer()/getToken() do the real (async) resolution at call
// time; this just renders which credential is live.
const oauth = hasSession();
const keyed = hasApiKey();
signInBtn.style.display = oauth ? 'none' : '';
signOutBtn.style.display = oauth ? '' : 'none';
$('authhint').textContent = (oauth || keyed)
? 'Ready — using ' + (keyed ? 'your API key.' : 'your Hanzo sign-in.')
: 'Paste a Hanzo API key or sign in to use your models.';
}
function setBusy(busy: boolean, msg?: string) {
runBtn.disabled = busy;
for (const chip of chips) chip.disabled = busy;
if (msg) setStatus(msg);
}
function setStatus(msg: string, kind: 'ok' | 'warn' | 'error' | '' = '') {
statusEl.textContent = msg;
statusEl.className = 'status ' + kind;
}
});
function hostLabel(h: string): string {
return { word: 'Word', excel: 'Excel', powerpoint: 'PowerPoint' }[h] || 'Office';
}
function errMessage(e: any): string {
return e?.message ? String(e.message) : String(e);
}
+131
View File
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { buildMessages, requestBody, extractContent, ask, streamDelta, listModels } from '../src/chat';
import { DEFAULT_MODEL } from '../src/config';
describe('buildMessages', () => {
it('pure generation when no selection', () => {
const m = buildMessages('draft an NDA clause', '');
expect(m[0].role).toBe('system');
expect(m[1].role).toBe('user');
expect(m[1].content).toBe('draft an NDA clause');
});
it('fences the selection as data when present', () => {
const m = buildMessages('rewrite formally', 'hey wanna sign this');
expect(m[1].content).toContain('rewrite formally');
expect(m[1].content).toContain('---- selected text ----');
expect(m[1].content).toContain('hey wanna sign this');
});
it('system prompt forbids markdown fences / preamble', () => {
const m = buildMessages('x', '');
expect(m[0].content.toLowerCase()).toContain('only the text');
});
});
describe('requestBody', () => {
it('defaults the model and disables streaming', () => {
const b = requestBody(buildMessages('x', ''));
expect(b.model).toBe(DEFAULT_MODEL);
expect(b.stream).toBe(false);
expect(Array.isArray(b.messages)).toBe(true);
});
it('passes an explicit model + temperature through', () => {
const b = requestBody(buildMessages('x', ''), { model: 'zen-pro', temperature: 0.2 });
expect(b.model).toBe('zen-pro');
expect(b.temperature).toBe(0.2);
});
it('enables streaming only when opts.stream is true', () => {
expect(requestBody(buildMessages('x', ''), { stream: true }).stream).toBe(true);
expect(requestBody(buildMessages('x', ''), { stream: false }).stream).toBe(false);
});
});
describe('streamDelta', () => {
it('reads choices[0].delta.content from an SSE frame', () => {
expect(streamDelta(JSON.stringify({ choices: [{ delta: { content: 'Hel' } }] }))).toBe('Hel');
});
it('returns empty string for a role-only / empty delta', () => {
expect(streamDelta(JSON.stringify({ choices: [{ delta: { role: 'assistant' } }] }))).toBe('');
expect(streamDelta(JSON.stringify({ choices: [{}] }))).toBe('');
});
});
describe('listModels', () => {
afterEach(() => vi.unstubAllGlobals());
it('returns ids from the /v1/models data array with the bearer', async () => {
const fetchMock = vi.fn(async (url: string, init: any) => {
expect(url).toBe('https://api.hanzo.ai/v1/models');
expect(init.headers.Authorization).toBe('Bearer t');
return { ok: true, json: async () => ({ data: [{ id: 'zen-1' }, { id: 'zen-pro' }] }) } as any;
});
vi.stubGlobal('fetch', fetchMock);
expect(await listModels('t')).toEqual(['zen-1', 'zen-pro']);
});
it('tolerates a bare string array and omits the bearer when unauthenticated', async () => {
const fetchMock = vi.fn(async (_url: string, init: any) => {
expect(init.headers.Authorization).toBeUndefined();
return { ok: true, json: async () => ['a', 'b'] } as any;
});
vi.stubGlobal('fetch', fetchMock);
expect(await listModels('')).toEqual(['a', 'b']);
});
});
describe('extractContent', () => {
it('reads OpenAI choices[0].message.content', () => {
expect(extractContent({ choices: [{ message: { content: 'hello' } }] })).toBe('hello');
});
it('tolerates choices[0].text and bare content', () => {
expect(extractContent({ choices: [{ text: 'a' }] })).toBe('a');
expect(extractContent({ content: 'b' })).toBe('b');
});
it('throws on an error payload', () => {
expect(() => extractContent({ error: { message: 'rate limited' } })).toThrow(/rate limited/);
});
it('throws when there is no content', () => {
expect(() => extractContent({ choices: [{}] })).toThrow(/no content/);
});
});
describe('ask', () => {
afterEach(() => vi.unstubAllGlobals());
it('POSTs to the gateway with the bearer and returns the content', async () => {
const fetchMock = vi.fn(async (_url: string, init: any) => {
const body = JSON.parse(init.body);
expect(init.headers.Authorization).toBe('Bearer tok123');
expect(body.messages[1].content).toContain('summarize');
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ choices: [{ message: { content: 'summary text' } }] }),
} as any;
});
vi.stubGlobal('fetch', fetchMock);
const out = await ask('summarize', 'the rows', 'tok123');
expect(out).toBe('summary text');
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0][0]).toBe('https://api.hanzo.ai/v1/chat/completions');
});
it('omits Authorization when token is empty', async () => {
const fetchMock = vi.fn(async (_url: string, init: any) => {
expect(init.headers.Authorization).toBeUndefined();
return { ok: true, status: 200, text: async () => JSON.stringify({ content: 'ok' }) } as any;
});
vi.stubGlobal('fetch', fetchMock);
expect(await ask('hi', '', '')).toBe('ok');
});
it('surfaces a structured gateway error', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 429,
text: async () => JSON.stringify({ error: { message: 'too many requests' } }),
} as any)));
await expect(ask('x', '', 't')).rejects.toThrow(/too many requests/);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { chatCompletionsURL, modelsURL, API_BASE_URL, pickBearer } from '../src/config';
describe('model gateway endpoints', () => {
it('chat + models go through api.hanzo.ai/v1 (llm.hanzo.ai is dead)', () => {
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
expect(API_BASE_URL).toBe('https://api.hanzo.ai');
expect(chatCompletionsURL()).not.toContain('llm.hanzo.ai');
});
// IAM endpoints, the hanzo-office client, PKCE, and scopes now live in
// @hanzo/auth and are pinned by its config/oauth tests — not duplicated here.
});
describe('pickBearer', () => {
it('API key wins over the OAuth token', () => {
expect(pickBearer('hk-abc', 'jwt-xyz')).toBe('hk-abc');
});
it('falls back to the OAuth token when no key', () => {
expect(pickBearer('', 'jwt-xyz')).toBe('jwt-xyz');
expect(pickBearer(' ', 'jwt-xyz')).toBe('jwt-xyz');
});
it('empty when neither is present (anonymous)', () => {
expect(pickBearer('', '')).toBe('');
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { excelValuesToText, splitLines } from '../src/host';
describe('excelValuesToText', () => {
it('flattens a 2-D range to TSV rows', () => {
expect(excelValuesToText([['a', 'b'], ['c', 'd']])).toBe('a\tb\nc\td');
});
it('renders null/undefined cells as empty', () => {
expect(excelValuesToText([[1, null], [undefined, 4]])).toBe('1\t\n\t4');
});
it('empty range → empty string', () => {
expect(excelValuesToText([])).toBe('');
});
});
describe('splitLines', () => {
it('one line per cell down a column', () => {
expect(splitLines('a\nb\nc')).toEqual([['a'], ['b'], ['c']]);
});
it('drops trailing blank lines so a stray newline does not clear a cell', () => {
expect(splitLines('a\nb\n\n')).toEqual([['a'], ['b']]);
});
it('single line stays a 1x1', () => {
expect(splitLines('solo')).toEqual([['solo']]);
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["office-js"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+112
View File
@@ -0,0 +1,112 @@
# Hanzo AI for Microsoft Outlook
A **mail add-in** that puts Hanzo AI inside **Outlook** — desktop (Windows/Mac),
new Outlook, and Outlook on the web. Open a message, click the Hanzo button on
the ribbon, and:
- **Summarize thread** — parties, dates, defined terms, obligations, open questions.
- **Draft reply** — a professional reply in the sender's tone; open it in a reply
window or insert it into the compose body.
- **Extract deadlines & action items** — every due date and task, with owner and date.
- **Explain plainly** — plain-language read-out plus the risks/obligations/deadlines.
This is the highest-value surface for a law office after Word: the inbox is where
deadlines and commitments actually arrive.
It reuses the **same backend as everything else Hanzo**: model calls go through
`api.hanzo.ai/v1` (OpenAI-compatible, streamed), sign-in is Hanzo IAM (`hanzo.id`,
authorization-code + PKCE, HIP-0111) — the mail add-in shares the `hanzo-office`
IAM client. No new API surface.
## What it reads / writes
- **Read mode** (a received message is open): reads the body (`item.body.getAsync`
as plain text), subject, and `from` — the input to summarize / extract / draft.
The write path is **Draft reply**, which opens a reply form
(`item.displayReplyForm`) pre-filled with the AI text.
- **Compose mode** (a reply / new message is open): **Insert into message** writes
the draft into the compose body — prepended above the quoted thread
(`item.body.prependAsync`) or replacing the body (`item.body.setSelectedDataAsync`).
## Architecture (DRY with the Office task pane)
The pure, host-agnostic core is **identical** to `@hanzo/office-addin` and
unit-tested here:
- `config.ts` — the `api.hanzo.ai/v1` gateway endpoints + roaming-settings keys.
- `chat.ts``ask` / `askStream` (SSE) / `listModels` and the request/response shaping.
- `auth.ts` — the `@hanzo/auth` OAuth2+PKCE core wired to Office `roamingSettings`,
plus the pasted `hk-` API-key path.
The only Outlook-specific module is **`outlook-host.ts`**: the mailbox glue
(`Office.context.mailbox.item`) and its pure shaping helpers — `stripHtml`,
`collapseWhitespace`, `shapeMessage`, `formatFrom`, `mailMode` — which are what
the tests cover. The Office.js calls themselves are thin.
## Build
```bash
pnpm --filter @hanzo/outlook-addin build # production (base https://outlook.hanzo.ai)
pnpm --filter @hanzo/outlook-addin test # vitest
pnpm --filter @hanzo/outlook-addin typecheck # tsc --noEmit
```
The build stamps the **hosting base URL** into `dist/manifest.xml`. Outlook loads
the task pane from an HTTPS origin declared in the manifest; the same origin
serves `auth-callback.html` (the IAM redirect, registered on the `hanzo-office`
IAM client). Override the base for local dev:
```bash
HANZO_OUTLOOK_BASE=https://localhost:3000 pnpm --filter @hanzo/outlook-addin build
```
Then serve `dist/` over HTTPS at that base:
```bash
npx office-addin-dev-certs install # trust a localhost cert (once)
cd packages/outlook/dist && node serve.mjs # https://localhost:3000
```
## Sideload (no store needed)
A **mail add-in** installs differently from a Word/Excel task pane — you add it
through the mailbox, not "Insert → Add-ins".
- **Outlook on the web / new Outlook**: **Settings ⚙ → Mail → Customize actions**
is *not* the path — use **Get Add-ins** (the "…" / Apps menu on an open message)
**→ My add-ins → Custom Addins → Add a custom add-in → Add from file**, pick
`dist/manifest.xml`. (`dist/` must be served at the base URL over HTTPS.)
- **Outlook desktop (Windows/Mac, classic)**: **Home → Get Add-ins → My add-ins →
Custom add-ins → Add a custom add-in → Add from file** → `dist/manifest.xml`.
Requires a Microsoft 365 / Exchange Online mailbox (sideloading a custom mail
add-in from file needs the mailbox to allow it).
- Open any message: the **Hanzo AI** button appears on the message ribbon (read
view); open a reply/compose and it appears there too.
### Whole firm (recommended for a law office)
An M365 admin deploys the add-in org-wide — every user gets the Hanzo button with
no per-person sideload:
**Microsoft 365 admin center → Settings → Integrated apps → Upload custom apps →
Provide link to manifest file** (host `manifest.xml` at the base URL) **or upload
the manifest**, choose the users/groups, and deploy. Centralized Deployment
covers Outlook desktop, web, and Mac.
## Publish (AppSource — public listing)
`dist/manifest.xml` is a valid, submittable Mail add-in manifest (validate with
`npx office-addin-manifest validate dist/manifest.xml`). To list publicly:
1. Host `dist/` at the production base (`outlook.hanzo.ai`) over HTTPS.
2. Ensure the `hanzo-office` IAM client allows the redirect
`https://outlook.hanzo.ai/auth-callback.html`.
3. Submit through **Microsoft Partner Center → Office Store** (needs a Partner
Center account + Microsoft's validation pass).
## Requirements
- Mailbox requirement set **1.5+** (declared in the manifest) — covers current
Outlook desktop, Mac, and Outlook on the web / new Outlook.
- `Permissions: ReadWriteItem` — reads the open item and writes the reply draft;
it never touches other items or makes EWS calls (so *not* ReadWriteMailbox).
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+161
View File
@@ -0,0 +1,161 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Hanzo AI for Microsoft Outlook — a MAIL add-in (Outlook desktop, Mac, and
Outlook on the web / new Outlook). Distinct host category from Word/Excel/
PowerPoint: a Mail add-in uses xsi:type="MailApp", <Rule> activation, and
message Read + Compose command surfaces.
The task pane is served from __HANZO_OUTLOOK_BASE__ (build.js stamps this: the
production outlook.hanzo.ai host, or https://localhost:3000 for a sideloaded
dev build). The same origin serves auth-callback.html — the IAM redirect
target registered on the hanzo-office IAM client, which this add-in reuses.
-->
<OfficeApp
xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0"
xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0"
xsi:type="MailApp">
<Id>b1e4c8a2-3f57-4d61-9a2e-7c0d5f6a2b13</Id>
<Version>1.0.0.0</Version>
<ProviderName>Hanzo AI</ProviderName>
<DefaultLocale>en-US</DefaultLocale>
<DisplayName DefaultValue="Hanzo AI" />
<Description DefaultValue="AI over your email — summarize threads, draft replies, and extract deadlines and action items in Outlook. Powered by Hanzo (api.hanzo.ai)." />
<IconUrl DefaultValue="__HANZO_OUTLOOK_BASE__/assets/icon-80.png" />
<HighResolutionIconUrl DefaultValue="__HANZO_OUTLOOK_BASE__/assets/icon-128.png" />
<SupportUrl DefaultValue="https://hanzo.ai/support" />
<!-- Domains the task pane may navigate to (IAM login, the API gateway). -->
<AppDomains>
<AppDomain>https://hanzo.id</AppDomain>
<AppDomain>https://iam.hanzo.ai</AppDomain>
<AppDomain>https://api.hanzo.ai</AppDomain>
<AppDomain>https://hanzo.ai</AppDomain>
</AppDomains>
<Hosts>
<Host Name="Mailbox" />
</Hosts>
<Requirements>
<Sets>
<Set Name="Mailbox" MinVersion="1.5" />
</Sets>
</Requirements>
<!-- Base (legacy) form: activate the add-in on any read or compose message so
older Outlook clients still surface it. VersionOverrides below adds the
modern ribbon buttons for read + compose. -->
<FormSettings>
<Form xsi:type="ItemRead">
<DesktopSettings>
<SourceLocation DefaultValue="__HANZO_OUTLOOK_BASE__/taskpane.html" />
<RequestedHeight>450</RequestedHeight>
</DesktopSettings>
</Form>
<Form xsi:type="ItemEdit">
<DesktopSettings>
<SourceLocation DefaultValue="__HANZO_OUTLOOK_BASE__/taskpane.html" />
</DesktopSettings>
</Form>
</FormSettings>
<!-- ReadWriteItem: read the open message body/subject/from AND write the AI
draft into a compose reply. Not ReadWriteMailbox — we never touch other
items or make EWS calls. -->
<Permissions>ReadWriteItem</Permissions>
<Rule xsi:type="RuleCollection" Mode="Or">
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read" />
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Edit" />
</Rule>
<DisableEntityHighlighting>false</DisableEntityHighlighting>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
<Requirements>
<bt:Sets DefaultMinVersion="1.5">
<bt:Set Name="Mailbox" />
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<FunctionFile resid="Hanzo.CommandsUrl" />
<!-- Reading a message: ribbon button opens the Hanzo pane to
summarize, extract deadlines, or draft a reply. -->
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="Hanzo.MsgRead.Group">
<Label resid="Hanzo.Group.Label" />
<Control xsi:type="Button" id="Hanzo.MsgRead.Button">
<Label resid="Hanzo.TaskpaneButton.Label" />
<Supertip>
<Title resid="Hanzo.TaskpaneButton.Label" />
<Description resid="Hanzo.TaskpaneButton.Tooltip" />
</Supertip>
<Icon>
<bt:Image size="16" resid="Hanzo.Icon.16" />
<bt:Image size="32" resid="Hanzo.Icon.32" />
<bt:Image size="80" resid="Hanzo.Icon.80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Hanzo.TaskpaneUrl" />
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- Composing a message: ribbon button opens the Hanzo pane to draft
or refine the reply, inserting the result into the body. -->
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="Hanzo.MsgCompose.Group">
<Label resid="Hanzo.Group.Label" />
<Control xsi:type="Button" id="Hanzo.MsgCompose.Button">
<Label resid="Hanzo.TaskpaneButton.Label" />
<Supertip>
<Title resid="Hanzo.TaskpaneButton.Label" />
<Description resid="Hanzo.TaskpaneButton.Tooltip" />
</Supertip>
<Icon>
<bt:Image size="16" resid="Hanzo.Icon.16" />
<bt:Image size="32" resid="Hanzo.Icon.32" />
<bt:Image size="80" resid="Hanzo.Icon.80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Hanzo.TaskpaneUrl" />
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Hanzo.Icon.16" DefaultValue="__HANZO_OUTLOOK_BASE__/assets/icon-16.png" />
<bt:Image id="Hanzo.Icon.32" DefaultValue="__HANZO_OUTLOOK_BASE__/assets/icon-32.png" />
<bt:Image id="Hanzo.Icon.80" DefaultValue="__HANZO_OUTLOOK_BASE__/assets/icon-80.png" />
</bt:Images>
<bt:Urls>
<bt:Url id="Hanzo.TaskpaneUrl" DefaultValue="__HANZO_OUTLOOK_BASE__/taskpane.html" />
<bt:Url id="Hanzo.CommandsUrl" DefaultValue="__HANZO_OUTLOOK_BASE__/commands.html" />
<bt:Url id="Hanzo.SupportUrl" DefaultValue="https://hanzo.ai/support" />
</bt:Urls>
<bt:ShortStrings>
<bt:String id="Hanzo.Group.Label" DefaultValue="Hanzo AI" />
<bt:String id="Hanzo.TaskpaneButton.Label" DefaultValue="Hanzo AI" />
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="Hanzo.TaskpaneButton.Tooltip" DefaultValue="Open the Hanzo AI pane to summarize the thread, draft a reply, or extract deadlines and action items." />
</bt:LongStrings>
</Resources>
</VersionOverrides>
</OfficeApp>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@hanzo/outlook-addin",
"version": "1.9.30",
"description": "Hanzo AI for Microsoft Outlook \u2014 a mail add-in. AI over your email: summarize threads, draft replies, extract deadlines and action items. Wired to the same api.hanzo.ai gateway and hanzo.id IAM as the Office task pane and the browser extension.",
"private": true,
"dependencies": {
"@hanzo/auth": "workspace:*"
},
"scripts": {
"build": "node src/build.js",
"watch": "node src/build.js --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/office-js": "^1.0.377",
"esbuild": "^0.25.8",
"typescript": "^5.8.3",
"vitest": "^3.2.6"
},
"engines": {
"node": ">=18.0.0"
},
"license": "MIT"
}
+74
View File
@@ -0,0 +1,74 @@
// Self-contained HTTPS static server for local Office sideload testing on any
// machine (Windows included). It serves the files next to it — the built
// dist/ — at https://localhost:3000, which is the base a `localhost` build of
// the add-in points its manifest at. No framework, only Node stdlib.
//
// 1. npx office-addin-dev-certs install # trust a localhost HTTPS cert (once)
// 2. node serve.mjs # serve dist/ at https://localhost:3000
// 3. sideload manifest.xml (see SIDELOAD-WINDOWS.md)
//
// The cert/key are read from the office-addin-dev-certs default location
// (~/.office-addin-dev-certs). If they are absent the script prints the exact
// install command instead of failing cryptically.
import { createServer } from 'node:https';
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, extname, normalize } from 'node:path';
import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
const ROOT = fileURLToPath(new URL('.', import.meta.url));
const PORT = Number(process.env.PORT || 3000);
const CERT_DIR = join(homedir(), '.office-addin-dev-certs');
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.map': 'application/json',
'.json': 'application/json',
'.xml': 'text/xml',
'.png': 'image/png',
'.css': 'text/css',
};
async function loadCerts() {
const cert = join(CERT_DIR, 'localhost.crt');
const key = join(CERT_DIR, 'localhost.key');
if (!existsSync(cert) || !existsSync(key)) {
console.error(
'No localhost HTTPS certificate found.\n' +
'Install a trusted one first (Office refuses an untrusted task-pane origin):\n\n' +
' npx office-addin-dev-certs install\n',
);
process.exit(1);
}
return { cert: await readFile(cert), key: await readFile(key) };
}
const { cert, key } = await loadCerts();
createServer({ cert, key }, async (req, res) => {
// Resolve the request path safely under ROOT (no traversal).
let rel = decodeURIComponent((req.url || '/').split('?')[0]);
if (rel === '/') rel = '/taskpane.html';
const path = normalize(join(ROOT, rel));
if (!path.startsWith(ROOT)) {
res.writeHead(403).end('forbidden');
return;
}
try {
const body = await readFile(path);
res.writeHead(200, {
'Content-Type': TYPES[extname(path)] || 'application/octet-stream',
// Office loads the pane in an iframe; allow it and permit the API host.
'Access-Control-Allow-Origin': '*',
});
res.end(body);
} catch {
res.writeHead(404).end('not found: ' + rel);
}
}).listen(PORT, () => {
console.log(`Hanzo Office add-in served at https://localhost:${PORT}`);
console.log('Sideload manifest.xml — see SIDELOAD-WINDOWS.md.');
});
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Signing in…</title>
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<p>Completing sign-in…</p>
<script>
// The IAM redirect lands here inside the Office auth dialog. Hand the full
// callback URL (carrying ?code=&state=) back to the task pane via
// messageParent, then the pane exchanges the code for a token.
Office.onReady(function () {
try {
Office.context.ui.messageParent(window.location.href);
} catch (e) {
document.body.innerText = 'Sign-in could not return to the add-in: ' + e;
}
});
</script>
</body>
</html>
+131
View File
@@ -0,0 +1,131 @@
// Office's auth = the @hanzo/auth core + two thin Office adapters. The OAuth2 +
// PKCE flow, endpoints, and refresh live ONCE in @hanzo/auth; here we only teach
// it how Office stores tokens (roamingSettings) and how it opens a login window
// (the Dialog API). Plus the office-only API-key path for zero-setup use.
/* global Office */
import {
authClientFor, login as coreLogin, logout as coreLogout, getValidToken,
type TokenStore, type TokenSet, type Opener,
} from '@hanzo/auth';
import { APIKEY_SETTING_KEY, TOKEN_SETTING_KEY, pickBearer } from './config.js';
// The office IAM client, with the redirect served from the add-in's own origin
// (registered on the hanzo-office IAM client).
function client() {
return authClientFor('office', { redirectUri: `${location.origin}/auth-callback.html` });
}
// roamingStore is the Office TokenStore: roamingSettings is per-user and roams
// with the Office identity. The TokenSet is JSON-serialized under one key.
const roamingStore: TokenStore = {
async get(): Promise<TokenSet | null> {
try {
const raw = Office.context.roamingSettings.get(TOKEN_SETTING_KEY) as string;
return raw ? (JSON.parse(raw) as TokenSet) : null;
} catch {
return null;
}
},
set(tokens: TokenSet): Promise<void> {
return saveRoaming(TOKEN_SETTING_KEY, JSON.stringify(tokens));
},
clear(): Promise<void> {
return removeRoaming(TOKEN_SETTING_KEY);
},
};
// dialogOpener is the Office Opener: it opens the authorize URL in an Office
// dialog and resolves with the callback URL the dialog posts back via
// messageParent (auth-callback.html does that post).
const dialogOpener: Opener = {
open(authorizeUrl: string): Promise<string> {
return new Promise((resolve, reject) => {
Office.context.ui.displayDialogAsync(authorizeUrl, { height: 60, width: 30 }, (result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
reject(new Error(result.error?.message || 'could not open login dialog'));
return;
}
const dialog = result.value;
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (arg: any) => {
dialog.close();
resolve(String(arg.message || ''));
});
dialog.addEventHandler(Office.EventType.DialogEventReceived, () => {
reject(new Error('login dialog closed'));
});
});
});
},
};
// signIn / clearToken / getToken delegate to the core with the Office adapters.
export async function signIn(): Promise<string> {
const tokens = await coreLogin(client(), roamingStore, dialogOpener);
return tokens.accessToken;
}
export function clearToken(): Promise<void> {
return coreLogout(roamingStore);
}
// getToken returns a valid (auto-refreshed) access token, or '' if not signed in.
export async function getToken(): Promise<string> {
return getValidToken(client(), roamingStore);
}
// ── API-key path (office-only, zero-setup) ────────────────────────────────
export function getApiKey(): string {
try {
return (Office.context.roamingSettings.get(APIKEY_SETTING_KEY) as string) || '';
} catch {
return '';
}
}
export function setApiKey(key: string): Promise<void> {
return saveRoaming(APIKEY_SETTING_KEY, key.trim());
}
export function clearApiKey(): Promise<void> {
return removeRoaming(APIKEY_SETTING_KEY);
}
// bearer is the credential the chat call sends: a pasted API key wins over the
// OAuth token (see pickBearer). Async because the OAuth token may refresh.
export async function bearer(): Promise<string> {
const key = getApiKey();
if (key) return key;
return getValidToken(client(), roamingStore);
}
// hasApiKey / hasSession are SYNC reflect checks for the pane (no refresh, no
// network) — used only to render which credential is live, not to authorize.
export function hasApiKey(): boolean {
return !!getApiKey();
}
export function hasSession(): boolean {
try {
return !!(Office.context.roamingSettings.get(TOKEN_SETTING_KEY) as string);
} catch {
return false;
}
}
// ── roamingSettings helpers ───────────────────────────────────────────────
function saveRoaming(key: string, value: string): Promise<void> {
return new Promise((resolve, reject) => {
Office.context.roamingSettings.set(key, value);
Office.context.roamingSettings.saveAsync((r) => {
r.status === Office.AsyncResultStatus.Succeeded ? resolve() : reject(new Error('save failed'));
});
});
}
function removeRoaming(key: string): Promise<void> {
return new Promise((resolve) => {
Office.context.roamingSettings.remove(key);
Office.context.roamingSettings.saveAsync(() => resolve());
});
}
// pickBearer re-export kept for callers/tests that import it from auth.
export { pickBearer };
+84
View File
@@ -0,0 +1,84 @@
// Build the Outlook (mail) add-in into dist/: bundle the TS entry points, copy
// the static HTML + assets, and stamp the hosting base URL into a manifest.xml
// the user can sideload or submit to AppSource.
//
// node src/build.js → production (HANZO_OUTLOOK_BASE=https://outlook.hanzo.ai)
// HANZO_OUTLOOK_BASE=https://localhost:3000 node src/build.js → dev sideload
//
// The base URL is where the task pane is HOSTED. Office add-ins load the pane
// from an HTTPS origin declared in the manifest; the same origin serves
// auth-callback.html (the IAM redirect target registered on the hanzo-office
// client — the mail add-in reuses that client). One string, stamped everywhere,
// so the manifest and the served files never disagree.
const esbuild = require('esbuild');
const fs = require('fs');
const path = require('path');
const BASE = (process.env.HANZO_OUTLOOK_BASE || 'https://outlook.hanzo.ai').replace(/\/+$/, '');
const watch = process.argv.includes('--watch');
const root = path.resolve(__dirname, '..');
const dist = path.join(root, 'dist');
const entries = ['taskpane', 'commands'];
async function build() {
fs.rmSync(dist, { recursive: true, force: true });
fs.mkdirSync(dist, { recursive: true });
// Bundle each entry to dist/<name>.js.
const ctx = await esbuild.context({
entryPoints: entries.map((e) => path.join(__dirname, `${e}.ts`)),
outdir: dist,
bundle: true,
format: 'iife',
platform: 'browser',
target: ['chrome90', 'edge90', 'safari14'],
sourcemap: true,
minify: !watch,
logLevel: 'info',
});
await ctx.rebuild();
// Copy static HTML (taskpane, commands, auth-callback).
for (const f of ['taskpane.html', 'commands.html', 'auth-callback.html']) {
fs.copyFileSync(path.join(__dirname, f), path.join(dist, f));
}
// Copy the local-test kit (self-contained HTTPS server) so a downloaded
// localhost build is runnable as-is.
for (const f of ['serve.mjs']) {
fs.copyFileSync(path.join(root, f), path.join(dist, f));
}
// Copy assets/ (icons).
const assetsSrc = path.join(root, 'assets');
const assetsDst = path.join(dist, 'assets');
fs.mkdirSync(assetsDst, { recursive: true });
for (const f of fs.readdirSync(assetsSrc)) {
fs.copyFileSync(path.join(assetsSrc, f), path.join(assetsDst, f));
}
// Stamp the base URL into the manifest and write it into dist/.
const manifest = fs.readFileSync(path.join(root, 'manifest.xml'), 'utf8');
const stamped = manifest.split('__HANZO_OUTLOOK_BASE__').join(BASE);
if (stamped.includes('__HANZO_OUTLOOK_BASE__')) {
throw new Error('manifest still has an unstamped __HANZO_OUTLOOK_BASE__ placeholder');
}
fs.writeFileSync(path.join(dist, 'manifest.xml'), stamped);
console.log(`✅ Outlook add-in built → dist/ (base ${BASE})`);
console.log(' Sideload dist/manifest.xml; serve dist/ at that base over HTTPS.');
if (watch) {
await ctx.watch();
console.log('👀 watching…');
} else {
await ctx.dispose();
}
}
build().catch((e) => {
console.error(e);
process.exit(1);
});
+182
View File
@@ -0,0 +1,182 @@
// The Hanzo chat call and its request/response shaping — pure, host-agnostic,
// and fully unit-testable (no Office.js, no DOM). taskpane.ts is the thin glue
// that reads the document via Office.js and hands the text here.
import { chatCompletionsURL, modelsURL, DEFAULT_MODEL } from './config.js';
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
// buildMessages turns a task (the user's instruction) plus the document
// selection into the OpenAI-compatible message list. An empty selection is a
// pure generation ("draft a NDA clause about X"); a non-empty selection is an
// operation ON the text ("rewrite this formally"), and the selection is fenced
// so the model treats it as data, not instructions.
export function buildMessages(task: string, selection: string): ChatMessage[] {
const system: ChatMessage = {
role: 'system',
content:
'You are Hanzo AI inside a Microsoft Office document. Return ONLY the text to ' +
'insert into the document — no preamble, no markdown fences, no "Here is". ' +
'Match the document\'s tone. When given a selection, operate on it directly.',
};
const trimmed = selection.trim();
const user: ChatMessage = {
role: 'user',
content: trimmed
? `${task}\n\n---- selected text ----\n${selection}`
: task,
};
return [system, user];
}
export interface ChatOptions {
model?: string;
temperature?: number;
/** Request an SSE stream. Defaults to false (one-shot completion). */
stream?: boolean;
signal?: AbortSignal;
}
// requestBody is the exact JSON the gateway receives. Separated so a test can
// assert the wire shape (model default, stream flag, messages) without a
// network call. `stream` defaults to false — the one-shot `ask` contract.
export function requestBody(messages: ChatMessage[], opts: ChatOptions = {}): Record<string, unknown> {
return {
model: opts.model || DEFAULT_MODEL,
messages,
stream: opts.stream === true,
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
};
}
// extractContent pulls the assistant text out of an OpenAI-compatible
// completion response, tolerating the shapes the gateway returns (choices with
// message.content, or a bare content). Throws on an error payload so the pane
// surfaces the real gateway message.
export function extractContent(data: any): string {
if (data && data.error) {
const msg = typeof data.error === 'string' ? data.error : data.error.message || 'unknown error';
throw new Error(`Hanzo API error: ${msg}`);
}
const choice = data?.choices?.[0];
const content =
choice?.message?.content ??
choice?.text ??
data?.content ??
'';
if (typeof content !== 'string' || content === '') {
throw new Error('Hanzo API returned no content');
}
return content;
}
// ask runs one non-streaming completion and returns the text to insert. token
// may be empty (the gateway serves anonymous/limited models); when present it
// is the IAM bearer.
export async function ask(
task: string,
selection: string,
token: string,
opts: ChatOptions = {},
): Promise<string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
const resp = await fetch(chatCompletionsURL(), {
method: 'POST',
headers,
body: JSON.stringify(requestBody(buildMessages(task, selection), opts)),
signal: opts.signal,
});
const text = await resp.text();
let data: any;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Hanzo API ${resp.status}: ${text.slice(0, 200)}`);
}
if (!resp.ok) {
// Prefer the structured error message; fall back to status.
try {
return extractContent(data);
} catch {
const m = data?.error?.message || data?.error || `HTTP ${resp.status}`;
throw new Error(`Hanzo API ${resp.status}: ${m}`);
}
}
return extractContent(data);
}
// streamDelta pulls the incremental text out of one OpenAI-compatible SSE frame
// payload (the JSON after `data: `). Pure, so the SSE parse is unit-testable.
export function streamDelta(payload: string): string {
const json = JSON.parse(payload);
return json?.choices?.[0]?.delta?.content ?? '';
}
// askStream runs a streaming completion, invoking onDelta with each text chunk
// as it arrives, and resolves with the full concatenated text. Same inputs as
// `ask`; use it for a live-typing pane. Falls back to a thrown error on non-2xx.
export async function askStream(
task: string,
selection: string,
token: string,
onDelta: (text: string) => void,
opts: ChatOptions = {},
): Promise<string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
const resp = await fetch(chatCompletionsURL(), {
method: 'POST',
headers,
body: JSON.stringify(requestBody(buildMessages(task, selection), { ...opts, stream: true })),
signal: opts.signal,
});
if (!resp.ok || !resp.body) {
const detail = await resp.text().catch(() => '');
throw new Error(`Hanzo API ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let full = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const payload = trimmed.slice(5).trim();
if (payload === '[DONE]') return full;
try {
const delta = streamDelta(payload);
if (delta) {
full += delta;
onDelta(delta);
}
} catch {
// partial SSE frame — wait for the next chunk
}
}
}
return full;
}
// listModels returns the model ids the caller may route to, from /v1/models.
// The endpoint is org-scoped by the bearer; an empty token lists public models.
export async function listModels(token: string): Promise<string[]> {
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const resp = await fetch(modelsURL(), { headers });
if (!resp.ok) throw new Error(`Hanzo API ${resp.status}: model list failed`);
const data: any = await resp.json();
const items = data?.data ?? data?.models ?? (Array.isArray(data) ? data : []);
return items
.map((m: any) => (typeof m === 'string' ? m : m?.id))
.filter((id: unknown): id is string => typeof id === 'string' && id.length > 0);
}
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Hanzo AI commands</title>
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
<script src="commands.js"></script>
</head>
<body></body>
</html>
+10
View File
@@ -0,0 +1,10 @@
// The ribbon FunctionFile. Office loads commands.html (which loads this) for
// any Action of type ExecuteFunction. The Hanzo add-in's only ribbon control is
// ShowTaskpane (no ExecuteFunction), so this exists to satisfy the manifest's
// FunctionFile requirement and to host future one-click ribbon actions.
/* global Office */
Office.onReady(() => {
/* no-op: the ribbon button opens the task pane directly */
});
+30
View File
@@ -0,0 +1,30 @@
// Office add-in config — ONLY what is office-specific. IAM endpoints, the OAuth
// client, PKCE, and scopes now live in @hanzo/auth (the one auth core); this
// module keeps the api.hanzo.ai model gateway + the roaming-settings keys.
export const API_BASE_URL = 'https://api.hanzo.ai';
// Default model. Overridable per-request; the gateway routes it.
export const DEFAULT_MODEL = 'zen-1';
// Roaming-settings keys (Office.context.roamingSettings persists per-user across
// the user's devices). Two auth paths: the IAM OAuth token set (managed by
// @hanzo/auth) and a pasted Hanzo API key (`hk-…`) for zero-setup use — the key
// needs no registered OAuth client, which is why the Windows test kit uses it.
export const TOKEN_SETTING_KEY = 'hanzo.tokens';
export const APIKEY_SETTING_KEY = 'hanzo.apiKey';
// pickBearer chooses the credential to send: a pasted API key wins over the
// OAuth token (an explicit key is a deliberate override), else the OAuth token,
// else empty (anonymous). Pure — unit-tested.
export function pickBearer(apiKey: string, oauthToken: string): string {
return (apiKey && apiKey.trim()) || (oauthToken && oauthToken.trim()) || '';
}
// chatCompletionsURL / modelsURL — the model gateway endpoints the add-in calls.
export function chatCompletionsURL(): string {
return `${API_BASE_URL}/v1/chat/completions`;
}
export function modelsURL(): string {
return `${API_BASE_URL}/v1/models`;
}
+170
View File
@@ -0,0 +1,170 @@
// The Outlook (mail) host glue: read the open message and write the AI draft
// into a reply. Outlook is a DIFFERENT Office.js surface from Word/Excel/PPT —
// there is no document "selection"; the unit of work is the mail item
// (Office.context.mailbox.item). Two modes:
// READ — a received message is open; we read its body/subject/from to
// summarize, extract deadlines, or draft a reply.
// COMPOSE — a reply/new message is open; we write the draft into its body.
//
// As in host.ts (Word/Excel), the Office-calling functions are thin and the
// shaping helpers (stripHtml, collapseWhitespace, shapeMessage) are pure and
// unit-tested — no Office.js, no DOM.
/* global Office */
export type MailMode = 'read' | 'compose' | 'unknown';
export type ReplyMode = 'replace' | 'prepend';
// A message read off the mail item, normalized for the model.
export interface MailContext {
subject: string;
from: string;
body: string;
}
// mailMode maps an Outlook item to read vs compose. A read item exposes
// `itemType`/`from`; a compose item exposes `body.setSelectedDataAsync` and no
// `from`. We key off the presence of `from` accessor semantics via displayReplyForm
// availability, but the reliable signal Office gives is item.itemType +
// whether from is a plain object (read) or absent (compose). Pure given the
// two booleans a caller can cheaply probe.
export function mailMode(hasFrom: boolean, canSetBody: boolean): MailMode {
if (hasFrom) return 'read';
if (canSetBody) return 'compose';
return 'unknown';
}
// stripHtml removes tags and decodes the handful of entities Outlook bodies
// carry, so an HTML mail body becomes readable plain text for the model. Pure.
// It is deliberately small: Outlook can return the body as 'text' directly
// (getBodyText below asks for that), so this only guards the HTML fallback.
export function stripHtml(html: string): string {
return collapseWhitespace(
html
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ')
.replace(/<\s*br\s*\/?\s*>/gi, '\n')
.replace(/<\s*\/\s*(p|div|tr|li|h[1-6])\s*>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'"),
);
}
// collapseWhitespace trims each line and drops runs of blank lines so the model
// sees clean text (Outlook HTML→text conversions leave ragged whitespace). Pure.
export function collapseWhitespace(text: string): string {
return text
.split('\n')
.map((l) => l.replace(/[ \t ]+/g, ' ').trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
// shapeMessage renders a MailContext into the single string handed to chat.ts
// as the "selection" — a labeled block the model reads as data (subject, from,
// then the body). Pure, so the prompt shaping is unit-testable. Empty fields
// are omitted so a compose-mode empty context doesn't emit "Subject: ".
export function shapeMessage(ctx: MailContext): string {
const headers: string[] = [];
if (ctx.subject.trim()) headers.push(`Subject: ${ctx.subject.trim()}`);
if (ctx.from.trim()) headers.push(`From: ${ctx.from.trim()}`);
const body = ctx.body.trim();
const headerBlock = headers.join('\n');
// Separate the body from any header lines with a blank line; when there are
// no headers (compose mode) the body stands alone with no leading newline.
if (headerBlock && body) return `${headerBlock}\n\n${body}`;
return headerBlock || body;
}
// formatFrom renders an Outlook EmailAddressDetails into "Name <addr>" (or just
// the address when there is no display name). Pure. `undefined` → ''.
export function formatFrom(from: { displayName?: string; emailAddress?: string } | undefined): string {
if (!from) return '';
const name = (from.displayName || '').trim();
const addr = (from.emailAddress || '').trim();
if (name && addr && name !== addr) return `${name} <${addr}>`;
return addr || name;
}
// ── Office.js mailbox glue (thin) ─────────────────────────────────────────
// currentMailMode reads the live item and classifies it. `from` is only
// present on a read item; a compose item has body.setSelectedDataAsync.
export function currentMailMode(): MailMode {
const item: any = Office.context?.mailbox?.item;
if (!item) return 'unknown';
const hasFrom = !!item.from;
const canSetBody = !!(item.body && typeof item.body.setSelectedDataAsync === 'function' && !hasFrom);
return mailMode(hasFrom, canSetBody);
}
// getBodyText reads the item body as plain text (Outlook converts HTML→text for
// us when we ask for CoercionType.Text). Falls back to '' on failure.
function getBodyText(): Promise<string> {
return new Promise((resolve) => {
const item: any = Office.context?.mailbox?.item;
if (!item?.body?.getAsync) {
resolve('');
return;
}
item.body.getAsync(Office.CoercionType.Text, (r: any) => {
if (r.status === Office.AsyncResultStatus.Succeeded) {
resolve(collapseWhitespace(String(r.value || '')));
} else {
resolve('');
}
});
});
}
// readMessage returns the open message as a MailContext: subject, from, and the
// plain-text body. Works in both read and compose mode (compose has no `from`
// and an empty/quoted body). The pure shaping happens in shapeMessage.
export async function readMessage(): Promise<MailContext> {
const item: any = Office.context?.mailbox?.item;
const subject = typeof item?.subject === 'string' ? item.subject : '';
const from = formatFrom(item?.from);
const body = await getBodyText();
return { subject, from, body };
}
// readContext is the "selection" the task pane feeds chat.ts: the shaped
// message block. Empty when there is no readable item.
export async function readContext(): Promise<string> {
if (currentMailMode() === 'unknown') return '';
return shapeMessage(await readMessage());
}
// insertDraft writes the AI output into the compose body. `mode` chooses
// replace-the-body vs prepend-above-the-existing-quote (a reply keeps the
// quoted thread below the new text). Uses HTML coercion off so the plain draft
// lands as-is. Only valid in compose mode.
export async function insertDraft(text: string, mode: ReplyMode): Promise<void> {
const item: any = Office.context?.mailbox?.item;
if (!item?.body) throw new Error('No compose message is open');
const options = { coercionType: Office.CoercionType.Text };
await new Promise<void>((resolve, reject) => {
const cb = (r: any) =>
r.status === Office.AsyncResultStatus.Succeeded
? resolve()
: reject(new Error(r.error?.message || 'insert failed'));
if (mode === 'prepend' && typeof item.body.prependAsync === 'function') {
item.body.prependAsync(text, options, cb);
} else {
item.body.setSelectedDataAsync(text, options, cb);
}
});
}
// startReply opens a reply form pre-filled with the AI draft, from a READ
// message — the one-click "draft a reply" path. Outlook's displayReplyForm
// takes the reply body directly, so we skip the compose round-trip.
export function startReply(draft: string): void {
const item: any = Office.context?.mailbox?.item;
if (item?.displayReplyForm) item.displayReplyForm(draft);
}
+88
View File
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hanzo AI</title>
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
<style>
:root { color-scheme: light dark; --fg:#1a1a1a; --muted:#666; --bg:#fff; --line:#e3e3e3; --accent:#111; }
@media (prefers-color-scheme: dark) { :root { --fg:#eaeaea; --muted:#9a9a9a; --bg:#1e1e1e; --line:#333; --accent:#fff; } }
* { box-sizing: border-box; }
body { font: 14px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 12px; }
header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
header img { width: 22px; height: 22px; }
header .title { font-weight: 600; }
header .host { color: var(--muted); font-size: 12px; margin-left: auto; }
label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 4px; }
textarea, select { width: 100%; font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px; }
textarea#prompt { min-height: 64px; resize: vertical; }
textarea#output { min-height: 140px; resize: vertical; }
.row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
button { font: inherit; border: 1px solid var(--line); background: var(--accent); color: var(--bg); border-radius: 6px; padding: 8px 14px; cursor: pointer; }
button.secondary { background: transparent; color: var(--fg); }
button:disabled { opacity: .5; cursor: default; }
.status { min-height: 18px; font-size: 12px; margin-top: 8px; color: var(--muted); }
.status.ok { color: #1a7f37; } .status.warn { color: #9a6700; } .status.error { color: #cf222e; }
.spacer { flex: 1; }
footer { margin-top: 10px; font-size: 11px; color: var(--muted); }
select#model { width: auto; max-width: 150px; margin-left: auto; padding: 4px 8px; font-size: 12px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip { padding: 5px 10px; border-radius: 999px; font-size: 13px; background: transparent; color: var(--fg); border: 1px solid var(--line); }
.chip:hover:not(:disabled) { border-color: var(--accent); }
</style>
</head>
<body>
<header>
<img src="assets/icon-32.png" alt="Hanzo" />
<span class="title">Hanzo AI</span>
<select id="model" aria-label="Model"></select>
<span class="host" id="hostname">Mail</span>
</header>
<!-- One-click actions over the open message (a law office in Outlook). -->
<div class="chips">
<button class="chip" data-task="Summarize this email thread. Preserve the parties, dates, defined terms, obligations, and any decisions or open questions. Use tight bullets.">Summarize thread</button>
<button class="chip" data-task="Draft a professional reply to this email in the same tone. Address every question and request, propose concrete next steps, and keep it concise. Return only the reply body — no subject line, no signature.">Draft reply</button>
<button class="chip" data-task="Extract every deadline, due date, and action item from this thread. For each, give: the item, who owns it, and the date if stated. List them as bullets; write 'none stated' if there are none.">Extract deadlines &amp; action items</button>
<button class="chip" data-task="Explain this email in plain, non-jargon language, and note any risks, obligations, or deadlines the reader should be aware of.">Explain plainly</button>
</div>
<label for="prompt">What should Hanzo do with this message?</label>
<textarea id="prompt" placeholder="e.g. Draft a reply declining the extension · List every deadline in this thread · Summarize for the partner"></textarea>
<div class="row">
<button id="run">Ask Hanzo</button>
<button id="signin" class="secondary">Sign in</button>
<button id="signout" class="secondary" style="display:none">Sign out</button>
<span class="spacer"></span>
</div>
<details>
<summary style="cursor:pointer;font-size:12px;color:var(--muted);margin-top:8px;">Or use a Hanzo API key</summary>
<div class="row">
<input id="apikey" type="password" placeholder="hk-…" style="flex:1;font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;" />
<button id="savekey" class="secondary">Save</button>
</div>
<div id="authhint" style="font-size:11px;color:var(--muted);margin-top:4px;"></div>
</details>
<div class="status" id="status"></div>
<label for="output">Result</label>
<textarea id="output" placeholder="Hanzo's output appears here — review before inserting or drafting a reply."></textarea>
<div class="row">
<select id="mode" aria-label="Insert mode">
<option value="prepend">Prepend above the quote</option>
<option value="replace">Replace message body</option>
</select>
<button id="insert" disabled>Insert into message</button>
<button id="reply" disabled>Draft reply</button>
</div>
<footer>Routed through api.hanzo.ai · sign in with Hanzo (hanzo.id) for your models.</footer>
<script src="taskpane.js"></script>
</body>
</html>
+189
View File
@@ -0,0 +1,189 @@
// Task-pane orchestration for Outlook: wire the UI to read the open message (or
// the compose draft), call Hanzo, stream the result, and either insert it into a
// compose reply or open a reply form. The logic-heavy parts (chat shaping, mail
// body extraction, auth) live in their own tested modules; this is the glue that
// binds them to the DOM once Office is ready.
/* global Office, document */
import { askStream, listModels } from './chat.js';
import { readContext, insertDraft, startReply, currentMailMode, type ReplyMode } from './outlook-host.js';
import { bearer, getApiKey, setApiKey, clearApiKey, signIn, clearToken, hasApiKey, hasSession } from './auth.js';
import { DEFAULT_MODEL } from './config.js';
Office.onReady((info) => {
if (info.host !== Office.HostType.Outlook) {
return; // not Outlook — the pane shows its static "unsupported" note
}
const $ = (id: string) => document.getElementById(id) as HTMLElement;
const promptEl = $('prompt') as HTMLTextAreaElement;
const runBtn = $('run') as HTMLButtonElement;
const statusEl = $('status');
const outputEl = $('output') as HTMLTextAreaElement;
const insertBtn = $('insert') as HTMLButtonElement;
const replyBtn = $('reply') as HTMLButtonElement;
const modeEl = $('mode') as HTMLSelectElement;
const modelEl = $('model') as HTMLSelectElement;
const signInBtn = $('signin') as HTMLButtonElement;
const signOutBtn = $('signout') as HTMLButtonElement;
const apiKeyEl = $('apikey') as HTMLInputElement;
const saveKeyBtn = $('savekey') as HTMLButtonElement;
const chips = Array.from(document.querySelectorAll<HTMLButtonElement>('.chip'));
const mode = currentMailMode();
$('hostname').textContent = mode === 'compose' ? 'Compose' : 'Read';
// In read mode there is no compose body to insert into — reply form is the
// write path; in compose mode Insert writes the draft, reply form is N/A.
insertBtn.style.display = mode === 'compose' ? '' : 'none';
modeEl.style.display = mode === 'compose' ? '' : 'none';
replyBtn.style.display = mode === 'read' ? '' : 'none';
apiKeyEl.value = getApiKey();
reflectAuth();
void populateModels();
let controller: AbortController | null = null;
// One code path for the Ask button AND the quick-action chips: read the open
// message, stream the completion into the output, enable Insert/Reply.
async function runTask(task: string) {
if (!task) {
setStatus('Type an instruction or pick an action first.', 'warn');
return;
}
controller?.abort();
controller = new AbortController();
setBusy(true, 'Reading message…');
outputEl.value = '';
insertBtn.disabled = true;
replyBtn.disabled = true;
try {
const context = await readContext();
setStatus('Asking Hanzo…');
await askStream(
task,
context,
await bearer(),
(delta) => { outputEl.value += delta; outputEl.scrollTop = outputEl.scrollHeight; },
{ model: modelEl.value || DEFAULT_MODEL, signal: controller.signal },
);
const has = !!outputEl.value;
insertBtn.disabled = !has;
replyBtn.disabled = !has;
setStatus(has ? 'Done — review, then Insert or Draft reply.' : 'No content returned.', has ? 'ok' : 'warn');
} catch (e: any) {
if (e?.name === 'AbortError') return;
setStatus(errMessage(e), 'error');
} finally {
setBusy(false);
}
}
runBtn.onclick = () => runTask(promptEl.value.trim());
// Quick-action chip: seed the prompt with its preset task, then run it.
for (const chip of chips) {
chip.onclick = () => {
const task = chip.dataset.task ?? '';
promptEl.value = task;
void runTask(task);
};
}
async function populateModels() {
try {
const ids = (await listModels(await bearer())).sort();
if (!ids.length) return;
modelEl.innerHTML = '';
for (const id of ids) {
const opt = document.createElement('option');
opt.value = id; opt.textContent = id;
modelEl.appendChild(opt);
}
modelEl.value = ids.includes(DEFAULT_MODEL) ? DEFAULT_MODEL : ids[0];
} catch {
// Non-fatal — a fixed default still works; leave the picker with its default.
modelEl.innerHTML = `<option value="${DEFAULT_MODEL}">${DEFAULT_MODEL}</option>`;
}
}
insertBtn.onclick = async () => {
const text = outputEl.value;
if (!text) return;
try {
await insertDraft(text, (modeEl.value as ReplyMode) || 'prepend');
setStatus('Inserted into the message.', 'ok');
} catch (e: any) {
setStatus(errMessage(e), 'error');
}
};
replyBtn.onclick = () => {
const text = outputEl.value;
if (!text) return;
try {
startReply(text);
setStatus('Reply drafted — review and send in the reply window.', 'ok');
} catch (e: any) {
setStatus(errMessage(e), 'error');
}
};
signInBtn.onclick = async () => {
setStatus('Opening Hanzo sign-in…');
try {
await signIn();
setStatus('Signed in.', 'ok');
reflectAuth();
void populateModels(); // a token unlocks the caller's org-specific models
} catch (e: any) {
setStatus(errMessage(e), 'error');
}
};
signOutBtn.onclick = async () => {
await clearToken();
setStatus('Signed out.', 'ok');
reflectAuth();
};
saveKeyBtn.onclick = async () => {
const key = apiKeyEl.value.trim();
if (key) {
await setApiKey(key);
setStatus('API key saved — ready to use.', 'ok');
} else {
await clearApiKey();
setStatus('API key cleared.', 'ok');
}
reflectAuth();
void populateModels(); // the new key may unlock a different model set
};
function reflectAuth() {
// Sync reflect only (no refresh/network): signed in via OAuth session or a
// saved API key. bearer()/getToken() do the real (async) resolution at call
// time; this just renders which credential is live.
const oauth = hasSession();
const keyed = hasApiKey();
signInBtn.style.display = oauth ? 'none' : '';
signOutBtn.style.display = oauth ? '' : 'none';
$('authhint').textContent = (oauth || keyed)
? 'Ready — using ' + (keyed ? 'your API key.' : 'your Hanzo sign-in.')
: 'Paste a Hanzo API key or sign in to use your models.';
}
function setBusy(busy: boolean, msg?: string) {
runBtn.disabled = busy;
for (const chip of chips) chip.disabled = busy;
if (msg) setStatus(msg);
}
function setStatus(msg: string, kind: 'ok' | 'warn' | 'error' | '' = '') {
statusEl.textContent = msg;
statusEl.className = 'status ' + kind;
}
});
function errMessage(e: any): string {
return e?.message ? String(e.message) : String(e);
}
+131
View File
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { buildMessages, requestBody, extractContent, ask, streamDelta, listModels } from '../src/chat';
import { DEFAULT_MODEL } from '../src/config';
describe('buildMessages', () => {
it('pure generation when no selection', () => {
const m = buildMessages('draft an NDA clause', '');
expect(m[0].role).toBe('system');
expect(m[1].role).toBe('user');
expect(m[1].content).toBe('draft an NDA clause');
});
it('fences the selection as data when present', () => {
const m = buildMessages('rewrite formally', 'hey wanna sign this');
expect(m[1].content).toContain('rewrite formally');
expect(m[1].content).toContain('---- selected text ----');
expect(m[1].content).toContain('hey wanna sign this');
});
it('system prompt forbids markdown fences / preamble', () => {
const m = buildMessages('x', '');
expect(m[0].content.toLowerCase()).toContain('only the text');
});
});
describe('requestBody', () => {
it('defaults the model and disables streaming', () => {
const b = requestBody(buildMessages('x', ''));
expect(b.model).toBe(DEFAULT_MODEL);
expect(b.stream).toBe(false);
expect(Array.isArray(b.messages)).toBe(true);
});
it('passes an explicit model + temperature through', () => {
const b = requestBody(buildMessages('x', ''), { model: 'zen-pro', temperature: 0.2 });
expect(b.model).toBe('zen-pro');
expect(b.temperature).toBe(0.2);
});
it('enables streaming only when opts.stream is true', () => {
expect(requestBody(buildMessages('x', ''), { stream: true }).stream).toBe(true);
expect(requestBody(buildMessages('x', ''), { stream: false }).stream).toBe(false);
});
});
describe('streamDelta', () => {
it('reads choices[0].delta.content from an SSE frame', () => {
expect(streamDelta(JSON.stringify({ choices: [{ delta: { content: 'Hel' } }] }))).toBe('Hel');
});
it('returns empty string for a role-only / empty delta', () => {
expect(streamDelta(JSON.stringify({ choices: [{ delta: { role: 'assistant' } }] }))).toBe('');
expect(streamDelta(JSON.stringify({ choices: [{}] }))).toBe('');
});
});
describe('listModels', () => {
afterEach(() => vi.unstubAllGlobals());
it('returns ids from the /v1/models data array with the bearer', async () => {
const fetchMock = vi.fn(async (url: string, init: any) => {
expect(url).toBe('https://api.hanzo.ai/v1/models');
expect(init.headers.Authorization).toBe('Bearer t');
return { ok: true, json: async () => ({ data: [{ id: 'zen-1' }, { id: 'zen-pro' }] }) } as any;
});
vi.stubGlobal('fetch', fetchMock);
expect(await listModels('t')).toEqual(['zen-1', 'zen-pro']);
});
it('tolerates a bare string array and omits the bearer when unauthenticated', async () => {
const fetchMock = vi.fn(async (_url: string, init: any) => {
expect(init.headers.Authorization).toBeUndefined();
return { ok: true, json: async () => ['a', 'b'] } as any;
});
vi.stubGlobal('fetch', fetchMock);
expect(await listModels('')).toEqual(['a', 'b']);
});
});
describe('extractContent', () => {
it('reads OpenAI choices[0].message.content', () => {
expect(extractContent({ choices: [{ message: { content: 'hello' } }] })).toBe('hello');
});
it('tolerates choices[0].text and bare content', () => {
expect(extractContent({ choices: [{ text: 'a' }] })).toBe('a');
expect(extractContent({ content: 'b' })).toBe('b');
});
it('throws on an error payload', () => {
expect(() => extractContent({ error: { message: 'rate limited' } })).toThrow(/rate limited/);
});
it('throws when there is no content', () => {
expect(() => extractContent({ choices: [{}] })).toThrow(/no content/);
});
});
describe('ask', () => {
afterEach(() => vi.unstubAllGlobals());
it('POSTs to the gateway with the bearer and returns the content', async () => {
const fetchMock = vi.fn(async (_url: string, init: any) => {
const body = JSON.parse(init.body);
expect(init.headers.Authorization).toBe('Bearer tok123');
expect(body.messages[1].content).toContain('summarize');
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ choices: [{ message: { content: 'summary text' } }] }),
} as any;
});
vi.stubGlobal('fetch', fetchMock);
const out = await ask('summarize', 'the rows', 'tok123');
expect(out).toBe('summary text');
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0][0]).toBe('https://api.hanzo.ai/v1/chat/completions');
});
it('omits Authorization when token is empty', async () => {
const fetchMock = vi.fn(async (_url: string, init: any) => {
expect(init.headers.Authorization).toBeUndefined();
return { ok: true, status: 200, text: async () => JSON.stringify({ content: 'ok' }) } as any;
});
vi.stubGlobal('fetch', fetchMock);
expect(await ask('hi', '', '')).toBe('ok');
});
it('surfaces a structured gateway error', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 429,
text: async () => JSON.stringify({ error: { message: 'too many requests' } }),
} as any)));
await expect(ask('x', '', 't')).rejects.toThrow(/too many requests/);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { chatCompletionsURL, modelsURL, API_BASE_URL, pickBearer } from '../src/config';
describe('model gateway endpoints', () => {
it('chat + models go through api.hanzo.ai/v1 (llm.hanzo.ai is dead)', () => {
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
expect(API_BASE_URL).toBe('https://api.hanzo.ai');
expect(chatCompletionsURL()).not.toContain('llm.hanzo.ai');
});
// IAM endpoints, the hanzo-office client, PKCE, and scopes now live in
// @hanzo/auth and are pinned by its config/oauth tests — not duplicated here.
});
describe('pickBearer', () => {
it('API key wins over the OAuth token', () => {
expect(pickBearer('hk-abc', 'jwt-xyz')).toBe('hk-abc');
});
it('falls back to the OAuth token when no key', () => {
expect(pickBearer('', 'jwt-xyz')).toBe('jwt-xyz');
expect(pickBearer(' ', 'jwt-xyz')).toBe('jwt-xyz');
});
it('empty when neither is present (anonymous)', () => {
expect(pickBearer('', '')).toBe('');
});
});
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import {
stripHtml,
collapseWhitespace,
shapeMessage,
formatFrom,
mailMode,
} from '../src/outlook-host';
describe('collapseWhitespace', () => {
it('trims each line and collapses inner runs of spaces/tabs', () => {
expect(collapseWhitespace(' a b \t c ')).toBe('a b c');
});
it('drops runs of blank lines down to one', () => {
expect(collapseWhitespace('a\n\n\n\nb')).toBe('a\n\nb');
});
it('trims leading and trailing blank lines', () => {
expect(collapseWhitespace('\n\n hi \n\n')).toBe('hi');
});
});
describe('stripHtml', () => {
it('turns <br> and block-close tags into newlines', () => {
expect(stripHtml('<p>Hi</p><p>Bye</p>')).toBe('Hi\nBye');
expect(stripHtml('one<br>two')).toBe('one\ntwo');
});
it('removes script/style blocks entirely', () => {
expect(stripHtml('<style>.a{color:red}</style>text<script>x=1</script>')).toBe('text');
});
it('decodes the common entities', () => {
expect(stripHtml('a &amp; b &lt;c&gt; &quot;d&quot; &#39;e&#39; &nbsp;f')).toBe(
'a & b <c> "d" \'e\' f',
);
});
it('strips arbitrary tags and collapses whitespace', () => {
expect(stripHtml('<div> <span>deadline</span> is <b>Friday</b> </div>')).toBe(
'deadline is Friday',
);
});
});
describe('formatFrom', () => {
it('renders "Name <addr>" when both present and differ', () => {
expect(formatFrom({ displayName: 'Jane Doe', emailAddress: 'jane@law.example' })).toBe(
'Jane Doe <jane@law.example>',
);
});
it('falls back to the address when there is no display name', () => {
expect(formatFrom({ emailAddress: 'jane@law.example' })).toBe('jane@law.example');
});
it('does not duplicate when name equals address', () => {
expect(formatFrom({ displayName: 'jane@law.example', emailAddress: 'jane@law.example' })).toBe(
'jane@law.example',
);
});
it('undefined → empty string', () => {
expect(formatFrom(undefined)).toBe('');
});
});
describe('shapeMessage', () => {
it('labels subject, from, then a body block separated by a blank line', () => {
const out = shapeMessage({
subject: 'Motion deadline',
from: 'Jane Doe <jane@law.example>',
body: 'Please file by Friday.',
});
expect(out).toBe('Subject: Motion deadline\nFrom: Jane Doe <jane@law.example>\n\nPlease file by Friday.');
});
it('omits empty fields (compose mode: no subject/from)', () => {
expect(shapeMessage({ subject: '', from: '', body: 'draft in progress' })).toBe('draft in progress');
});
it('omits the body block when the body is empty', () => {
expect(shapeMessage({ subject: 'Hi', from: '', body: ' ' })).toBe('Subject: Hi');
});
it('everything empty → empty string', () => {
expect(shapeMessage({ subject: '', from: '', body: '' })).toBe('');
});
});
describe('mailMode', () => {
it('read when the item exposes a from', () => {
expect(mailMode(true, false)).toBe('read');
expect(mailMode(true, true)).toBe('read');
});
it('compose when there is no from but the body is writable', () => {
expect(mailMode(false, true)).toBe('compose');
});
it('unknown when neither', () => {
expect(mailMode(false, false)).toBe('unknown');
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["office-js"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+2 -1
View File
@@ -20,7 +20,8 @@
"curly": "warn",
"eqeqeq": "warn",
"no-throw-literal": "warn",
"semi": "off"
"semi": "off",
"@typescript-eslint/no-var-requires": "off"
},
"ignorePatterns": [
"out",
+7 -5
View File
@@ -2,7 +2,7 @@
"name": "hanzo-ide",
"displayName": "Hanzo AI",
"description": "The ultimate meta AI development platform. Manage and run all LLMs and CLI tools (Claude, Codex, Gemini, OpenHands, Aider) in one unified interface. Features MCP integration, async execution, git worktrees, and universal context sync.",
"version": "1.8.0",
"version": "1.9.30",
"publisher": "hanzo-ai",
"private": false,
"license": "MIT",
@@ -295,7 +295,7 @@
"vscode:prepublish": "node scripts/compile-main.js || true && npm run build:mcp",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"pretest": "npm run compile && npm run lint",
"pretest": "npm run compile && tsc -p tsconfig.test.json && npm run lint",
"lint": "eslint .",
"test": "node ./out/test/runTest.js",
"test:simple": "node test-simple.js",
@@ -306,7 +306,7 @@
"test:mcp-proxy": "cross-env MOCHA_TEST_FILE=\"MCP.*Proxy Test\" node ./out/test/runTest.js",
"test:mcp-integration": "cross-env MOCHA_TEST_FILE=\"MCP Proxy Integration\" node ./out/test/runTest.js",
"test:coverage": "c8 npm test",
"test:vitest": "vitest",
"test:vitest": "vitest run",
"test:vitest:coverage": "vitest --coverage",
"test:vitest:run": "vitest run --coverage",
"build:mcp": "node scripts/build-mcp-standalone.js",
@@ -329,7 +329,8 @@
"start:prod": "cross-env VSCODE_ENV=production npm run watch",
"preview": "npx serve . -p 3000",
"deploy": "vercel --prod",
"deploy:preview": "vercel"
"deploy:preview": "vercel",
"posttest": "vitest run"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
@@ -381,7 +382,8 @@
"tree-sitter": "^0.21.1",
"tree-sitter-javascript": "^0.23.1",
"tree-sitter-typescript": "^0.23.2",
"zod": "^3.25.71"
"zod": "^3.25.71",
"ws": "^8.21.0"
},
"__metadata": {
"id": "fd0ca99f-75f0-4318-af8c-660c5e883c16",
+4 -2
View File
@@ -48,14 +48,16 @@ export class ProjectManager {
case 'openFile':
await this.openFile(message.path);
break;
case 'login':
case 'login': {
const authManager = AuthManager.getInstance(this.context);
await authManager.initiateAuth();
break;
case 'logout':
}
case 'logout': {
const authMgr = AuthManager.getInstance(this.context);
await authMgr.logout();
break;
}
}
}
+5 -5
View File
@@ -220,14 +220,14 @@ export class ApiClient {
}
console.warn('[Hanzo] Entering makeChunkedRequest');
console.warn('[Hanzo] Has files property:', originalData.hasOwnProperty('files'));
console.warn('[Hanzo] Has files property:', Object.prototype.hasOwnProperty.call(originalData, 'files'));
console.warn('[Hanzo] Files is array:', Array.isArray(originalData.files));
if (originalData.files && Array.isArray(originalData.files)) {
console.warn(`[Hanzo] Files array length: ${originalData.files.length}`);
}
let dataToChunk = originalData;
const dataToChunk = originalData;
console.warn(`[Hanzo] Data to chunk: ${JSON.stringify(dataToChunk, null, 2)}`);
// Now check if we have files to chunk
@@ -243,7 +243,7 @@ export class ApiClient {
// Prepare for chunked upload
const totalChunks = fileChunks.length;
let chunkResponses: AxiosResponse[] = [];
const chunkResponses: AxiosResponse[] = [];
// Function to make a request for a single chunk
const makeChunkRequest = async (chunkIndex: number, filesChunk: any[]): Promise<AxiosResponse> => {
@@ -377,7 +377,7 @@ export class ApiClient {
// Process known keys that might be in the response
for (const key of knownKeys) {
if (currentResponse.data.hasOwnProperty(key)) {
if (Object.prototype.hasOwnProperty.call(currentResponse.data, key)) {
const value = currentResponse.data[key];
// If the property is an array, concatenate it
@@ -463,7 +463,7 @@ export class ApiClient {
// Prepare for chunked upload
const totalChunks = fileChunks.length;
let chunkResponses: AxiosResponse[] = [];
const chunkResponses: AxiosResponse[] = [];
// Function to make a request for a single gzipped chunk
const makeGzippedChunkRequest = async (chunkIndex: number, filesChunk: any[]): Promise<AxiosResponse> => {
+32 -17
View File
@@ -5,6 +5,20 @@ import * as os from 'os';
import axios from 'axios';
import { execSync } from 'child_process';
// The canonical HIP-0111 OAuth path prefix. Both the login UI (authorize) and
// the API (token) carry it — this file previously omitted it (hitting
// hanzo.id/oauth/authorize, which 404s), the exact drift @hanzo/auth exists to
// prevent. Kept as a local const here because this MCP-standalone auth compiles
// without the workspace bundler; the value MUST equal @hanzo/auth's IAM_API_PATH.
const IAM_API_PATH = '/v1/iam';
// form() encodes a token-endpoint body as application/x-www-form-urlencoded —
// RFC 6749 requires it; posting a JSON object (axios's default) is rejected by
// IAM. axios serializes URLSearchParams as form and sets the header itself.
function form(body: Record<string, string>): URLSearchParams {
return new URLSearchParams(body);
}
interface AuthToken {
token: string;
refreshToken?: string;
@@ -71,7 +85,7 @@ export class StandaloneAuthManager {
}
private async getStoredToken(): Promise<AuthToken | null> {
if (this.isAnonymous) return null;
if (this.isAnonymous) {return null;}
try {
if (fs.existsSync(this.tokenFile)) {
@@ -95,7 +109,7 @@ export class StandaloneAuthManager {
}
private async storeToken(token: AuthToken): Promise<void> {
if (this.isAnonymous) return;
if (this.isAnonymous) {return;}
fs.writeFileSync(this.tokenFile, JSON.stringify(token, null, 2));
// Secure the file
@@ -104,12 +118,14 @@ export class StandaloneAuthManager {
private async refreshToken(refreshToken: string): Promise<AuthToken | null> {
try {
const response = await axios.post(`${this.iamConfig.endpoint}/oauth/token`, {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.iamConfig.clientId,
client_secret: this.iamConfig.clientSecret
});
const response = await axios.post(
`${this.iamConfig.endpoint}${IAM_API_PATH}/oauth/token`,
form({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.iamConfig.clientId,
}),
);
if (response.data?.access_token) {
const token: AuthToken = {
@@ -129,14 +145,14 @@ export class StandaloneAuthManager {
}
public async isAuthenticated(): Promise<boolean> {
if (this.isAnonymous) return true;
if (this.isAnonymous) {return true;}
const token = await this.getStoredToken();
return !!token;
}
public async getAuthToken(): Promise<string | null> {
if (this.isAnonymous) return null;
if (this.isAnonymous) {return null;}
const tokenData = await this.getStoredToken();
return tokenData?.token || null;
@@ -166,11 +182,11 @@ export class StandaloneAuthManager {
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
const authUrl = new URL(`${this.iamConfig.loginUrl}/oauth/authorize`);
const authUrl = new URL(`${this.iamConfig.loginUrl}${IAM_API_PATH}/oauth/authorize`);
authUrl.searchParams.append('client_id', this.iamConfig.clientId);
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('redirect_uri', 'http://localhost:8765/callback');
authUrl.searchParams.append('scope', 'read write');
authUrl.searchParams.append('scope', 'openid profile email offline_access');
authUrl.searchParams.append('state', state);
authUrl.searchParams.append('code_challenge', codeChallenge);
authUrl.searchParams.append('code_challenge_method', 'S256');
@@ -223,7 +239,7 @@ export class StandaloneAuthManager {
const server = http.createServer(async (req: any, res: any) => {
const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname !== '/callback') return;
if (parsedUrl.pathname !== '/callback') {return;}
const state = parsedUrl.query.state;
if (state !== expectedState) {
@@ -247,9 +263,8 @@ export class StandaloneAuthManager {
redirect_uri: 'http://localhost:8765/callback',
code_verifier: codeVerifier,
};
if (clientSecret) body.client_secret = clientSecret;
const tokenResp = await axios.post(`${endpoint}/oauth/token`, body);
// Public PKCE client: NO client_secret (the verifier is the proof).
const tokenResp = await axios.post(`${endpoint}${IAM_API_PATH}/oauth/token`, form(body));
const tokens = tokenResp.data;
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(successHtml);
@@ -307,7 +322,7 @@ export class StandaloneAuthManager {
}
public async logout(): Promise<void> {
if (this.isAnonymous) return;
if (this.isAnonymous) {return;}
try {
if (fs.existsSync(this.tokenFile)) {
@@ -42,7 +42,7 @@ Session Statistics:
placeHolder: 'Search in session history...'
});
if (!query) return;
if (!query) {return;}
const results = await tracker.searchSessions(query, { limit: 20 });
+27 -27
View File
@@ -8,7 +8,7 @@ import * as path from 'path';
import * as fs from 'fs/promises';
import { GraphDatabase, GraphNode, GraphEdge } from './graph-db';
export interface Symbol {
export interface CodeSymbol {
name: string;
kind: ts.SyntaxKind;
kindName: string;
@@ -37,10 +37,10 @@ export interface FunctionCall {
}
export class ASTIndex {
private symbols: Map<string, Symbol[]> = new Map();
private symbols: Map<string, CodeSymbol[]> = new Map();
private imports: Map<string, ImportInfo[]> = new Map();
private calls: Map<string, FunctionCall[]> = new Map();
private fileSymbols: Map<string, Symbol[]> = new Map();
private fileSymbols: Map<string, CodeSymbol[]> = new Map();
private graphDb: GraphDatabase;
constructor() {
@@ -61,7 +61,7 @@ export class ASTIndex {
// Clear existing symbols for this file
this.fileSymbols.delete(filePath);
const fileSymbolsList: Symbol[] = [];
const fileSymbolsList: CodeSymbol[] = [];
// Add file node to graph
this.graphDb.addNode({
@@ -169,8 +169,8 @@ export class ASTIndex {
kind?: ts.SyntaxKind | ts.SyntaxKind[];
exact?: boolean;
caseSensitive?: boolean;
}): Symbol[] {
const results: Symbol[] = [];
}): CodeSymbol[] {
const results: CodeSymbol[] = [];
const { kind, exact = false, caseSensitive = false } = options || {};
const normalizedQuery = caseSensitive ? query : query.toLowerCase();
@@ -258,11 +258,11 @@ export class ASTIndex {
/**
* Find symbol definition
*/
findDefinition(symbolName: string, fromFile: string): Symbol | null {
findDefinition(symbolName: string, fromFile: string): CodeSymbol | null {
// First check local file
const fileSymbols = this.fileSymbols.get(fromFile) || [];
const localSymbol = fileSymbols.find(s => s.name === symbolName);
if (localSymbol) return localSymbol;
if (localSymbol) {return localSymbol;}
// Check imports
const fileImports = Array.from(this.imports.values())
@@ -276,7 +276,7 @@ export class ASTIndex {
const resolvedPath = path.resolve(path.dirname(fromFile), imp.from);
const importedSymbols = this.fileSymbols.get(resolvedPath) || [];
const imported = importedSymbols.find(s => s.name === symbolName);
if (imported) return imported;
if (imported) {return imported;}
}
}
}
@@ -290,10 +290,10 @@ export class ASTIndex {
* Get call hierarchy for a function
*/
getCallHierarchy(functionName: string): {
callers: Array<{ symbol: Symbol; calls: FunctionCall[] }>;
callers: Array<{ symbol: CodeSymbol; calls: FunctionCall[] }>;
callees: string[];
} {
const callers: Array<{ symbol: Symbol; calls: FunctionCall[] }> = [];
const callers: Array<{ symbol: CodeSymbol; calls: FunctionCall[] }> = [];
const callees = new Set<string>();
// Find all calls to this function
@@ -352,17 +352,17 @@ export class ASTIndex {
* Get type hierarchy
*/
getTypeHierarchy(typeName: string): {
parents: Symbol[];
children: Symbol[];
implementations: Symbol[];
parents: CodeSymbol[];
children: CodeSymbol[];
implementations: CodeSymbol[];
} {
const typeSymbols = (this.symbols.get(typeName) || [])
.filter(s => s.kind === ts.SyntaxKind.ClassDeclaration ||
s.kind === ts.SyntaxKind.InterfaceDeclaration);
const parents: Symbol[] = [];
const children: Symbol[] = [];
const implementations: Symbol[] = [];
const parents: CodeSymbol[] = [];
const children: CodeSymbol[] = [];
const implementations: CodeSymbol[] = [];
// Find inheritance relationships using graph
for (const typeSymbol of typeSymbols) {
@@ -374,7 +374,7 @@ export class ASTIndex {
const parentNode = this.graphDb.getNode(edge.to);
if (parentNode) {
const parentSymbol = this.findSymbolFromNode(parentNode);
if (parentSymbol) parents.push(parentSymbol);
if (parentSymbol) {parents.push(parentSymbol);}
}
}
@@ -384,7 +384,7 @@ export class ASTIndex {
const childNode = this.graphDb.getNode(edge.from);
if (childNode) {
const childSymbol = this.findSymbolFromNode(childNode);
if (childSymbol) children.push(childSymbol);
if (childSymbol) {children.push(childSymbol);}
}
}
@@ -395,7 +395,7 @@ export class ASTIndex {
const implNode = this.graphDb.getNode(edge.from);
if (implNode) {
const implSymbol = this.findSymbolFromNode(implNode);
if (implSymbol) implementations.push(implSymbol);
if (implSymbol) {implementations.push(implSymbol);}
}
}
}
@@ -496,9 +496,9 @@ export class ASTIndex {
ts.isSetAccessorDeclaration(node);
}
private extractSymbol(node: ts.Node, sourceFile: ts.SourceFile, filePath: string): Symbol | null {
private extractSymbol(node: ts.Node, sourceFile: ts.SourceFile, filePath: string): CodeSymbol | null {
let name: string | undefined;
let kind = node.kind;
const kind = node.kind;
// Extract name based on node type
if ('name' in node && (node as any).name && ts.isIdentifier((node as any).name)) {
@@ -507,11 +507,11 @@ export class ASTIndex {
name = node.name.text;
}
if (!name) return null;
if (!name) {return null;}
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.pos);
const symbol: Symbol = {
const symbol: CodeSymbol = {
name,
kind,
kindName: ts.SyntaxKind[kind],
@@ -587,7 +587,7 @@ export class ASTIndex {
name = node.expression.name.text;
}
if (!name) return null;
if (!name) {return null;}
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.pos);
@@ -600,7 +600,7 @@ export class ASTIndex {
};
}
private addSymbol(symbol: Symbol): void {
private addSymbol(symbol: CodeSymbol): void {
if (!this.symbols.has(symbol.name)) {
this.symbols.set(symbol.name, []);
}
@@ -622,7 +622,7 @@ export class ASTIndex {
this.calls.get(call.name)!.push(call);
}
private findSymbolFromNode(node: GraphNode): Symbol | null {
private findSymbolFromNode(node: GraphNode): CodeSymbol | null {
const { name, filePath, line } = node.properties;
const fileSymbols = this.fileSymbols.get(filePath) || [];
return fileSymbols.find(s => s.name === name && s.line === line) || null;
@@ -114,24 +114,27 @@ export class LocalBackend implements IBackend {
const provider = config.get<string>('provider', 'local');
switch (provider) {
case 'openai':
case 'openai': {
const openaiKey = config.get<string>('openaiApiKey');
if (openaiKey) {
return new CloudEmbeddingServer('openai', openaiKey);
}
break;
case 'cohere':
}
case 'cohere': {
const cohereKey = config.get<string>('cohereApiKey');
if (cohereKey) {
return new CloudEmbeddingServer('cohere', cohereKey);
}
break;
case 'hanzo':
}
case 'hanzo': {
const hanzoKey = config.get<string>('apiKey') || vscode.workspace.getConfiguration('hanzo').get<string>('apiKey');
if (hanzoKey) {
return new CloudEmbeddingServer('hanzo', hanzoKey);
}
break;
}
}
// Default to local
+11 -9
View File
@@ -177,8 +177,8 @@ export class DocumentStore {
): Promise<ChatDocument[]> {
const filter: Record<string, any> = {};
if (options?.chatId) filter.chatId = options.chatId;
if (options?.type) filter.type = options.type;
if (options?.chatId) {filter.chatId = options.chatId;}
if (options?.type) {filter.type = options.type;}
const results = await this.vectorStore.search(query, {
topK: options?.limit || 20,
@@ -195,7 +195,7 @@ export class DocumentStore {
const hasAllTags = options.tags.every(tag =>
doc.metadata.tags.includes(tag)
);
if (!hasAllTags) continue;
if (!hasAllTags) {continue;}
}
documents.push(doc);
}
@@ -284,7 +284,7 @@ export class DocumentStore {
if (relDoc) {
related.push(relDoc);
}
if (related.length >= limit) break;
if (related.length >= limit) {break;}
}
return related;
@@ -385,19 +385,21 @@ export class DocumentStore {
private generateTitle(content: string, type: ChatDocument['type']): string {
const lines = content.split('\n').filter(l => l.trim());
if (lines.length === 0) return 'Untitled';
if (lines.length === 0) {return 'Untitled';}
switch (type) {
case 'code':
case 'code': {
// Try to find function/class name
const codeMatch = lines[0].match(/(?:function|class|def|const|let|var)\s+(\w+)/);
if (codeMatch) return codeMatch[1];
if (codeMatch) {return codeMatch[1];}
break;
case 'markdown':
}
case 'markdown': {
// Try to find heading
const heading = lines.find(l => l.startsWith('#'));
if (heading) return heading.replace(/^#+\s*/, '');
if (heading) {return heading.replace(/^#+\s*/, '');}
break;
}
}
// Default: first line truncated
+5 -5
View File
@@ -169,7 +169,7 @@ export class GraphDatabase {
const outEdges = this.edgeIndex.get(`from:${nodeId}`) || new Set();
outEdges.forEach(id => {
const edge = this.edges.get(id);
if (edge) edges.push(edge);
if (edge) {edges.push(edge);}
});
}
@@ -177,7 +177,7 @@ export class GraphDatabase {
const inEdges = this.edgeIndex.get(`to:${nodeId}`) || new Set();
inEdges.forEach(id => {
const edge = this.edges.get(id);
if (edge) edges.push(edge);
if (edge) {edges.push(edge);}
});
}
@@ -192,8 +192,8 @@ export class GraphDatabase {
while (queue.length > 0) {
const { node, path } = queue.shift()!;
if (path.length > maxDepth) continue;
if (visited.has(node)) continue;
if (path.length > maxDepth) {continue;}
if (visited.has(node)) {continue;}
visited.add(node);
if (node === toId) {
@@ -263,7 +263,7 @@ export class GraphDatabase {
while (stack.length > 0) {
const nodeId = stack.pop()!;
if (visited.has(nodeId)) continue;
if (visited.has(nodeId)) {continue;}
visited.add(nodeId);
const node = this.nodes.get(nodeId);
@@ -107,7 +107,7 @@ export class Graph implements IGraph {
* Vertex finder helper function.
*/
findVertices = (args: any[]): verticesType => {
if (typeof args[0] == "object") {
if (typeof args[0] === "object") {
return this.searchVertices(args[0]);
} else if (args.length == 0) {
return this.vertices.slice(); // Note: slice is costly.
@@ -57,7 +57,7 @@ export class Graphene {
addTransformer((program: stepType[]) => {
return program.reduce(function (acc: stepType[], step: stepType) {
if (step[0] != newname) return acc.concat([step]);
if (step[0] != newname) {return acc.concat([step]);}
return acc.concat(newprogram);
}, []);
}, 100); // these need to run early, so they get a high priority
@@ -73,7 +73,7 @@ export class Graphene {
addTransformer((program: stepType[]) => {
return program.map((step: stepType) => {
// If the name of this step is not the new name, return it as it is.
if (step[0] != newName) return step;
if (step[0] != newName) {return step;}
// otherwise, return a step with the equivalent old name, with the args.
return [oldName, extend(step[1], defaults as any[])];
});
@@ -100,7 +100,7 @@ export const propertyPipeTypeMethod: TypePipeMethod = (
_state: IGraphState
) => {
// no gremlin, so we need to pull (query initialization).
if (!gremlin) return "pull";
if (!gremlin) {return "pull";}
// If there is a gremlin, we'll set its result to the property's value.
gremlin.result = gremlin.vertex[args[0]];
@@ -164,17 +164,17 @@ export const filterPipeTypeMethod: TypePipeMethod = (
}
// filter by object
if (typeof args[0] == "object") {
if (typeof args[0] === "object") {
return objectFilter(gremlin.vertex, args[0]) ? gremlin : "pull";
}
if (typeof args[0] != "function") {
if (typeof args[0] !== "function") {
grapheneError("Filter is not a function: " + args[0]);
return gremlin; // keep things going.
}
// gremlin fails if the filter function returns false.
if (!args[0](gremlin.vertex, gremlin)) return "pull";
if (!args[0](gremlin.vertex, gremlin)) {return "pull";}
// gremlin passed.
return gremlin;
@@ -209,7 +209,7 @@ export const takePipeTypeMethod: TypePipeMethod = (
}
// query initialization.
if (!gremlin) return "pull";
if (!gremlin) {return "pull";}
state.taken++;
return gremlin;
@@ -231,7 +231,7 @@ export const asPipeTypeMethod: TypePipeMethod = (
_state: IGraphState
) => {
// query initialization.
if (!gremlin) return "pull";
if (!gremlin) {return "pull";}
// init the 'as' state.
gremlin.state.as = gremlin.state.as || {};
@@ -257,7 +257,7 @@ export const mergePipeTypeMethod: TypePipeMethod = (
state: IGraphState
) => {
// query initialization.
if (!state.vertices && !gremlin) return "pull";
if (!state.vertices && !gremlin) {return "pull";}
// state initialization.
if (!state.vertices || !state.vertices.length) {
@@ -266,7 +266,7 @@ export const mergePipeTypeMethod: TypePipeMethod = (
}
// done with this batch.
if (!state.vertices.length) return "pull";
if (!state.vertices.length) {return "pull";}
const vertex = state.vertices.pop();
return makeGremlin(vertex as vertexType, gremlin.state);
@@ -288,8 +288,8 @@ export const exceptPipeTypeMethod: TypePipeMethod = (
_state: IGraphState
) => {
// query initialization.
if (!gremlin) return "pull";
if (gremlin.vertex == gremlin.state.as[args[0]]) return "pull";
if (!gremlin) {return "pull";}
if (gremlin.vertex == gremlin.state.as[args[0]]) {return "pull";}
return gremlin;
};
@@ -308,7 +308,7 @@ export const backPipeTypeMethod: TypePipeMethod = (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_state: IGraphState
) => {
if (!gremlin) return "pull";
if (!gremlin) {return "pull";}
// Go to the vertex stored in the "as" label.
return gotoVertex(gremlin, gremlin.state.as[args[0]]);
@@ -13,7 +13,7 @@ export const PipeTypes: any = {};
export const addPipeType = (
query: Query,
name: pipeTypeConstant,
fun: TypePipeMethod | Function
fun: TypePipeMethod | ((...args: any[]) => any)
) => {
PipeTypes[name] = fun;
query[name] = (...args: TypeStepArguments) => {
@@ -5,10 +5,10 @@ import { TypeTransformer } from "./types";
export const T: TypeTransformer[] = []; // Transformers
export const addTransformer = (
fun: Function,
fun: (query: any) => any,
priority: number
): void | false => {
if (typeof fun != "function") {
if (typeof fun !== "function") {
return grapheneError("Invalid transformer function encountered.");
}
@@ -35,7 +35,7 @@ export const transform = (program: stepType[]): stepType[] => {
export const extend = (list: TypeStepArguments, defaults: any[]) => {
return Object.keys(defaults).reduce((acc, key) => {
if (typeof list[parseInt(key)] != "undefined") return acc;
if (typeof list[parseInt(key)] !== "undefined") {return acc;}
acc[parseInt(key)] = defaults[parseInt(key)];
return acc;
}, list);
@@ -1,4 +1,4 @@
export type TypeTransformer = {
fun: Function;
fun: (query: any) => any;
priority: number;
};
@@ -28,10 +28,10 @@ export const gotoVertex = (gremlin: IGremlin, vertex: vertexType): IGremlin => {
export const filterEdges = (filter: any) => {
return (edge: edgeType): boolean => {
// no filter, everything is valid.
if (!filter) return true;
if (!filter) {return true;}
// string filter: label must match.
if (typeof filter == "string") {
if (typeof filter === "string") {
return edge._label == filter;
}
@@ -47,7 +47,7 @@ export const filterEdges = (filter: any) => {
export const objectFilter = (thing: any, filter: any): boolean => {
for (const key in filter) {
if (thing[key] !== filter[key]) return false;
if (thing[key] !== filter[key]) {return false;}
}
return true;
};

Some files were not shown because too many files have changed in this diff Show More