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,155 @@
import { Command } from 'commander';
const BASH_COMPLETION = `
# sentryagent bash completion
# Add to ~/.bashrc or ~/.bash_profile:
# source <(sentryagent completion bash)
_sentryagent_completion() {
local cur prev words cword
_init_completion || return
local commands="configure register-agent list-agents issue-token rotate-credentials tail-audit-log completion"
local global_opts="--help --version"
case "\${prev}" in
sentryagent)
COMPREPLY=( \$(compgen -W "\${commands} \${global_opts}" -- "\${cur}") )
return 0
;;
configure)
COMPREPLY=( \$(compgen -W "--help" -- "\${cur}") )
return 0
;;
register-agent)
COMPREPLY=( \$(compgen -W "--name --description --help" -- "\${cur}") )
return 0
;;
list-agents)
COMPREPLY=( \$(compgen -W "--help" -- "\${cur}") )
return 0
;;
issue-token)
COMPREPLY=( \$(compgen -W "--agent-id --help" -- "\${cur}") )
return 0
;;
rotate-credentials)
COMPREPLY=( \$(compgen -W "--agent-id --help" -- "\${cur}") )
return 0
;;
tail-audit-log)
COMPREPLY=( \$(compgen -W "--agent-id --help" -- "\${cur}") )
return 0
;;
completion)
COMPREPLY=( \$(compgen -W "bash zsh --help" -- "\${cur}") )
return 0
;;
*)
COMPREPLY=()
return 0
;;
esac
}
complete -F _sentryagent_completion sentryagent
`.trim();
const ZSH_COMPLETION = `
#compdef sentryagent
# sentryagent zsh completion
# Add to ~/.zshrc:
# source <(sentryagent completion zsh)
# Or generate a file and place it in your $fpath:
# sentryagent completion zsh > ~/.zsh/completions/_sentryagent
_sentryagent() {
local state
_arguments \\
'(-v --version)'{-v,--version}'[Show version]' \\
'(-h --help)'{-h,--help}'[Show help]' \\
'1: :->command' \\
'*: :->args'
case \$state in
command)
local commands=(
'configure:Configure CLI with API URL and credentials'
'register-agent:Register a new agent'
'list-agents:List all registered agents'
'issue-token:Issue an OAuth2 access token for an agent'
'rotate-credentials:Rotate credentials for an agent'
'tail-audit-log:Poll and stream audit log events'
'completion:Output shell completion script'
)
_describe 'command' commands
;;
args)
case \${words[2]} in
configure)
_arguments \\
'(-h --help)'{-h,--help}'[Show help]'
;;
register-agent)
_arguments \\
'--name[Agent name]:name' \\
'--description[Agent description]:description' \\
'(-h --help)'{-h,--help}'[Show help]'
;;
list-agents)
_arguments \\
'(-h --help)'{-h,--help}'[Show help]'
;;
issue-token)
_arguments \\
'--agent-id[Agent ID]:agent-id' \\
'(-h --help)'{-h,--help}'[Show help]'
;;
rotate-credentials)
_arguments \\
'--agent-id[Agent ID]:agent-id' \\
'(-h --help)'{-h,--help}'[Show help]'
;;
tail-audit-log)
_arguments \\
'--agent-id[Filter by agent ID]:agent-id' \\
'(-h --help)'{-h,--help}'[Show help]'
;;
completion)
local shells=('bash:Generate bash completion script' 'zsh:Generate zsh completion script')
_describe 'shell' shells
;;
esac
;;
esac
}
_sentryagent "\$@"
`.trim();
export function registerCompletion(program: Command): void {
const completion = program
.command('completion')
.description('Output shell completion scripts');
completion
.command('bash')
.description('Output bash completion script')
.action(() => {
console.log(BASH_COMPLETION);
});
completion
.command('zsh')
.description('Output zsh completion script')
.action(() => {
console.log(ZSH_COMPLETION);
});
completion.addHelpText(
'after',
'\nSupported shells: bash, zsh',
);
}

View File

@@ -0,0 +1,63 @@
import * as readline from 'readline';
import { Command } from 'commander';
import chalk from 'chalk';
import { writeConfig } from '../config';
function prompt(rl: readline.Interface, question: string): Promise<string> {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer.trim());
});
});
}
export function registerConfigure(program: Command): void {
program
.command('configure')
.description('Configure the CLI with API URL and credentials')
.action(async () => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
try {
console.log(chalk.bold('SentryAgent CLI Configuration'));
console.log(chalk.dim('─'.repeat(40)));
const apiUrl = await prompt(
rl,
chalk.cyan('API URL') + ' (e.g. https://api.sentryagent.ai): ',
);
if (apiUrl === '') {
console.error(chalk.red('API URL cannot be empty.'));
process.exit(1);
}
const clientId = await prompt(rl, chalk.cyan('Client ID') + ': ');
if (clientId === '') {
console.error(chalk.red('Client ID cannot be empty.'));
process.exit(1);
}
const clientSecret = await prompt(
rl,
chalk.cyan('Client Secret') + ': ',
);
if (clientSecret === '') {
console.error(chalk.red('Client Secret cannot be empty.'));
process.exit(1);
}
writeConfig({ apiUrl, clientId, clientSecret });
console.log();
console.log(
chalk.green('✓') +
' Configuration saved to ~/.sentryagent/config.json',
);
} finally {
rl.close();
}
});
}

