feat(phase-4): WS2 + WS3 — Developer Portal (Next.js 14) and CLI tool (sentryagent)

WS2: Developer Portal (portal/)
- Standalone Next.js 14 + Tailwind CSS app — independent deployment
- Home page: hero, feature grid, CTA to /get-started
- /pricing: free tier limits table (10 agents, 1k calls/day) + paid tier CTA
- /sdks: all 4 SDKs (Node.js, Python, Go, Java) with install + code examples
- /api-explorer: Swagger UI from NEXT_PUBLIC_API_URL/openapi.json, persistAuthorization
- /get-started: 4-step wizard (setup → register agent → credentials → SDK snippet)
- Shared Nav component with active-link highlighting
- Build: 8/8 static pages, zero TypeScript errors

WS3: CLI Tool (cli/ — npm package: sentryagent)
- configure, register-agent, list-agents, issue-token, rotate-credentials, tail-audit-log
- Auto OAuth2 token fetch + 30s-buffer cache via client_credentials flow
- chalk-formatted table output, confirmation prompts, bounded audit log dedup
- bash + zsh shell completion scripts
- README with installation, all commands, and completion setup
- Build: tsc clean, node dist/index.js --help verified

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-04-02 04:29:50 +00:00
parent 1b682c22b2
commit d1e6af25aa
147 changed files with 8079 additions and 29 deletions

View File

