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:
348
cli/README.md
Normal file
348
cli/README.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# sentryagent CLI
|
||||
|
||||
The official command-line interface for [SentryAgent.ai](https://sentryagent.ai) — manage agents, issue OAuth2 tokens, rotate credentials, and stream audit logs from your terminal.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### From npm (once published)
|
||||
|
||||
```bash
|
||||
npm install -g sentryagent
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
cd cli/
|
||||
npm install
|
||||
npm run build
|
||||
npm install -g .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Before using any command, configure the CLI with your API endpoint and credentials:
|
||||
|
||||
```bash
|
||||
sentryagent configure
|
||||
```
|
||||
|
||||
You will be prompted for:
|
||||
|
||||
| Field | Description |
|
||||
|---------------|--------------------------------------------------|
|
||||
| API URL | The SentryAgent.ai API base URL (e.g. `https://api.sentryagent.ai`) |
|
||||
| Client ID | Your tenant client ID |
|
||||
| Client Secret | Your tenant client secret |
|
||||
|
||||
Configuration is stored at `~/.sentryagent/config.json` with permissions `0600`.
|
||||
|
||||
If any command is run before `sentryagent configure` has been called, the CLI exits with:
|
||||
|
||||
```
|
||||
Not configured. Run `sentryagent configure` first.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### `sentryagent --version` / `-v`
|
||||
|
||||
Output the installed CLI version.
|
||||
|
||||
```bash
|
||||
sentryagent --version
|
||||
# 1.0.0
|
||||
```
|
||||
|
||||
### `sentryagent --help` / `-h`
|
||||
|
||||
Show all available commands and global options.
|
||||
|
||||
```bash
|
||||
sentryagent --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `sentryagent configure`
|
||||
|
||||
Interactively configure the CLI.
|
||||
|
||||
```bash
|
||||
sentryagent configure
|
||||
```
|
||||
|
||||
**Prompts:**
|
||||
|
||||
```
|
||||
SentryAgent CLI Configuration
|
||||
────────────────────────────────────────
|
||||
API URL (e.g. https://api.sentryagent.ai): https://api.sentryagent.ai
|
||||
Client ID: tenant_01ABC...
|
||||
Client Secret: ****
|
||||
|
||||
✓ Configuration saved to ~/.sentryagent/config.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `sentryagent register-agent`
|
||||
|
||||
Register a new agent with the identity provider.
|
||||
|
||||
```bash
|
||||
sentryagent register-agent --name <name> [--description <desc>]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Flag | Required | Description |
|
||||
|-------------------|----------|---------------------|
|
||||
| `--name <name>` | Yes | Agent display name |
|
||||
| `--description` | No | Agent description |
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
sentryagent register-agent --name "billing-agent" --description "Handles billing workflows"
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```
|
||||
✓ Agent registered successfully
|
||||
|
||||
Agent ID: 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
Name: billing-agent
|
||||
Description: Handles billing workflows
|
||||
Status: active
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `sentryagent list-agents`
|
||||
|
||||
List all agents registered for your tenant, displayed as a formatted table.
|
||||
|
||||
```bash
|
||||
sentryagent list-agents
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```
|
||||
AGENT ID NAME STATUS CREATED AT
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
01ARZ3NDEKTSV4RRFFQ69G5FAV billing-agent active 4/2/2026, 9:00:00 AM
|
||||
01ARZ3NDEKTSV4RRFFQ69G5FAX auth-agent active 4/1/2026, 3:00:00 PM
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
Total: 2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `sentryagent issue-token`
|
||||
|
||||
Issue an OAuth2 `client_credentials` access token for a specific agent.
|
||||
|
||||
```bash
|
||||
sentryagent issue-token --agent-id <id>
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Flag | Required | Description |
|
||||
|--------------------|----------|-------------------------|
|
||||
| `--agent-id <id>` | Yes | Target agent ID |
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
sentryagent issue-token --agent-id 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```
|
||||
✓ Token issued successfully
|
||||
|
||||
Access Token:
|
||||
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
|
||||
Token Type: Bearer
|
||||
Expires In: 3600s
|
||||
Expires At: 2026-04-02T10:00:00.000Z
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `sentryagent rotate-credentials`
|
||||
|
||||
Rotate the client secret for an agent. Prompts for confirmation before proceeding.
|
||||
|
||||
```bash
|
||||
sentryagent rotate-credentials --agent-id <id>
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Flag | Required | Description |
|
||||
|--------------------|----------|-------------------------|
|
||||
| `--agent-id <id>` | Yes | Target agent ID |
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
sentryagent rotate-credentials --agent-id 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```
|
||||
⚠ This will invalidate the current secret for agent 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
This will invalidate the current secret. Continue? [y/N] y
|
||||
|
||||
✓ Credentials rotated successfully
|
||||
|
||||
Client ID: 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
Client Secret: cs_new_secret_value_here
|
||||
|
||||
Store the new client secret securely — it will not be shown again.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `sentryagent tail-audit-log`
|
||||
|
||||
Poll the audit log API every 5 seconds and stream new events to stdout. Press **Ctrl+C** to stop.
|
||||
|
||||
```bash
|
||||
sentryagent tail-audit-log [--agent-id <id>]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Flag | Required | Description |
|
||||
|--------------------|----------|------------------------------------|
|
||||
| `--agent-id <id>` | No | Filter events for a specific agent |
|
||||
|
||||
**Example (all events):**
|
||||
|
||||
```bash
|
||||
sentryagent tail-audit-log
|
||||
```
|
||||
|
||||
**Example (filtered by agent):**
|
||||
|
||||
```bash
|
||||
sentryagent tail-audit-log --agent-id 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```
|
||||
Tailing audit log — press Ctrl+C to stop
|
||||
────────────────────────────────────────────────────────────
|
||||
4/2/2026, 9:05:00 AM agent.token.issued outcome=success agent=01ARZ3NDEKTSV... id=evt_01...
|
||||
4/2/2026, 9:10:03 AM agent.registered outcome=success id=evt_02...
|
||||
^C
|
||||
|
||||
Stopped.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `sentryagent completion`
|
||||
|
||||
Output shell completion scripts.
|
||||
|
||||
#### Bash
|
||||
|
||||
```bash
|
||||
sentryagent completion bash
|
||||
```
|
||||
|
||||
To enable permanently, add to `~/.bashrc` or `~/.bash_profile`:
|
||||
|
||||
```bash
|
||||
source <(sentryagent completion bash)
|
||||
```
|
||||
|
||||
Or write to a file:
|
||||
|
||||
```bash
|
||||
sentryagent completion bash > ~/.bash_completion.d/sentryagent
|
||||
```
|
||||
|
||||
#### Zsh
|
||||
|
||||
```bash
|
||||
sentryagent completion zsh
|
||||
```
|
||||
|
||||
To enable permanently, add to `~/.zshrc`:
|
||||
|
||||
```bash
|
||||
source <(sentryagent completion zsh)
|
||||
```
|
||||
|
||||
Or write to a file in your `$fpath`:
|
||||
|
||||
```bash
|
||||
sentryagent completion zsh > ~/.zsh/completions/_sentryagent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shell Completion Setup
|
||||
|
||||
### Bash (one-time setup)
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.bash_completion.d
|
||||
sentryagent completion bash > ~/.bash_completion.d/sentryagent
|
||||
echo 'source ~/.bash_completion.d/sentryagent' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
### Zsh (one-time setup)
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.zsh/completions
|
||||
sentryagent completion zsh > ~/.zsh/completions/_sentryagent
|
||||
echo 'fpath=(~/.zsh/completions $fpath)' >> ~/.zshrc
|
||||
echo 'autoload -Uz compinit && compinit' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
After setup, pressing **Tab** after `sentryagent` will autocomplete commands and flags.
|
||||
|
||||
---
|
||||
|
||||
## Configuration File
|
||||
|
||||
The config file is stored at `~/.sentryagent/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiUrl": "https://api.sentryagent.ai",
|
||||
"clientId": "tenant_01ABC...",
|
||||
"clientSecret": "cs_secret_value"
|
||||
}
|
||||
```
|
||||
|
||||
The directory is created with mode `0700` and the file with mode `0600` to prevent other users from reading your credentials.
|
||||
|
||||
---
|
||||
|
||||
## Environment
|
||||
|
||||
- Node.js >= 18.0.0 is required (uses the built-in `fetch` API)
|
||||
- All HTTP requests use OAuth2 `client_credentials` tokens fetched automatically from your configuration
|
||||
- Tokens are cached in memory for the duration of the CLI session (refreshed 30 seconds before expiry)
|
||||
267
cli/package-lock.json
generated
Normal file
267
cli/package-lock.json
generated
Normal file
@@ -0,0 +1,267 @@
|
||||
{
|
||||
"name": "sentryagent",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sentryagent",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"sentryagent": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
"@tsconfig/node12": "^1.0.7",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@tsconfig/node16": "^1.0.2",
|
||||
"acorn": "^8.4.1",
|
||||
"acorn-walk": "^8.1.1",
|
||||
"arg": "^4.1.0",
|
||||
"create-require": "^1.1.0",
|
||||
"diff": "^4.0.1",
|
||||
"make-error": "^1.1.1",
|
||||
"v8-compile-cache-lib": "^3.0.1",
|
||||
"yn": "3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"ts-node": "dist/bin.js",
|
||||
"ts-node-cwd": "dist/bin-cwd.js",
|
||||
"ts-node-esm": "dist/bin-esm.js",
|
||||
"ts-node-script": "dist/bin-script.js",
|
||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||
"ts-script": "dist/bin-script-deprecated.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": ">=1.2.50",
|
||||
"@swc/wasm": ">=1.2.50",
|
||||
"@types/node": "*",
|
||||
"typescript": ">=2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/wasm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
cli/package.json
Normal file
34
cli/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "sentryagent",
|
||||
"version": "1.0.0",
|
||||
"description": "SentryAgent.ai CLI — manage agents, tokens, and audit logs",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"sentryagent": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.7",
|
||||
"typescript": "^5.4.5",
|
||||
"ts-node": "^10.9.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"sentryagent",
|
||||
"agentidp",
|
||||
"cli",
|
||||
"agents",
|
||||
"identity"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
95
cli/src/api.ts
Normal file
95
cli/src/api.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Config } from './config';
|
||||
|
||||
interface TokenCache {
|
||||
accessToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let tokenCache: TokenCache | null = null;
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
async function fetchToken(config: Config): Promise<string> {
|
||||
const now = Date.now();
|
||||
if (tokenCache !== null && tokenCache.expiresAt > now + 30_000) {
|
||||
return tokenCache.accessToken;
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
});
|
||||
|
||||
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(`Authentication failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as TokenResponse;
|
||||
tokenCache = {
|
||||
accessToken: data.access_token,
|
||||
expiresAt: now + data.expires_in * 1000,
|
||||
};
|
||||
return tokenCache.accessToken;
|
||||
}
|
||||
|
||||
export function clearTokenCache(): void {
|
||||
tokenCache = null;
|
||||
}
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
|
||||
interface ApiRequestOptions {
|
||||
method?: HttpMethod;
|
||||
body?: unknown;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
config: Config,
|
||||
endpoint: string,
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const token = await fetchToken(config);
|
||||
const { method = 'GET', body, params } = options;
|
||||
|
||||
let url = `${config.apiUrl}${endpoint}`;
|
||||
if (params !== undefined && Object.keys(params).length > 0) {
|
||||
const qs = new URLSearchParams(params);
|
||||
url = `${url}?${qs.toString()}`;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const fetchOptions: RequestInit = { method, headers };
|
||||
if (body !== undefined) {
|
||||
fetchOptions.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const res = await fetch(url, fetchOptions);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`API error (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
155
cli/src/commands/completion.ts
Normal file
155
cli/src/commands/completion.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
63
cli/src/commands/configure.ts
Normal file
63
cli/src/commands/configure.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
70
cli/src/commands/issue-token.ts
Normal file
70
cli/src/commands/issue-token.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
105
cli/src/commands/list-agents.ts
Normal file
105
cli/src/commands/list-agents.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
54
cli/src/commands/register-agent.ts
Normal file
54
cli/src/commands/register-agent.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
85
cli/src/commands/rotate-credentials.ts
Normal file
85
cli/src/commands/rotate-credentials.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
122
cli/src/commands/tail-audit-log.ts
Normal file
122
cli/src/commands/tail-audit-log.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
61
cli/src/config.ts
Normal file
61
cli/src/config.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface Config {
|
||||
apiUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.sentryagent');
|
||||
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
||||
|
||||
export function readConfig(): Config | null {
|
||||
if (!fs.existsSync(CONFIG_FILE)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
parsed !== null &&
|
||||
typeof parsed === 'object' &&
|
||||
'apiUrl' in parsed &&
|
||||
'clientId' in parsed &&
|
||||
'clientSecret' in parsed &&
|
||||
typeof (parsed as Record<string, unknown>)['apiUrl'] === 'string' &&
|
||||
typeof (parsed as Record<string, unknown>)['clientId'] === 'string' &&
|
||||
typeof (parsed as Record<string, unknown>)['clientSecret'] === 'string'
|
||||
) {
|
||||
const p = parsed as Record<string, unknown>;
|
||||
return {
|
||||
apiUrl: p['apiUrl'] as string,
|
||||
clientId: p['clientId'] as string,
|
||||
clientSecret: p['clientSecret'] as string,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeConfig(config: Config): void {
|
||||
if (!fs.existsSync(CONFIG_DIR)) {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
export function requireConfig(): Config {
|
||||
const config = readConfig();
|
||||
if (config === null) {
|
||||
console.error('Not configured. Run `sentryagent configure` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
31
cli/src/index.ts
Normal file
31
cli/src/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from 'commander';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
import { registerConfigure } from './commands/configure';
|
||||
import { registerRegisterAgent } from './commands/register-agent';
|
||||
import { registerListAgents } from './commands/list-agents';
|
||||
import { registerIssueToken } from './commands/issue-token';
|
||||
import { registerRotateCredentials } from './commands/rotate-credentials';
|
||||
import { registerTailAuditLog } from './commands/tail-audit-log';
|
||||
import { registerCompletion } from './commands/completion';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('sentryagent')
|
||||
.description('SentryAgent.ai CLI — manage agents, tokens, and audit logs')
|
||||
.version(packageJson.version, '-v, --version', 'Output the current version');
|
||||
|
||||
// Register all commands
|
||||
registerConfigure(program);
|
||||
registerRegisterAgent(program);
|
||||
registerListAgents(program);
|
||||
registerIssueToken(program);
|
||||
registerRotateCredentials(program);
|
||||
registerTailAuditLog(program);
|
||||
registerCompletion(program);
|
||||
|
||||
// Parse args — commander will display help automatically on --help
|
||||
program.parse(process.argv);
|
||||
29
cli/tsconfig.json
Normal file
29
cli/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user