View File

@@ -0,0 +1,70 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { requireConfig } from '../config';
interface TokenResponse {
access_token: string;
expires_in: number;
token_type: string;
scope?: string;
}
export function registerIssueToken(program: Command): void {
program
.command('issue-token')
.description('Issue an OAuth2 access token for an agent')
.requiredOption('--agent-id <id>', 'Agent ID to issue a token for')
.action(async (options: { agentId: string }) => {
const config = requireConfig();
try {
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: config.clientId,
client_secret: config.clientSecret,
agent_id: options.agentId,
});
const res = await fetch(`${config.apiUrl}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Token issuance failed (${res.status}): ${text}`);
}
const data = (await res.json()) as TokenResponse;
const expiresAt = new Date(
Date.now() + data.expires_in * 1000,
).toISOString();
console.log(chalk.green('✓') + ' Token issued successfully');
console.log();
console.log(chalk.bold('Access Token:'));
console.log(chalk.cyan(data.access_token));
console.log();
console.log(
chalk.bold('Token Type: ') + data.token_type,
);
console.log(
chalk.bold('Expires In: ') + `${data.expires_in}s`,
);
console.log(
chalk.bold('Expires At: ') + chalk.dim(expiresAt),
);
if (data.scope !== undefined) {
console.log(chalk.bold('Scope: ') + data.scope);
}
} catch (err) {
console.error(
chalk.red('Error:'),
err instanceof Error ? err.message : String(err),
);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,105 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { requireConfig } from '../config';
import { apiRequest } from '../api';
interface Agent {
id: string;
name: string;
status: string;
createdAt: string;
description?: string;
}
interface AgentsResponse {
agents: Agent[];
total?: number;
}
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 1) + '…';
}
function padEnd(str: string, len: number): string {
return str.padEnd(len, ' ');
}
export function registerListAgents(program: Command): void {
program
.command('list-agents')
.description('List all registered agents')
.action(async () => {
const config = requireConfig();
try {
const data = await apiRequest<AgentsResponse | Agent[]>(
config,
'/agents',
);
const agents: Agent[] = Array.isArray(data)
? data
: (data as AgentsResponse).agents ?? [];
if (agents.length === 0) {
console.log(chalk.yellow('No agents found.'));
return;
}
const ID_W = 26;
const NAME_W = 24;
const STATUS_W = 10;
const DATE_W = 20;
const header =
chalk.bold(padEnd('AGENT ID', ID_W)) +
' ' +
chalk.bold(padEnd('NAME', NAME_W)) +
' ' +
chalk.bold(padEnd('STATUS', STATUS_W)) +
' ' +
chalk.bold('CREATED AT');
const divider = chalk.dim(
'─'.repeat(ID_W + NAME_W + STATUS_W + DATE_W + 6),
);
console.log(header);
console.log(divider);
for (const agent of agents) {
const statusColor =
agent.status === 'active'
? chalk.green
: agent.status === 'inactive'
? chalk.yellow
: chalk.red;
const createdAt = new Date(agent.createdAt).toLocaleString();
console.log(
chalk.cyan(padEnd(truncate(agent.id, ID_W), ID_W)) +
' ' +
padEnd(truncate(agent.name, NAME_W), NAME_W) +
' ' +
statusColor(padEnd(truncate(agent.status, STATUS_W), STATUS_W)) +
' ' +
chalk.dim(truncate(createdAt, DATE_W)),
);
}
console.log(divider);
const total = Array.isArray(data)
? agents.length
: ((data as AgentsResponse).total ?? agents.length);
console.log(chalk.dim(`Total: ${total}`));
} catch (err) {
console.error(
chalk.red('Error:'),
err instanceof Error ? err.message : String(err),
);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,54 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { requireConfig } from '../config';
import { apiRequest } from '../api';
interface AgentResponse {
id: string;
name: string;
description?: string;
status: string;
createdAt: string;
}
export function registerRegisterAgent(program: Command): void {
program
.command('register-agent')
.description('Register a new agent')
.requiredOption('--name <name>', 'Agent name')
.option('--description <desc>', 'Agent description')
.action(async (options: { name: string; description?: string }) => {
const config = requireConfig();
try {
const body: { name: string; description?: string } = {
name: options.name,
};
if (options.description !== undefined) {
body.description = options.description;
}
const agent = await apiRequest<AgentResponse>(config, '/agents', {
method: 'POST',
body,
});
console.log(chalk.green('✓') + ' Agent registered successfully');
console.log();
console.log(
chalk.bold('Agent ID: ') + chalk.cyan(agent.id),
);
console.log(chalk.bold('Name: ') + agent.name);
if (agent.description !== undefined) {
console.log(chalk.bold('Description:') + ' ' + agent.description);
}
console.log(chalk.bold('Status: ') + agent.status);
} catch (err) {
console.error(
chalk.red('Error:'),
err instanceof Error ? err.message : String(err),
);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,85 @@
import * as readline from 'readline';
import { Command } from 'commander';
import chalk from 'chalk';
import { requireConfig } from '../config';
import { apiRequest } from '../api';
interface RotateResponse {
clientId: string;
clientSecret: string;
rotatedAt?: string;
}
function prompt(rl: readline.Interface, question: string): Promise<string> {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer.trim());
});
});
}
export function registerRotateCredentials(program: Command): void {
program
.command('rotate-credentials')
.description('Rotate credentials for an agent (invalidates current secret)')
.requiredOption('--agent-id <id>', 'Agent ID whose credentials to rotate')
.action(async (options: { agentId: string }) => {
const config = requireConfig();
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
try {
console.log(
chalk.yellow('⚠') +
' This will invalidate the current secret for agent ' +
chalk.cyan(options.agentId),
);
const answer = await prompt(
rl,
chalk.bold('This will invalidate the current secret. Continue? [y/N] '),
);
if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
console.log(chalk.dim('Aborted.'));
return;
}
const data = await apiRequest<RotateResponse>(
config,
`/agents/${options.agentId}/credentials/rotate`,
{ method: 'POST' },
);
console.log();
console.log(chalk.green('✓') + ' Credentials rotated successfully');
console.log();
console.log(chalk.bold('Client ID: ') + chalk.cyan(data.clientId));
console.log(
chalk.bold('Client Secret: ') + chalk.yellow(data.clientSecret),
);
console.log();
console.log(
chalk.dim(
'Store the new client secret securely — it will not be shown again.',
),
);
if (data.rotatedAt !== undefined) {
console.log(
chalk.dim('Rotated at: ') + chalk.dim(data.rotatedAt),
);
}
} catch (err) {
console.error(
chalk.red('Error:'),
err instanceof Error ? err.message : String(err),
);
process.exit(1);
} finally {
rl.close();
}
});
}

View File

@@ -0,0 +1,122 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { requireConfig } from '../config';
import { apiRequest } from '../api';
interface AuditEvent {
id: string;
timestamp: string;
action: string;
agentId?: string;
tenantId?: string;
outcome: string;
details?: Record<string, unknown>;
}
interface AuditLogsResponse {
events: AuditEvent[];
nextCursor?: string;
}
function formatEvent(event: AuditEvent): string {
const ts = chalk.dim(new Date(event.timestamp).toLocaleString());
const outcome =
event.outcome === 'success'
? chalk.green(event.outcome)
: chalk.red(event.outcome);
const action = chalk.cyan(event.action);
const agentPart =
event.agentId !== undefined
? ' ' + chalk.dim('agent=' + event.agentId)
: '';
return `${ts} ${action} outcome=${outcome}${agentPart} id=${chalk.dim(event.id)}`;
}
export function registerTailAuditLog(program: Command): void {
program
.command('tail-audit-log')
.description(
'Poll and stream audit log events every 5 seconds (Ctrl+C to stop)',
)
.option('--agent-id <id>', 'Filter events for a specific agent ID')
.action(async (options: { agentId?: string }) => {
const config = requireConfig();
console.log(
chalk.bold('Tailing audit log') +
(options.agentId !== undefined
? chalk.dim(` (agent: ${options.agentId})`)
: '') +
chalk.dim(' — press Ctrl+C to stop'),
);
console.log(chalk.dim('─'.repeat(60)));
const seenIds = new Set<string>();
let cursor: string | undefined;
let running = true;
process.on('SIGINT', () => {
running = false;
console.log();
console.log(chalk.dim('Stopped.'));
process.exit(0);
});
while (running) {
try {
const params: Record<string, string> = {};
if (options.agentId !== undefined) {
params['agentId'] = options.agentId;
}
if (cursor !== undefined) {
params['cursor'] = cursor;
}
// Request events from the last poll window
params['limit'] = '50';
const data = await apiRequest<AuditLogsResponse | AuditEvent[]>(
config,
'/audit/logs',
{ params },
);
const events: AuditEvent[] = Array.isArray(data)
? data
: (data as AuditLogsResponse).events ?? [];
if (!Array.isArray(data) && (data as AuditLogsResponse).nextCursor !== undefined) {
cursor = (data as AuditLogsResponse).nextCursor;
}
for (const event of events) {
if (!seenIds.has(event.id)) {
seenIds.add(event.id);
console.log(formatEvent(event));
}
}
// Keep the seenIds set bounded to avoid unbounded memory growth
if (seenIds.size > 10_000) {
const arr = Array.from(seenIds);
const keep = arr.slice(arr.length - 5_000);
seenIds.clear();
for (const id of keep) seenIds.add(id);
}
} catch (err) {
console.error(
chalk.yellow('⚠') +
' Poll error: ' +
(err instanceof Error ? err.message : String(err)),
);
}
// Wait 5 seconds between polls
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 5000);
// Allow the timer to be garbage-collected if process exits
if (typeof timer.unref === 'function') timer.unref();
});
}
});
}