@@ -0,0 +1,674 @@
'use client';
import React, { useState } from 'react';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Sdk = 'nodejs' | 'python' | 'go' | 'java';
interface WizardState {
step: 1 | 2 | 3 | 4;
agentName: string;
agentId: string | null;
clientId: string | null;
clientSecret: string | null;
selectedSdk: Sdk;
loading: boolean;
error: string | null;
}
interface AgentCreateResponse {
agentId: string;
}
interface CredentialsResponse {
clientId: string;
clientSecret: string;
}
// ---------------------------------------------------------------------------
// SDK code snippets
// ---------------------------------------------------------------------------
function buildSnippet(
sdk: Sdk,
apiUrl: string,
clientId: string,
clientSecret: string
): string {
switch (sdk) {
case 'nodejs':
return `import { AgentIdPClient } from '@sentryagent/idp-sdk';
const client = new AgentIdPClient({
apiUrl: '${apiUrl}',
clientId: '${clientId}',
clientSecret: '${clientSecret}',
});
const { accessToken } = await client.tokens.issue();
console.log('Access token:', accessToken);`;
case 'python':
return `from sentryagent_idp import AgentIdPClient
client = AgentIdPClient(
api_url="${apiUrl}",
client_id="${clientId}",
client_secret="${clientSecret}",
)
token_response = client.tokens.issue()
print("Access token:", token_response.access_token)`;
case 'go':
return `import idp "github.com/sentryagent/idp-sdk-go"
client := idp.NewClient(idp.Config{
APIURL: "${apiUrl}",
ClientID: "${clientId}",
ClientSecret: "${clientSecret}",
})
token, err := client.Tokens.Issue(ctx)
if err != nil {
panic(err)
}
fmt.Println("Access token:", token.AccessToken)`;
case 'java':
return `AgentIdPClient client = AgentIdPClient.builder()
.apiUrl("${apiUrl}")
.clientId("${clientId}")
.clientSecret("${clientSecret}")
.build();
TokenResponse token = client.tokens().issue();
System.out.println("Access token: " + token.getAccessToken());`;
default:
return '';
}
}
// ---------------------------------------------------------------------------
// Shared UI helpers
// ---------------------------------------------------------------------------
function StepIndicator({
current,
total,
}: {
current: number;
total: number;
}): React.ReactElement {
return (
<div className="mb-8 flex items-center gap-2">
{Array.from({ length: total }, (_, i) => i + 1).map((n) => (
<React.Fragment key={n}>
<div
className={[
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold',
n < current
? 'bg-brand-600 text-white'
: n === current
? 'border-2 border-brand-600 bg-brand-50 text-brand-700'
: 'bg-slate-200 text-slate-500',
].join(' ')}
>
{n < current ? '✓' : n}
</div>
{n < total && (
<div
className={[
'h-0.5 flex-1',
n < current ? 'bg-brand-600' : 'bg-slate-200',
].join(' ')}
/>
)}
</React.Fragment>
))}
</div>
);
}
function CopyButton({ text }: { text: string }): React.ReactElement {
const [copied, setCopied] = useState(false);
const handleCopy = async (): Promise<void> => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={() => void handleCopy()}
className="ml-2 rounded bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-200"
>
{copied ? 'Copied!' : 'Copy'}
</button>
);
}
function ErrorAlert({ message }: { message: string }): React.ReactElement {
return (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{message}
</div>
);
}
// ---------------------------------------------------------------------------
// Step components
// ---------------------------------------------------------------------------
function Step1AccountSetup({
onNext,
}: {
onNext: () => void;
}): React.ReactElement {
return (
<div>
<h2 className="mb-2 text-2xl font-bold text-slate-900">
Step 1: Account Setup
</h2>
<p className="mb-6 text-slate-600">
Before registering your first agent, make sure you have the AgentIdP
server running.
</p>
<ol className="mb-8 space-y-4">
{[
{
n: 1,
title: 'Clone the repository',
code: 'git clone https://github.com/sentryagent/sentryagent-idp.git',
},
{
n: 2,
title: 'Copy environment variables',
code: 'cp .env.example .env',
},
{
n: 3,
title: 'Start the server',
code: 'docker compose up -d && npm run db:migrate',
},
{
n: 4,
title: 'Verify the server is healthy',
code: 'curl http://localhost:3000/health',
},
].map(({ n, title, code }) => (
<li key={n} className="flex gap-4">
<span className="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-brand-600 text-xs font-bold text-white">
{n}
</span>
<div className="flex-1">
<p className="mb-1 font-medium text-slate-800">{title}</p>
<div className="flex items-center rounded-lg bg-slate-900 px-3 py-2">
<code className="flex-1 text-sm text-slate-100">{code}</code>
<CopyButton text={code} />
</div>
</div>
</li>
))}
</ol>
<button
onClick={onNext}
className="rounded-lg bg-brand-600 px-6 py-2.5 font-semibold text-white transition-colors hover:bg-brand-700"
>
My server is running &rarr;
</button>
</div>
);
}
function Step2RegisterAgent({
agentName,
onAgentNameChange,
agentId,
loading,
error,
onRegister,
onNext,
}: {
agentName: string;
onAgentNameChange: (v: string) => void;
agentId: string | null;
loading: boolean;
error: string | null;
onRegister: () => void;
onNext: () => void;
}): React.ReactElement {
return (
<div>
<h2 className="mb-2 text-2xl font-bold text-slate-900">
Step 2: Register Your Agent
</h2>
<p className="mb-6 text-slate-600">
Give your agent a name and register it with AgentIdP. You will receive a
unique Agent ID.
</p>
{error && <ErrorAlert message={error} />}
{agentId ? (
<div className="mb-6 rounded-xl border border-green-200 bg-green-50 p-6">
<p className="mb-1 text-sm font-semibold text-green-700">
Agent registered successfully!
</p>
<div className="flex items-center gap-2">
<p className="text-sm text-slate-700">
Agent ID:{' '}
<code className="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-sm">
{agentId}
</code>
</p>
<CopyButton text={agentId} />
</div>
</div>
) : (
<div className="mb-6">
<label
htmlFor="agent-name"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Agent Name
</label>
<div className="flex gap-3">
<input
id="agent-name"
type="text"
value={agentName}
onChange={(e) => onAgentNameChange(e.target.value)}
placeholder="e.g. my-summarisation-agent"
className="flex-1 rounded-lg border border-slate-300 px-4 py-2.5 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-200"
/>
<button
onClick={onRegister}
disabled={loading || agentName.trim() === ''}
className="rounded-lg bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Registering…' : 'Register Agent'}
</button>
</div>
</div>
)}
{agentId && (
<button
onClick={onNext}
className="rounded-lg bg-brand-600 px-6 py-2.5 font-semibold text-white transition-colors hover:bg-brand-700"
>
Generate Credentials &rarr;
</button>
)}
</div>
);
}
function Step3GenerateCredentials({
agentId,
clientId,
clientSecret,
loading,
error,
onGenerate,
onNext,
}: {
agentId: string;
clientId: string | null;
clientSecret: string | null;
loading: boolean;
error: string | null;
onGenerate: () => void;
onNext: () => void;
}): React.ReactElement {
return (
<div>
<h2 className="mb-2 text-2xl font-bold text-slate-900">
Step 3: Generate Credentials
</h2>
<p className="mb-6 text-slate-600">
Generate OAuth 2.0 client credentials for agent{' '}
<code className="rounded bg-slate-100 px-1.5 py-0.5 text-sm">
{agentId}
</code>
. Store your client secret securely it will not be shown again.
</p>
{error && <ErrorAlert message={error} />}
{clientId && clientSecret ? (
<div className="mb-6 space-y-4">
<div className="rounded-xl border border-brand-200 bg-brand-50 p-5">
<p className="mb-3 text-sm font-semibold text-brand-700">
Credentials generated. Store these securely!
</p>
<div className="space-y-3">
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-slate-500">
Client ID
</p>
<div className="flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-sm">
<code className="flex-1 break-all font-mono text-sm text-slate-800">
{clientId}
</code>
<CopyButton text={clientId} />
</div>
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-slate-500">
Client Secret
</p>
<div className="flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-sm">
<code className="flex-1 break-all font-mono text-sm text-slate-800">
{clientSecret}
</code>
<CopyButton text={clientSecret} />
</div>
</div>
</div>
</div>
</div>
) : (
<button
onClick={onGenerate}
disabled={loading}
className="mb-6 rounded-lg bg-brand-600 px-6 py-2.5 font-semibold text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Generating…' : 'Generate Credentials'}
</button>
)}
{clientId && clientSecret && (
<button
onClick={onNext}
className="rounded-lg bg-brand-600 px-6 py-2.5 font-semibold text-white transition-colors hover:bg-brand-700"
>
Choose Your SDK &rarr;
</button>
)}
</div>
);
}
const SDK_OPTIONS: { id: Sdk; label: string; description: string }[] = [
{
id: 'nodejs',
label: 'Node.js / TypeScript',
description: 'npm install @sentryagent/idp-sdk',
},
{
id: 'python',
label: 'Python',
description: 'pip install sentryagent-idp',
},
{
id: 'go',
label: 'Go',
description: 'go get github.com/sentryagent/idp-sdk-go',
},
{
id: 'java',
label: 'Java',
description: 'Maven / Gradle — ai.sentryagent:idp-sdk:1.0.0',
},
];
function Step4SdkSelection({
selectedSdk,
onSdkChange,
clientId,
clientSecret,
apiUrl,
}: {
selectedSdk: Sdk;
onSdkChange: (sdk: Sdk) => void;
clientId: string;
clientSecret: string;
apiUrl: string;
}): React.ReactElement {
const snippet = buildSnippet(selectedSdk, apiUrl, clientId, clientSecret);
return (
<div>
<h2 className="mb-2 text-2xl font-bold text-slate-900">
Step 4: Choose Your SDK
</h2>
<p className="mb-6 text-slate-600">
Select your language and copy the ready-to-run code snippet below. Your
credentials are pre-filled.
</p>
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
{SDK_OPTIONS.map(({ id, label, description }) => (
<button
key={id}
onClick={() => onSdkChange(id)}
className={[
'rounded-xl border p-4 text-left transition-all',
selectedSdk === id
? 'border-brand-500 bg-brand-50 shadow-md'
: 'border-slate-200 bg-white hover:border-brand-300 hover:bg-brand-50',
].join(' ')}
>
<p
className={[
'mb-1 text-sm font-semibold',
selectedSdk === id ? 'text-brand-700' : 'text-slate-800',
].join(' ')}
>
{label}
</p>
<p className="text-xs text-slate-500">{description}</p>
</button>
))}
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold uppercase tracking-wider text-slate-500">
Ready-to-run code
</p>
<CopyButton text={snippet} />
</div>
<pre className="overflow-x-auto rounded-xl bg-slate-900 px-5 py-5 text-sm leading-relaxed text-slate-100">
<code>{snippet}</code>
</pre>
</div>
<div className="mt-8 rounded-xl border border-green-200 bg-green-50 p-5 text-center">
<p className="text-lg font-bold text-green-800">
You are all set!
</p>
<p className="mt-1 text-sm text-green-700">
Your agent is registered and you have credentials. Start making
authenticated API calls using the snippet above.
</p>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main wizard component
// ---------------------------------------------------------------------------
interface GetStartedWizardProps {
apiUrl: string;
}
export function GetStartedWizard({
apiUrl,
}: GetStartedWizardProps): React.ReactElement {
const [state, setState] = useState<WizardState>({
step: 1,
agentName: '',
agentId: null,
clientId: null,
clientSecret: null,
selectedSdk: 'nodejs',
loading: false,
error: null,
});
const goToStep = (step: WizardState['step']): void => {
setState((prev) => ({ ...prev, step, error: null }));
};
const handleAgentNameChange = (value: string): void => {
setState((prev) => ({ ...prev, agentName: value }));
};
const handleSdkChange = (sdk: Sdk): void => {
setState((prev) => ({ ...prev, selectedSdk: sdk }));
};
const handleRegisterAgent = async (): Promise<void> => {
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const response = await fetch(`${apiUrl}/agents`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: state.agentName.trim() }),
});
if (!response.ok) {
const body = (await response.json()) as { message?: string };
throw new Error(body.message ?? `HTTP ${response.status}`);
}
const data = (await response.json()) as AgentCreateResponse;
setState((prev) => ({
...prev,
agentId: data.agentId,
loading: false,
error: null,
}));
} catch (err) {
setState((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : 'Failed to register agent',
}));
}
};
const handleGenerateCredentials = async (): Promise<void> => {
if (!state.agentId) return;
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const response = await fetch(
`${apiUrl}/agents/${state.agentId}/credentials`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
);
if (!response.ok) {
const body = (await response.json()) as { message?: string };
throw new Error(body.message ?? `HTTP ${response.status}`);
}
const data = (await response.json()) as CredentialsResponse;
setState((prev) => ({
...prev,
clientId: data.clientId,
clientSecret: data.clientSecret,
loading: false,
error: null,
}));
} catch (err) {
setState((prev) => ({
...prev,
loading: false,
error:
err instanceof Error
? err.message
: 'Failed to generate credentials',
}));
}
};
const stepLabels = [
'Account Setup',
'Register Agent',
'Generate Credentials',
'Choose SDK',
];
return (
<div className="mx-auto max-w-3xl">
{/* Step label row */}
<div className="mb-2 flex gap-2 text-xs font-medium text-slate-500">
{stepLabels.map((label, i) => (
<span
key={label}
className={[
'flex-1 text-center',
i + 1 === state.step ? 'font-bold text-brand-700' : '',
].join(' ')}
>
{label}
</span>
))}
</div>
<StepIndicator current={state.step} total={4} />
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
{state.step === 1 && (
<Step1AccountSetup onNext={() => goToStep(2)} />
)}
{state.step === 2 && (
<Step2RegisterAgent
agentName={state.agentName}
onAgentNameChange={handleAgentNameChange}
agentId={state.agentId}
loading={state.loading}
error={state.error}
onRegister={() => void handleRegisterAgent()}
onNext={() => goToStep(3)}
/>
)}
{state.step === 3 && state.agentId && (
<Step3GenerateCredentials
agentId={state.agentId}
clientId={state.clientId}
clientSecret={state.clientSecret}
loading={state.loading}
error={state.error}
onGenerate={() => void handleGenerateCredentials()}
onNext={() => goToStep(4)}
/>
)}
{state.step === 4 &&
state.clientId &&
state.clientSecret && (
<Step4SdkSelection
selectedSdk={state.selectedSdk}
onSdkChange={handleSdkChange}
clientId={state.clientId}
clientSecret={state.clientSecret}
apiUrl={apiUrl}
/>
)}
</div>
</div>
);
}

