diff --git a/README.md b/README.md index cdbf225..591d016 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,18 @@ The rest of the §20 agent suite is not built. See [`docs/prd-implementation-map.md`](docs/prd-implementation-map.md) for exactly what exists. +## Install the CLI + +```bash +curl -fsSL https://outreachgraph.com/install.sh | sh +``` + +That installs `og` ([`@profullstack/outreachgraph`](https://www.npmjs.com/package/@profullstack/outreachgraph) +on npm) with bun if you have it, otherwise npm (Node >= 20), into `~/.local` when +the global prefix is not writable. `npm install -g @profullstack/outreachgraph` +works too. Then set `OUTREACHGRAPH_API_URL=https://outreachgraph.com` and +`OUTREACHGRAPH_API_TOKEN` to a key from Settings, and run `og today`. + ## Quick start ```bash @@ -44,7 +56,7 @@ reach past it would be a surface where the gates are optional. ```bash export OUTREACHGRAPH_API_URL=http://localhost:8080 export OUTREACHGRAPH_API_TOKEN=$API_TOKEN -export OUTREACHGRAPH_WORKSPACE_ID=wsp_... +export OUTREACHGRAPH_WORKSPACE_ID=wsp_... # only with the service token; an og_live_ key needs neither export OUTREACHGRAPH_ORGANIZATION_ID=org_... bun apps/cli/src/index.ts today # the approval queue diff --git a/apps/api/src/autogtm-docs.ts b/apps/api/src/autogtm-docs.ts index bef24ff..2413845 100644 --- a/apps/api/src/autogtm-docs.ts +++ b/apps/api/src/autogtm-docs.ts @@ -1082,6 +1082,9 @@ export function llmsText(baseUrl: string): string { '- Header on every request: `X-API-Key: og_live_…` (or `Authorization: Bearer og_live_…`).', ); lines.push(`- Full schema: \`${api}/public/openapi.json\``); + lines.push( + `- Command line: \`curl -fsSL ${baseUrl}/install.sh | sh\` installs \`og\` (npm \`@profullstack/outreachgraph\`); it reads \`OUTREACHGRAPH_API_URL\` and \`OUTREACHGRAPH_API_TOKEN\`.`, + ); lines.push( '- Keys are minted at `/settings` in the app, or `POST /api-keys` with a signed-in session.', ); diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 0000000..00ae871 --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,31 @@ +# @profullstack/outreachgraph + +`og`, the [OutreachGraph](https://outreachgraph.com) command line. It is a client of the +`/api/v1` API and nothing else: the policy engine runs server-side, so every action goes +through the same gates as the web app. + +## Install + +```bash +curl -fsSL https://outreachgraph.com/install.sh | sh +``` + +or with a package manager (Node >= 20, or Bun): + +```bash +npm install -g @profullstack/outreachgraph +bun add -g @profullstack/outreachgraph +``` + +## Configure + +Create an API key in OutreachGraph under Settings, then: + +```bash +export OUTREACHGRAPH_API_URL=https://outreachgraph.com +export OUTREACHGRAPH_API_TOKEN=og_live_... # the key names its workspace + +og today # the approval queue +og prospects # ranked prospects +og --help # every command +``` diff --git a/apps/cli/bin/og.ts b/apps/cli/bin/og.ts new file mode 100644 index 0000000..db46495 --- /dev/null +++ b/apps/cli/bin/og.ts @@ -0,0 +1,7 @@ +// Entry for the published npm package: runs the CLI under plain Node or Bun. +import { run } from '../src/index'; + +const result = await run(process.argv.slice(2)); +if (result.code === 0) console.log(result.output); +else console.error(result.output); +process.exit(result.code); diff --git a/apps/cli/package.json b/apps/cli/package.json index 540dce7..29a2886 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,18 +1,46 @@ { - "name": "@outreachgraph/cli", + "name": "@profullstack/outreachgraph", "version": "0.16.2", - "private": true, + "description": "og: OutreachGraph from a terminal. Approval queue, prospects, campaigns and imports over the /api/v1 API.", "type": "module", + "license": "UNLICENSED", + "homepage": "https://outreachgraph.com", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/outreachgraph.git", + "directory": "apps/cli" + }, + "bugs": { + "url": "https://github.com/profullstack/outreachgraph/issues" + }, + "keywords": [ + "outreachgraph", + "outreach", + "cli", + "gtm" + ], "bin": { - "og": "./src/index.ts" + "og": "./dist/og.js" + }, + "files": [ + "dist/og.js", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "publishConfig": { + "access": "public" }, "scripts": { "start": "bun src/index.ts", - "build": "bun build src/index.ts --target=bun --outdir=dist --compile", + "build": "bun build bin/og.ts --target=node --format=esm --outfile=dist/og.js --banner='#!/usr/bin/env node'", + "build:binary": "bun build src/index.ts --target=bun --outdir=dist --compile", + "prepublishOnly": "bun run build", "typecheck": "tsc --noEmit", "test": "bun test" }, - "dependencies": { + "devDependencies": { "@outreachgraph/mcp": "workspace:*" } } diff --git a/apps/cli/src/commands.ts b/apps/cli/src/commands.ts index 1692eae..44a80a9 100644 --- a/apps/cli/src/commands.ts +++ b/apps/cli/src/commands.ts @@ -11,6 +11,8 @@ * changes with terminal width. */ +import { spawn, spawnSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; import type { ApiClient } from '@outreachgraph/mcp/src/client'; export interface CommandContext { @@ -80,7 +82,9 @@ function openInBrowser(url: string): void { const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; try { - Bun.spawn([opener, url], { stdio: ['ignore', 'ignore', 'ignore'] }); + const child = spawn(opener, [url], { stdio: 'ignore', detached: true }); + child.on('error', () => {}); + child.unref(); } catch { // Headless box: the printed URL is the fallback. } @@ -1145,11 +1149,9 @@ async function editInEditor(markdown: string): Promise { writeFileSync(path, markdown); try { const editor = process.env.VISUAL ?? process.env.EDITOR ?? 'vi'; - const child = Bun.spawnSync([...editor.split(/\s+/), path], { - stdio: ['inherit', 'inherit', 'inherit'], - }); - if (child.exitCode !== 0) - throw new Error(`${editor} exited with ${child.exitCode}; nothing saved`); + const [cmd, ...cmdArgs] = editor.split(/\s+/); + const child = spawnSync(cmd ?? 'vi', [...cmdArgs, path], { stdio: 'inherit' }); + if (child.status !== 0) throw new Error(`${editor} exited with ${child.status}; nothing saved`); return readFileSync(path, 'utf8'); } finally { rmSync(dir, { recursive: true, force: true }); @@ -1192,7 +1194,7 @@ async function runProfile({ client, args, flags }: CommandContext): Promise Promise }).edit ?? editInEditor)( `${markdown}\n`, ); @@ -1399,7 +1401,7 @@ async function runCadences({ client, args, flags }: CommandContext): Promise parseStepSpec(spec, index)) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 08532e6..63d9e06 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -13,6 +13,7 @@ import { ApiError, configFromEnv, createClient } from '@outreachgraph/mcp/src/client'; import { commandByName, usage } from './commands'; +import pkg from '../package.json' with { type: 'json' }; export interface ParsedArgv { readonly command: string | undefined; @@ -59,6 +60,10 @@ export function parseArgv(argv: readonly string[]): ParsedArgv { export async function run(argv: readonly string[]): Promise<{ output: string; code: number }> { const { command, args, flags } = parseArgv(argv); + if (flags.version === true || command === 'version' || (command === '-v' && args.length === 0)) { + return { output: `og ${pkg.version}`, code: 0 }; + } + if (!command || command === 'help' || flags.help === true) { return { output: usage(), code: 0 }; } diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/client.ts index e37f9b1..ebd7649 100644 --- a/apps/mcp/src/client.ts +++ b/apps/mcp/src/client.ts @@ -15,8 +15,9 @@ export interface ApiConfig { readonly baseUrl: string; readonly token: string; - readonly workspaceId: string; - readonly organizationId: string; + /** Optional with a workspace key (`og_live_…`), which names its own workspace. */ + readonly workspaceId?: string | undefined; + readonly organizationId?: string | undefined; readonly userId?: string | undefined; } @@ -63,8 +64,11 @@ export function configFromEnv(env: NodeJS.ProcessEnv = process.env): ApiConfig { if (!baseUrl) missing.push('OUTREACHGRAPH_API_URL'); if (!token) missing.push('OUTREACHGRAPH_API_TOKEN'); - if (!workspaceId) missing.push('OUTREACHGRAPH_WORKSPACE_ID'); - if (!organizationId) missing.push('OUTREACHGRAPH_ORGANIZATION_ID'); + // A workspace key from Settings is scoped by the row it hashes to, so it needs + // no headers naming a workspace. Only the service token does. + const workspaceKey = token?.startsWith('og_live_') ?? false; + if (!workspaceId && !workspaceKey) missing.push('OUTREACHGRAPH_WORKSPACE_ID'); + if (!organizationId && !workspaceKey) missing.push('OUTREACHGRAPH_ORGANIZATION_ID'); if (missing.length > 0) { throw new Error(`Missing configuration: ${missing.join(', ')}`); @@ -73,8 +77,8 @@ export function configFromEnv(env: NodeJS.ProcessEnv = process.env): ApiConfig { return { baseUrl: baseUrl!.replace(/\/+$/, ''), token: token!, - workspaceId: workspaceId!, - organizationId: organizationId!, + ...(workspaceId ? { workspaceId } : {}), + ...(organizationId ? { organizationId } : {}), ...(env.OUTREACHGRAPH_USER_ID ? { userId: env.OUTREACHGRAPH_USER_ID } : {}), }; } @@ -82,8 +86,8 @@ export function configFromEnv(env: NodeJS.ProcessEnv = process.env): ApiConfig { export function createClient(config: ApiConfig, fetchImpl: FetchLike = fetch): ApiClient { const headers = { authorization: `Bearer ${config.token}`, - 'x-workspace-id': config.workspaceId, - 'x-organization-id': config.organizationId, + ...(config.workspaceId ? { 'x-workspace-id': config.workspaceId } : {}), + ...(config.organizationId ? { 'x-organization-id': config.organizationId } : {}), ...(config.userId ? { 'x-user-id': config.userId } : {}), }; diff --git a/apps/mcp/src/mcp.test.ts b/apps/mcp/src/mcp.test.ts index 994e9d1..6aaaf54 100644 --- a/apps/mcp/src/mcp.test.ts +++ b/apps/mcp/src/mcp.test.ts @@ -76,6 +76,15 @@ describe('configFromEnv', () => { expect(message).toContain('OUTREACHGRAPH_ORGANIZATION_ID'); }); + test('a workspace key needs no workspace or organization id', () => { + const config = configFromEnv({ + OUTREACHGRAPH_API_URL: 'https://outreachgraph.com', + OUTREACHGRAPH_API_TOKEN: 'og_live_abcdefghijklmnop', + } as NodeJS.ProcessEnv); + expect(config.workspaceId).toBeUndefined(); + expect(config.organizationId).toBeUndefined(); + }); + test('trims a trailing slash off the base url', () => { const config = configFromEnv({ OUTREACHGRAPH_API_URL: 'https://api.test/', diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx index 524f203..31c7496 100644 --- a/apps/web/app/(marketing)/page.tsx +++ b/apps/web/app/(marketing)/page.tsx @@ -38,6 +38,7 @@ export default async function LandingPage() { + @@ -353,6 +354,34 @@ function RefusalSection() { ); } +function TerminalSection() { + return ( + +

