Files
Hanzo Dev 85dc2db900 feat(clio): Hanzo AI integration for Clio practice management
New @hanzo/clio package: brings Hanzo AI into a lawyer's Clio matters.
Pick a matter and Hanzo can summarize it, draft correspondence, extract
key dates & obligations, or answer freeform questions over the matter's
documents — then write the result back to Clio as a Note or Activity.

Architecture (one and only one way, secret-safe):
- Clio OAuth2 authorization-code grant. client_secret lives ONLY in the
  token-exchange service (server.ts, read from env); the browser SPA never
  sees it. Verified: dist/app.js has zero secret / crypto / process.env refs.
- Clio REST API v4 client (clio-api.ts): pure request-shaping wrappers for
  listMatters/getMatter/listDocuments/downloadDocument/listContacts and the
  write-back createNote/createActivity — URL, headers, fields selection,
  pagination all unit-testable; one thin clioFetch/clioDownload at the edge.
- Hanzo chat (hanzo-chat.ts) mirrors @hanzo/office-addin's wire contract:
  api.hanzo.ai/v1/chat/completions + /v1/models, no /api/ prefix.
- Four actions + matter-context assembly/truncation (actions.ts, 48k total /
  12k per-doc caps, truncation marked).
- Custom Action landing (custom-action.ts + server.ts): parses Clio's signed
  deep-link, HMAC-verifies the nonce (constant-time) with the client_secret,
  redirects into the SPA on the right matter.
- Configurable regional Clio base (us/eu/au/ca).

esbuild build (SPA iife + Node ESM service), tsc --noEmit clean, 86 vitest
tests pass across config/oauth/api/chat/actions/custom-action/server.

Registered packages/clio in pnpm-workspace.yaml.
2026-07-03 17:13:30 -07:00

101 lines
3.5 KiB
JavaScript

// Build the Clio integration into dist/: bundle the SPA (app.ts, browser) and
// the token-exchange service (server.ts, node), copy the static HTML/CSS/assets.
//
// node build.js → production (base https://clio.hanzo.ai)
// HANZO_CLIO_BASE=http://localhost:8787 node build.js → local dev
//
// The Clio Developer App's PUBLIC client_id + redirect + region are stamped into
// the SPA via esbuild `define` (the client_id is public; the SECRET is read from
// the environment by the running server, never bundled). One place, one source.
import esbuild from 'esbuild';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const BASE = (process.env.HANZO_CLIO_BASE || 'https://clio.hanzo.ai').replace(/\/+$/, '');
const CLIO_CLIENT_ID = process.env.CLIO_CLIENT_ID || '';
const CLIO_REDIRECT_URI = process.env.CLIO_REDIRECT_URI || `${BASE}/oauth/callback`;
const CLIO_REGION = process.env.CLIO_REGION || 'us';
const watch = process.argv.includes('--watch');
const root = path.dirname(fileURLToPath(import.meta.url));
const src = path.join(root, 'src');
const dist = path.join(root, 'dist');
async function build() {
fs.rmSync(dist, { recursive: true, force: true });
fs.mkdirSync(dist, { recursive: true });
// The browser SPA — client_id/redirect/region stamped via define. Secrets are
// NOT define-able here; server.ts reads them from process.env at runtime.
const spa = await esbuild.context({
entryPoints: [path.join(src, 'app.ts')],
outfile: path.join(dist, 'app.js'),
bundle: true,
format: 'iife',
platform: 'browser',
target: ['chrome90', 'edge90', 'safari14', 'firefox90'],
sourcemap: true,
minify: !watch,
define: {
CLIO_CLIENT_ID: JSON.stringify(CLIO_CLIENT_ID),
CLIO_REDIRECT_URI: JSON.stringify(CLIO_REDIRECT_URI),
CLIO_REGION: JSON.stringify(CLIO_REGION),
},
logLevel: 'info',
});
await spa.rebuild();
// The token-exchange service — a Node ESM bundle. No secrets baked in; it
// reads CLIO_CLIENT_SECRET etc. from the environment when it runs.
const server = await esbuild.context({
entryPoints: [path.join(src, 'server.ts')],
outfile: path.join(dist, 'server.js'),
bundle: true,
format: 'esm',
platform: 'node',
target: ['node18'],
sourcemap: true,
minify: !watch,
banner: { js: '// Hanzo Clio token-exchange service — run: node server.js' },
logLevel: 'info',
});
await server.rebuild();
// Static files.
for (const f of ['index.html', 'styles.css']) {
fs.copyFileSync(path.join(src, f), path.join(dist, f));
}
// assets/ (icons) — mirror office; create the dir even if empty.
const assetsSrc = path.join(root, 'assets');
const assetsDst = path.join(dist, 'assets');
fs.mkdirSync(assetsDst, { recursive: true });
if (fs.existsSync(assetsSrc)) {
for (const f of fs.readdirSync(assetsSrc)) {
fs.copyFileSync(path.join(assetsSrc, f), path.join(assetsDst, f));
}
}
console.log(`✅ Clio integration built → dist/ (base ${BASE}, region ${CLIO_REGION})`);
console.log(' Serve dist/{index.html,app.js,styles.css,assets} as static; run dist/server.js for /oauth/*.');
if (!CLIO_CLIENT_ID) {
console.log(' ⚠ CLIO_CLIENT_ID not set — set it (and CLIO_CLIENT_SECRET on the server) before deploy.');
}
if (watch) {
await spa.watch();
await server.watch();
console.log('👀 watching…');
} else {
await spa.dispose();
await server.dispose();
}
}
build().catch((e) => {
console.error(e);
process.exit(1);
});