66
portal/components/Nav.tsx Normal file
View File

@@ -0,0 +1,66 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
interface NavLink {
href: string;
label: string;
}
const links: NavLink[] = [
{ href: '/', label: 'Home' },
{ href: '/api-explorer', label: 'API Explorer' },
{ href: '/get-started', label: 'Get Started' },
{ href: '/sdks', label: 'SDKs' },
{ href: '/pricing', label: 'Pricing' },
];
export function Nav(): React.ReactElement {
const pathname = usePathname();
return (
<header className="sticky top-0 z-50 border-b border-slate-200 bg-white/90 backdrop-blur">
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<Link href="/" className="flex items-center gap-2">
<span className="text-xl font-bold text-brand-600">
SentryAgent
</span>
<span className="rounded bg-brand-100 px-1.5 py-0.5 text-xs font-semibold text-brand-700">
AgentIdP
</span>
</Link>
<ul className="flex items-center gap-1">
{links.map(({ href, label }) => {
const isActive =
href === '/' ? pathname === '/' : pathname.startsWith(href);
return (
<li key={href}>
<Link
href={href}
className={[
'rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-brand-100 text-brand-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
].join(' ')}
>
{label}
</Link>
</li>
);
})}
</ul>
<Link
href="/get-started"
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-brand-700"
>
Get Started Free
</Link>
</nav>
</header>
);
}

View File

@@ -0,0 +1,39 @@
'use client';
import React from 'react';
import dynamic from 'next/dynamic';
import type { SwaggerUIProps } from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
// Dynamically import SwaggerUI with no SSR — SwaggerUI requires browser globals
const SwaggerUI = dynamic<SwaggerUIProps>(() => import('swagger-ui-react'), {
ssr: false,
});
interface SwaggerExplorerProps {
apiUrl: string;
}
const SUBMIT_METHODS: SwaggerUIProps['supportedSubmitMethods'] = [
'get',
'post',
'put',
'patch',
'delete',
];
export function SwaggerExplorer({
apiUrl,
}: SwaggerExplorerProps): React.ReactElement {
const specUrl = `${apiUrl}/openapi.json`;
return (
<div className="swagger-wrapper min-h-screen">
<SwaggerUI
url={specUrl}
persistAuthorization={true}
supportedSubmitMethods={SUBMIT_METHODS}
/>
</div>
);
}