+ Or run it from a terminal. +

+

+ og is the same approval queue, prospects and campaigns from the command line, + through the same policy engine. Install it, then set an API key from Settings. +

+
+        {`curl -fsSL https://outreachgraph.com/install.sh | sh
+
+export OUTREACHGRAPH_API_URL=https://outreachgraph.com
+export OUTREACHGRAPH_API_TOKEN=og_live_...
+og today`}
+      
+

+ Or npm install -g @profullstack/outreachgraph. Agents can read{' '} + + llms.txt + + . +

+
+ ); +} + function ClosingCta() { return ( diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 7e466ae..cccbfcc 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -12,6 +12,15 @@ const config: NextConfig = { async headers() { return [ + { + // The CLI's curl installer: served as a shell script, never cached for long, + // so `curl -fsSL https://outreachgraph.com/install.sh | sh` always gets the current one. + source: '/install.sh', + headers: [ + { key: 'Content-Type', value: 'text/x-sh; charset=utf-8' }, + { key: 'Cache-Control', value: 'public, max-age=300' }, + ], + }, { // The service worker must never be cached, or clients can be stuck on // an old one indefinitely and never see an update. diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index ce07af6..cc1ecf3 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -14,7 +14,8 @@ export async function proxy(request: NextRequest) { export const config = { // Everything but Next's own assets and static files. API routes stay // covered on purpose: a training crawler hitting the API gets 402 too. + // /install.sh is the CLI's curl installer and must reach anyone, so it is out. matcher: [ - '/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|woff|woff2|ttf|otf|mp3|mp4|webmanifest)$).*)', + '/((?!_next/static|_next/image|favicon.ico|install\\.sh$|.*\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|woff|woff2|ttf|otf|mp3|mp4|webmanifest)$).*)', ], }; diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh new file mode 100644 index 0000000..d6a4cf1 --- /dev/null +++ b/apps/web/public/install.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Install og, the OutreachGraph command line (npm: @profullstack/outreachgraph). +# +# curl -fsSL https://outreachgraph.com/install.sh | sh +# +# Uses bun if present, otherwise npm (Node >= 20). When the global npm prefix +# is not writable it installs into ~/.local instead of asking for sudo. +# Re-running it upgrades to the latest version. +set -eu + +PKG="@profullstack/outreachgraph" + +say() { printf '%s\n' "$*"; } +die() { printf 'og install: %s\n' "$*" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } + +say "Installing og (OutreachGraph CLI)..." + +if have bun; then + # bun add -g writes into the nearest package.json above its global dir, so + # give that dir its own before installing. + BUN_GLOBAL="${BUN_INSTALL:-$HOME/.bun}/install/global" + mkdir -p "$BUN_GLOBAL" + [ -f "$BUN_GLOBAL/package.json" ] || printf '{}\n' > "$BUN_GLOBAL/package.json" + bun add -g "$PKG@latest" + BIN_DIR="${BUN_INSTALL:-$HOME/.bun}/bin" +elif have npm && have node; then + major=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0) + [ "$major" -ge 20 ] || die "Node >= 20 required (found $(node -v)). Upgrade Node or install bun: https://bun.sh" + prefix=$(npm config get prefix) + modules="$prefix/lib/node_modules" + [ -d "$modules" ] || modules="$prefix/lib" + if [ -w "$modules" ] && [ -w "$prefix/bin" ]; then + npm install -g "$PKG@latest" + BIN_DIR="$prefix/bin" + else + BIN_DIR="$HOME/.local/bin" + say "npm prefix $prefix is not writable; installing into $HOME/.local" + npm install -g --prefix "$HOME/.local" "$PKG@latest" + fi +else + die "needs bun (https://bun.sh) or Node >= 20 with npm (https://nodejs.org)" +fi + +say "" +if have og; then + say "$(og --version 2>/dev/null || echo og) installed." +else + say "og installed to $BIN_DIR, which is not on your PATH. Add it:" + say " export PATH=\"$BIN_DIR:\$PATH\"" +fi +say "" +say "Next: create an API key in OutreachGraph (Settings), then" +say " export OUTREACHGRAPH_API_URL=https://outreachgraph.com" +say " export OUTREACHGRAPH_API_TOKEN=og_live_..." +say " og today # the approval queue" +say " og --help # every command" diff --git a/bun.lock b/bun.lock index f69f713..24fa725 100644 --- a/bun.lock +++ b/bun.lock @@ -35,12 +35,12 @@ }, }, "apps/cli": { - "name": "@outreachgraph/cli", + "name": "@profullstack/outreachgraph", "version": "0.16.2", "bin": { - "og": "./src/index.ts", + "og": "./dist/og.js", }, - "dependencies": { + "devDependencies": { "@outreachgraph/mcp": "workspace:*", }, }, @@ -365,8 +365,6 @@ "@outreachgraph/api": ["@outreachgraph/api@workspace:apps/api"], - "@outreachgraph/cli": ["@outreachgraph/cli@workspace:apps/cli"], - "@outreachgraph/contracts": ["@outreachgraph/contracts@workspace:packages/contracts"], "@outreachgraph/db": ["@outreachgraph/db@workspace:packages/db"], @@ -415,6 +413,8 @@ "@profullstack/openprofile": ["@profullstack/openprofile@0.2.0", "", {}, "sha512-cdFsBbfRXurvy5KqHDNcKXxurK2s/YlfVgnILFRpVl2hq3860rExqDee8IfsFGxw5cPdqGv2fxUxtek1Xy2U5Q=="], + "@profullstack/outreachgraph": ["@profullstack/outreachgraph@workspace:apps/cli"], + "@profullstack/x402-gateway": ["@profullstack/x402-gateway@0.7.0", "", { "dependencies": { "@profullstack/keys": "^0.1.0" } }, "sha512-xsZRWHdOD9Y9EuRS4PsRmykVS2geOu/luY0jBlliOF3SEgxiLhgmx7ks+2jOUtGlq+cIfSonnk5eEObRGCOJ9w=="], "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],