Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/autogtm-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
Expand Down
31 changes: 31 additions & 0 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
@@ -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
```
7 changes: 7 additions & 0 deletions apps/cli/bin/og.ts
Original file line number Diff line number Diff line change
@@ -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);
38 changes: 33 additions & 5 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -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:*"
}
}
18 changes: 10 additions & 8 deletions apps/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
}
Expand Down Expand Up @@ -1145,11 +1149,9 @@ async function editInEditor(markdown: string): Promise<string> {
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 });
Expand Down Expand Up @@ -1192,7 +1194,7 @@ async function runProfile({ client, args, flags }: CommandContext): Promise<stri

const file = flagString(flags, 'file');
const edited = file
? await Bun.file(file).text()
? await readFile(file, 'utf8')
: await ((flags as { edit?: (markdown: string) => Promise<string> }).edit ?? editInEditor)(
`${markdown}\n`,
);
Expand Down Expand Up @@ -1399,7 +1401,7 @@ async function runCadences({ client, args, flags }: CommandContext): Promise<str

const file = flagString(flags, 'file');
// A file holds either the steps array or a whole `{ steps }` body.
const fromFile: unknown = file ? JSON.parse(await Bun.file(file).text()) : undefined;
const fromFile: unknown = file ? JSON.parse(await readFile(file, 'utf8')) : undefined;
const steps =
fromFile === undefined
? asList(flags.step).map((spec, index) => parseStepSpec(spec, index))
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
}
Expand Down
20 changes: 12 additions & 8 deletions apps/mcp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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(', ')}`);
Expand All @@ -73,17 +77,17 @@ 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 } : {}),
};
}

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 } : {}),
};

Expand Down
9 changes: 9 additions & 0 deletions apps/mcp/src/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/',
Expand Down
29 changes: 29 additions & 0 deletions apps/web/app/(marketing)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default async function LandingPage() {
<CapabilitiesSection />
<IdeasSection />
<RefusalSection />
<TerminalSection />
<ClosingCta />
<SiteFooter />
</div>
Expand Down Expand Up @@ -353,6 +354,34 @@ function RefusalSection() {
);
}

function TerminalSection() {
return (
<Band className="border-border border-t py-16 sm:py-20" id="cli">
<h2 className="text-[28px] leading-tight font-semibold tracking-[-0.025em] text-balance sm:text-[34px]">
Or run it from a terminal.
</h2>
<p className="text-ink-muted mt-4 max-w-[38em] text-[17px] leading-relaxed">
<code>og</code> 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.
</p>
<pre className="border-border bg-surface-raised mt-6 overflow-x-auto rounded-2xl border p-5 font-mono text-[12.5px] leading-loose">
<code>{`curl -fsSL https://outreachgraph.com/install.sh | sh

export OUTREACHGRAPH_API_URL=https://outreachgraph.com
export OUTREACHGRAPH_API_TOKEN=og_live_...
og today`}</code>
</pre>
<p className="text-ink-muted mt-3 text-[13px]">
Or <code>npm install -g @profullstack/outreachgraph</code>. Agents can read{' '}
<a href="/api/v1/public/llms.txt" className="underline">
llms.txt
</a>
.
</p>
</Band>
);
}

function ClosingCta() {
return (
<Band className="bg-ink py-16 text-center text-white sm:py-20">
Expand Down
9 changes: 9 additions & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion apps/web/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)$).*)',
],
};
57 changes: 57 additions & 0 deletions apps/web/public/install.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading