@troxy/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +63 -0
- package/bin/troxy.js +99 -0
- package/package.json +20 -0
- package/src/activity.js +26 -0
- package/src/api.js +50 -0
- package/src/auth.js +69 -0
- package/src/cards.js +58 -0
- package/src/config.js +19 -0
- package/src/init.js +119 -0
- package/src/mcp-server.js +110 -0
- package/src/policies.js +88 -0
- package/src/print.js +16 -0
- package/src/tests/config.test.js +25 -0
- package/src/tests/init.test.js +22 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ['v*']
|
|
7
|
+
pull_request:
|
|
8
|
+
branches: [main]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
# ─────────────────────────────────────────────
|
|
12
|
+
# TEST — runs on every push and PR
|
|
13
|
+
# ─────────────────────────────────────────────
|
|
14
|
+
test:
|
|
15
|
+
name: Test (Node ${{ matrix.node }})
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
|
|
18
|
+
strategy:
|
|
19
|
+
matrix:
|
|
20
|
+
node: ['18', '20', '22']
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- name: Setup Node.js ${{ matrix.node }}
|
|
26
|
+
uses: actions/setup-node@v4
|
|
27
|
+
with:
|
|
28
|
+
node-version: ${{ matrix.node }}
|
|
29
|
+
cache: npm
|
|
30
|
+
|
|
31
|
+
- name: Install dependencies
|
|
32
|
+
run: npm ci
|
|
33
|
+
|
|
34
|
+
- name: Run tests
|
|
35
|
+
run: npm test
|
|
36
|
+
|
|
37
|
+
# ─────────────────────────────────────────────
|
|
38
|
+
# PUBLISH — runs only on version tags (v*)
|
|
39
|
+
# Requires NPM_TOKEN secret in repo settings
|
|
40
|
+
# ─────────────────────────────────────────────
|
|
41
|
+
publish:
|
|
42
|
+
name: Publish to npm
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
needs: test
|
|
45
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
46
|
+
|
|
47
|
+
steps:
|
|
48
|
+
- uses: actions/checkout@v4
|
|
49
|
+
|
|
50
|
+
- name: Setup Node.js
|
|
51
|
+
uses: actions/setup-node@v4
|
|
52
|
+
with:
|
|
53
|
+
node-version: '20'
|
|
54
|
+
registry-url: 'https://registry.npmjs.org'
|
|
55
|
+
cache: npm
|
|
56
|
+
|
|
57
|
+
- name: Install dependencies
|
|
58
|
+
run: npm ci
|
|
59
|
+
|
|
60
|
+
- name: Publish
|
|
61
|
+
run: npm publish --access public
|
|
62
|
+
env:
|
|
63
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/bin/troxy.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runInit } from '../src/init.js';
|
|
3
|
+
import { runMcp } from '../src/mcp-server.js';
|
|
4
|
+
import { runLogin, clearSession } from '../src/auth.js';
|
|
5
|
+
import { runCards } from '../src/cards.js';
|
|
6
|
+
import { runPolicies } from '../src/policies.js';
|
|
7
|
+
import { runActivity } from '../src/activity.js';
|
|
8
|
+
import { api } from '../src/api.js';
|
|
9
|
+
import { requireJwt } from '../src/auth.js';
|
|
10
|
+
import { table } from '../src/print.js';
|
|
11
|
+
|
|
12
|
+
const [,, command, sub, ...rest] = process.argv;
|
|
13
|
+
const allArgs = [sub, ...rest].filter(Boolean);
|
|
14
|
+
|
|
15
|
+
// Parse --flag value pairs
|
|
16
|
+
const flags = {};
|
|
17
|
+
const positional = [];
|
|
18
|
+
for (let i = 0; i < allArgs.length; i++) {
|
|
19
|
+
if (allArgs[i].startsWith('--')) {
|
|
20
|
+
const key = allArgs[i].slice(2);
|
|
21
|
+
const next = allArgs[i + 1];
|
|
22
|
+
flags[key] = next && !next.startsWith('--') ? allArgs[++i] : true;
|
|
23
|
+
} else {
|
|
24
|
+
positional.push(allArgs[i]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
switch (command) {
|
|
29
|
+
// ── Setup ─────────────────────────────────────────────────────
|
|
30
|
+
case 'init':
|
|
31
|
+
await runInit(flags);
|
|
32
|
+
break;
|
|
33
|
+
|
|
34
|
+
// ── Auth ──────────────────────────────────────────────────────
|
|
35
|
+
case 'login':
|
|
36
|
+
await runLogin(flags);
|
|
37
|
+
break;
|
|
38
|
+
|
|
39
|
+
case 'logout':
|
|
40
|
+
clearSession();
|
|
41
|
+
console.log('\n Logged out ✓\n');
|
|
42
|
+
break;
|
|
43
|
+
|
|
44
|
+
// ── MCP server (started by MCP clients) ───────────────────────
|
|
45
|
+
case 'mcp':
|
|
46
|
+
await runMcp();
|
|
47
|
+
break;
|
|
48
|
+
|
|
49
|
+
// ── Resources ─────────────────────────────────────────────────
|
|
50
|
+
case 'cards':
|
|
51
|
+
await runCards(positional, flags);
|
|
52
|
+
break;
|
|
53
|
+
|
|
54
|
+
case 'policies':
|
|
55
|
+
await runPolicies(positional, flags);
|
|
56
|
+
break;
|
|
57
|
+
|
|
58
|
+
case 'activity':
|
|
59
|
+
await runActivity(flags);
|
|
60
|
+
break;
|
|
61
|
+
|
|
62
|
+
// ── Status ────────────────────────────────────────────────────
|
|
63
|
+
case 'status': {
|
|
64
|
+
const health = await api.health();
|
|
65
|
+
console.log(`\n API: ${health.status === 'ok' ? '✓ online' : '✗ ' + health.status}`);
|
|
66
|
+
console.log(` DB: ${health.db}`);
|
|
67
|
+
console.log(` Env: ${health.env}\n`);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Help / default ────────────────────────────────────────────
|
|
72
|
+
default:
|
|
73
|
+
if (command) console.error(` Unknown command: ${command}\n`);
|
|
74
|
+
console.log(`
|
|
75
|
+
Troxy — AI payment control
|
|
76
|
+
|
|
77
|
+
Setup
|
|
78
|
+
npx troxy init --key <api-key> Initialize and patch MCP clients
|
|
79
|
+
npx troxy login Log in to your dashboard account
|
|
80
|
+
npx troxy logout Clear local session
|
|
81
|
+
npx troxy status Check API health
|
|
82
|
+
|
|
83
|
+
Cards
|
|
84
|
+
npx troxy cards list
|
|
85
|
+
npx troxy cards create --name "Personal" [--budget 500] [--provider stripe]
|
|
86
|
+
npx troxy cards delete --name "Personal"
|
|
87
|
+
|
|
88
|
+
Policies
|
|
89
|
+
npx troxy policies list
|
|
90
|
+
npx troxy policies create --name "Block high" --action BLOCK --field amount --operator gte --value 100
|
|
91
|
+
npx troxy policies enable --name "Block high"
|
|
92
|
+
npx troxy policies disable --name "Block high"
|
|
93
|
+
npx troxy policies delete --name "Block high"
|
|
94
|
+
|
|
95
|
+
Activity
|
|
96
|
+
npx troxy activity [--limit 50]
|
|
97
|
+
`);
|
|
98
|
+
process.exit(command ? 1 : 0);
|
|
99
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@troxy/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI payment control — protect your agent's payments with policies",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"troxy": "./bin/troxy.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test src/tests/*.test.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@modelcontextprotocol/sdk": "^1.10.2"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["mcp", "ai", "payments", "policy", "agents"],
|
|
19
|
+
"license": "MIT"
|
|
20
|
+
}
|
package/src/activity.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { api } from './api.js';
|
|
2
|
+
import { requireJwt } from './auth.js';
|
|
3
|
+
import { table } from './print.js';
|
|
4
|
+
|
|
5
|
+
const DECISION_ICON = { ALLOW: '✓', BLOCK: '✗', ESCALATE: '⏳', NOTIFY: '~' };
|
|
6
|
+
|
|
7
|
+
export async function runActivity(flags) {
|
|
8
|
+
const jwt = requireJwt();
|
|
9
|
+
const limit = Number(flags.limit || 20);
|
|
10
|
+
const rows = await api.activity(jwt, limit);
|
|
11
|
+
|
|
12
|
+
if (!rows.length) { console.log('\n No activity yet.\n'); return; }
|
|
13
|
+
|
|
14
|
+
console.log();
|
|
15
|
+
table(
|
|
16
|
+
['Decision', 'Agent', 'Merchant', 'Amount', 'Policy', 'Time'],
|
|
17
|
+
rows.map(r => [
|
|
18
|
+
`${DECISION_ICON[r.decision] || ' '} ${r.decision}`,
|
|
19
|
+
r.agent_name || 'unknown',
|
|
20
|
+
r.merchant_name || '—',
|
|
21
|
+
r.amount ? `$${Number(r.amount).toFixed(2)}` : '—',
|
|
22
|
+
r.policy_name || '—',
|
|
23
|
+
new Date(r.created_at).toLocaleString(),
|
|
24
|
+
]),
|
|
25
|
+
);
|
|
26
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const BASE_URL =
|
|
2
|
+
process.env.TROXY_API_URL ||
|
|
3
|
+
'https://wuxyx33bka.execute-api.us-east-1.amazonaws.com';
|
|
4
|
+
|
|
5
|
+
async function request(method, path, { apiKey, jwt, body } = {}) {
|
|
6
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
7
|
+
if (apiKey) headers['X-Troxy-Key'] = apiKey;
|
|
8
|
+
if (jwt) headers['Authorization'] = `Bearer ${jwt}`;
|
|
9
|
+
|
|
10
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
11
|
+
method,
|
|
12
|
+
headers,
|
|
13
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const data = await res.json();
|
|
17
|
+
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
|
18
|
+
return data;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const api = {
|
|
22
|
+
// Auth
|
|
23
|
+
health: () => request('GET', '/health'),
|
|
24
|
+
magicLink: (email) => request('POST', '/auth/magic-link', { body: { email } }),
|
|
25
|
+
verify: (token) => request('POST', '/auth/verify', { body: { token } }),
|
|
26
|
+
|
|
27
|
+
// Cards
|
|
28
|
+
listCards: (jwt) => request('GET', '/cards', { jwt }),
|
|
29
|
+
createCard: (jwt, b) => request('POST', '/cards', { jwt, body: b }),
|
|
30
|
+
updateCard: (jwt, id, b) => request('PUT', `/cards/${id}`, { jwt, body: b }),
|
|
31
|
+
deleteCard: (jwt, id) => request('DELETE', `/cards/${id}`, { jwt }),
|
|
32
|
+
|
|
33
|
+
// Policies
|
|
34
|
+
listPolicies: (jwt) => request('GET', '/dashboard/policies', { jwt }),
|
|
35
|
+
createPolicy: (jwt, b) => request('POST', '/dashboard/policies', { jwt, body: b }),
|
|
36
|
+
updatePolicy: (jwt, id, b) => request('PATCH', `/dashboard/policies/${id}`, { jwt, body: b }),
|
|
37
|
+
deletePolicy: (jwt, id) => request('DELETE', `/dashboard/policies/${id}`, { jwt }),
|
|
38
|
+
|
|
39
|
+
// Activity + insights
|
|
40
|
+
activity: (jwt, limit) => request('GET', `/dashboard/activity?limit=${limit || 20}`, { jwt }),
|
|
41
|
+
insights: (jwt) => request('GET', '/dashboard/insights', { jwt }),
|
|
42
|
+
|
|
43
|
+
// Tokens
|
|
44
|
+
listTokens: (jwt) => request('GET', '/tokens', { jwt }),
|
|
45
|
+
createToken: (jwt, b) => request('POST', '/tokens', { jwt, body: b }),
|
|
46
|
+
revokeToken: (jwt, id) => request('DELETE', `/tokens/${id}`, { jwt }),
|
|
47
|
+
|
|
48
|
+
// Evaluate (agent API key)
|
|
49
|
+
evaluate: (body, apiKey) => request('POST', '/evaluate', { apiKey, body }),
|
|
50
|
+
};
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import readline from 'readline';
|
|
5
|
+
import { api } from './api.js';
|
|
6
|
+
|
|
7
|
+
const SESSION_FILE = path.join(os.homedir(), '.troxy', 'session.json');
|
|
8
|
+
|
|
9
|
+
export function loadSession() {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function saveSession(data) {
|
|
18
|
+
fs.mkdirSync(path.dirname(SESSION_FILE), { recursive: true });
|
|
19
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function clearSession() {
|
|
23
|
+
try { fs.unlinkSync(SESSION_FILE); } catch {}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Require a valid JWT session or exit with a helpful message. */
|
|
27
|
+
export function requireJwt() {
|
|
28
|
+
const session = loadSession();
|
|
29
|
+
if (!session?.jwt) {
|
|
30
|
+
console.error('\n Not logged in. Run: npx troxy login\n');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
return session.jwt;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Interactive magic-link login flow. */
|
|
37
|
+
export async function runLogin({ email } = {}) {
|
|
38
|
+
if (!email) {
|
|
39
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
40
|
+
email = await new Promise(resolve => rl.question(' Email: ', ans => { rl.close(); resolve(ans.trim()); }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
process.stdout.write(`\n Sending magic link to ${email}... `);
|
|
44
|
+
try {
|
|
45
|
+
await api.magicLink(email);
|
|
46
|
+
console.log('✓');
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.log('✗');
|
|
49
|
+
console.error(` Error: ${err.message}\n`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
54
|
+
const token = await new Promise(resolve =>
|
|
55
|
+
rl.question(' Paste the code from the email: ', ans => { rl.close(); resolve(ans.trim()); })
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
process.stdout.write(' Verifying... ');
|
|
59
|
+
try {
|
|
60
|
+
const result = await api.verify(token);
|
|
61
|
+
saveSession({ jwt: result.token, email });
|
|
62
|
+
console.log('✓');
|
|
63
|
+
console.log(`\n Logged in as ${email}\n`);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.log('✗');
|
|
66
|
+
console.error(` Error: ${err.message}\n`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/cards.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { api } from './api.js';
|
|
2
|
+
import { requireJwt } from './auth.js';
|
|
3
|
+
import { table, fmt } from './print.js';
|
|
4
|
+
|
|
5
|
+
export async function runCards([sub, ...args], flags) {
|
|
6
|
+
const jwt = requireJwt();
|
|
7
|
+
|
|
8
|
+
switch (sub) {
|
|
9
|
+
case 'list':
|
|
10
|
+
case undefined: {
|
|
11
|
+
const cards = await api.listCards(jwt);
|
|
12
|
+
if (!cards.length) { console.log('\n No cards yet.\n'); return; }
|
|
13
|
+
console.log();
|
|
14
|
+
table(
|
|
15
|
+
['Name', 'Status', 'Budget', 'Used', 'Provider'],
|
|
16
|
+
cards.map(c => [
|
|
17
|
+
c.alias_name,
|
|
18
|
+
c.status,
|
|
19
|
+
c.monthly_budget ? `$${c.monthly_budget}` : '—',
|
|
20
|
+
c.budget_used ? `$${Number(c.budget_used).toFixed(2)}` : '$0.00',
|
|
21
|
+
c.provider || '—',
|
|
22
|
+
]),
|
|
23
|
+
);
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
case 'create': {
|
|
28
|
+
const name = flags.name;
|
|
29
|
+
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
30
|
+
const body = {
|
|
31
|
+
alias_name: name,
|
|
32
|
+
monthly_budget: flags.budget ? Number(flags.budget) : null,
|
|
33
|
+
provider: flags.provider || null,
|
|
34
|
+
card_number: flags['card-number'] || null,
|
|
35
|
+
status: 'active',
|
|
36
|
+
};
|
|
37
|
+
const card = await api.createCard(jwt, body);
|
|
38
|
+
console.log(`\n Card "${card.alias_name}" created ✓\n`);
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
case 'delete': {
|
|
43
|
+
const name = flags.name;
|
|
44
|
+
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
45
|
+
const cards = await api.listCards(jwt);
|
|
46
|
+
const card = cards.find(c => c.alias_name === name);
|
|
47
|
+
if (!card) { console.error(` Card "${name}" not found\n`); process.exit(1); }
|
|
48
|
+
await api.deleteCard(jwt, card.id);
|
|
49
|
+
console.log(`\n Card "${name}" deleted ✓\n`);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
default:
|
|
54
|
+
console.error(` Unknown subcommand: ${sub}`);
|
|
55
|
+
console.error(' Usage: npx troxy cards [list|create|delete]\n');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), '.troxy');
|
|
6
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
export function loadConfig() {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function saveConfig(data) {
|
|
17
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
18
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
|
|
19
|
+
}
|
package/src/init.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { saveConfig } from './config.js';
|
|
5
|
+
import { evaluatePayment } from './api.js';
|
|
6
|
+
|
|
7
|
+
// MCP config locations per client and platform
|
|
8
|
+
const MCP_CLIENTS = [
|
|
9
|
+
{
|
|
10
|
+
name: 'Claude Desktop',
|
|
11
|
+
path: {
|
|
12
|
+
darwin: path.join(os.homedir(), 'Library/Application Support/Claude/claude_desktop_config.json'),
|
|
13
|
+
win32: path.join(process.env.APPDATA || os.homedir(), 'Claude/claude_desktop_config.json'),
|
|
14
|
+
linux: path.join(os.homedir(), '.config/claude/claude_desktop_config.json'),
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'Cursor',
|
|
19
|
+
path: {
|
|
20
|
+
darwin: path.join(os.homedir(), '.cursor/mcp.json'),
|
|
21
|
+
win32: path.join(os.homedir(), '.cursor/mcp.json'),
|
|
22
|
+
linux: path.join(os.homedir(), '.cursor/mcp.json'),
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'Windsurf',
|
|
27
|
+
path: {
|
|
28
|
+
darwin: path.join(os.homedir(), '.codeium/windsurf/mcp_config.json'),
|
|
29
|
+
win32: path.join(os.homedir(), '.codeium/windsurf/mcp_config.json'),
|
|
30
|
+
linux: path.join(os.homedir(), '.codeium/windsurf/mcp_config.json'),
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export async function runInit({ key } = {}) {
|
|
36
|
+
if (!key || !key.startsWith('txy-')) {
|
|
37
|
+
console.error('\n Error: --key is required and must start with txy-');
|
|
38
|
+
console.error(' Usage: npx troxy init --key txy-...\n');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log('\n Troxy — AI payment control\n');
|
|
43
|
+
|
|
44
|
+
// Validate the key by hitting /evaluate (404 card = key is valid)
|
|
45
|
+
process.stdout.write(' Validating API key... ');
|
|
46
|
+
try {
|
|
47
|
+
const result = await evaluatePayment(
|
|
48
|
+
{ agent: 'troxy-init', card_alias: '__ping__', amount: 0 },
|
|
49
|
+
key,
|
|
50
|
+
);
|
|
51
|
+
if (result.error === 'invalid or revoked API key') {
|
|
52
|
+
console.log('✗');
|
|
53
|
+
console.error('\n Error: Invalid or revoked API key.\n');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
console.log('✓');
|
|
57
|
+
} catch {
|
|
58
|
+
console.log('✗');
|
|
59
|
+
console.error('\n Error: Could not reach Troxy API. Check your internet connection.\n');
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Save config
|
|
64
|
+
saveConfig({ apiKey: key });
|
|
65
|
+
console.log(' Config saved (~/.troxy/config.json) ✓');
|
|
66
|
+
|
|
67
|
+
// Detect and patch MCP clients
|
|
68
|
+
const platform = process.platform;
|
|
69
|
+
const detected = MCP_CLIENTS.filter(c => {
|
|
70
|
+
const p = c.path[platform] ?? c.path.linux;
|
|
71
|
+
return fs.existsSync(p);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (detected.length === 0) {
|
|
75
|
+
console.log('\n No MCP clients detected (Claude Desktop, Cursor, Windsurf).');
|
|
76
|
+
console.log(' Troxy MCP server config:\n');
|
|
77
|
+
console.log(JSON.stringify(mcpEntry(key), null, 4));
|
|
78
|
+
console.log('\n Add the above to your MCP client\'s config under "mcpServers".\n');
|
|
79
|
+
} else {
|
|
80
|
+
console.log('\n MCP clients found:');
|
|
81
|
+
for (const client of detected) {
|
|
82
|
+
const configPath = client.path[platform] ?? client.path.linux;
|
|
83
|
+
try {
|
|
84
|
+
patchMcpConfig(configPath, key);
|
|
85
|
+
console.log(` • ${client.name} ✓`);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.log(` • ${client.name} ✗ (${err.message})`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
console.log('\n Restart your MCP client to activate Troxy.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log('\n Your payments are now protected.');
|
|
94
|
+
console.log(' Dashboard → https://dashboard.troxy.ai\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function mcpEntry(apiKey) {
|
|
98
|
+
return {
|
|
99
|
+
troxy: {
|
|
100
|
+
command: 'npx',
|
|
101
|
+
args: ['troxy', 'mcp'],
|
|
102
|
+
env: { TROXY_API_KEY: apiKey },
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function patchMcpConfig(configPath, apiKey) {
|
|
108
|
+
let config = {};
|
|
109
|
+
try {
|
|
110
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
111
|
+
} catch {
|
|
112
|
+
// file exists but is empty or malformed — start fresh
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!config.mcpServers) config.mcpServers = {};
|
|
116
|
+
config.mcpServers.troxy = mcpEntry(apiKey).troxy;
|
|
117
|
+
|
|
118
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
119
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import {
|
|
4
|
+
CallToolRequestSchema,
|
|
5
|
+
ListToolsRequestSchema,
|
|
6
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
import { loadConfig } from './config.js';
|
|
8
|
+
import { evaluatePayment } from './api.js';
|
|
9
|
+
|
|
10
|
+
export async function runMcp() {
|
|
11
|
+
const config = loadConfig();
|
|
12
|
+
const apiKey = process.env.TROXY_API_KEY || config?.apiKey;
|
|
13
|
+
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
process.stderr.write(
|
|
16
|
+
'Troxy: no API key found. Run: npx troxy init --key txy-...\n',
|
|
17
|
+
);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const server = new Server(
|
|
22
|
+
{ name: 'troxy', version: '0.1.0' },
|
|
23
|
+
{ capabilities: { tools: {} } },
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
27
|
+
tools: [
|
|
28
|
+
{
|
|
29
|
+
name: 'evaluate_payment',
|
|
30
|
+
description:
|
|
31
|
+
'Evaluate whether a payment should be allowed, blocked, or escalated ' +
|
|
32
|
+
'based on your Troxy policies. Call this before initiating any payment.',
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
required: ['card_alias', 'merchant_name', 'amount'],
|
|
36
|
+
properties: {
|
|
37
|
+
card_alias: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'Card alias to charge (e.g. "Personal", "Business")',
|
|
40
|
+
},
|
|
41
|
+
merchant_name: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'Name of the merchant or service',
|
|
44
|
+
},
|
|
45
|
+
amount: {
|
|
46
|
+
type: 'number',
|
|
47
|
+
description: 'Payment amount',
|
|
48
|
+
},
|
|
49
|
+
agent: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: 'Name of the agent making the payment (optional)',
|
|
52
|
+
},
|
|
53
|
+
merchant_category: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
description: 'Merchant category, e.g. "software", "travel" (optional)',
|
|
56
|
+
},
|
|
57
|
+
currency: {
|
|
58
|
+
type: 'string',
|
|
59
|
+
description: 'Currency code, defaults to USD (optional)',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
68
|
+
if (request.params.name !== 'evaluate_payment') {
|
|
69
|
+
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const args = request.params.arguments ?? {};
|
|
73
|
+
const result = await evaluatePayment(args, apiKey);
|
|
74
|
+
|
|
75
|
+
if (result.error) {
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: 'text', text: `Troxy error: ${result.error}` }],
|
|
78
|
+
isError: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { decision, policy, audit_id } = result;
|
|
83
|
+
let text;
|
|
84
|
+
|
|
85
|
+
switch (decision) {
|
|
86
|
+
case 'ALLOW':
|
|
87
|
+
text = `✓ Payment approved.${policy ? ` Policy matched: "${policy}".` : ''} (audit: ${audit_id})`;
|
|
88
|
+
break;
|
|
89
|
+
case 'BLOCK':
|
|
90
|
+
text = `✗ Payment blocked by policy "${policy}". Do not proceed with this payment. (audit: ${audit_id})`;
|
|
91
|
+
break;
|
|
92
|
+
case 'ESCALATE':
|
|
93
|
+
text = `⏳ Payment requires human approval — a request has been sent to the account owner. Do not proceed until approved. (audit: ${audit_id})`;
|
|
94
|
+
break;
|
|
95
|
+
case 'NOTIFY':
|
|
96
|
+
text = `✓ Payment approved with notification. Policy matched: "${policy}". (audit: ${audit_id})`;
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
text = JSON.stringify(result);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
content: [{ type: 'text', text }],
|
|
104
|
+
isError: decision === 'BLOCK',
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const transport = new StdioServerTransport();
|
|
109
|
+
await server.connect(transport);
|
|
110
|
+
}
|
package/src/policies.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { api } from './api.js';
|
|
2
|
+
import { requireJwt } from './auth.js';
|
|
3
|
+
import { table } from './print.js';
|
|
4
|
+
|
|
5
|
+
export async function runPolicies([sub, ...args], flags) {
|
|
6
|
+
const jwt = requireJwt();
|
|
7
|
+
|
|
8
|
+
switch (sub) {
|
|
9
|
+
case 'list':
|
|
10
|
+
case undefined: {
|
|
11
|
+
const policies = await api.listPolicies(jwt);
|
|
12
|
+
if (!policies.length) { console.log('\n No policies yet.\n'); return; }
|
|
13
|
+
console.log();
|
|
14
|
+
table(
|
|
15
|
+
['Name', 'Action', 'Priority', 'Status', 'Conditions'],
|
|
16
|
+
policies.map(p => [
|
|
17
|
+
p.name,
|
|
18
|
+
p.action,
|
|
19
|
+
p.priority,
|
|
20
|
+
p.enabled ? 'enabled' : 'disabled',
|
|
21
|
+
Array.isArray(p.conditions) ? `${p.conditions.length} condition(s)` : '—',
|
|
22
|
+
]),
|
|
23
|
+
);
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
case 'create': {
|
|
28
|
+
const name = flags.name;
|
|
29
|
+
const action = (flags.action || '').toUpperCase();
|
|
30
|
+
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
31
|
+
if (!action) { console.error(' --action is required\n'); process.exit(1); }
|
|
32
|
+
if (!['ALLOW','BLOCK','ESCALATE','NOTIFY'].includes(action)) {
|
|
33
|
+
console.error(' --action must be ALLOW, BLOCK, ESCALATE, or NOTIFY\n');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Build a single condition if --field/--operator/--value are provided
|
|
38
|
+
const conditions = [];
|
|
39
|
+
if (flags.field) {
|
|
40
|
+
if (!flags.operator) { console.error(' --operator is required with --field\n'); process.exit(1); }
|
|
41
|
+
const cond = { field: flags.field, operator: flags.operator };
|
|
42
|
+
if (flags.value) cond.value = flags.value;
|
|
43
|
+
if (flags.value2) cond.value2 = flags.value2;
|
|
44
|
+
conditions.push(cond);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const body = {
|
|
48
|
+
name,
|
|
49
|
+
action,
|
|
50
|
+
conditions,
|
|
51
|
+
conditions_logic: (flags.logic || 'AND').toUpperCase(),
|
|
52
|
+
enabled: true,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const policy = await api.createPolicy(jwt, body);
|
|
56
|
+
console.log(`\n Policy "${policy.name}" created ✓ (priority: ${policy.priority})\n`);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
case 'delete': {
|
|
61
|
+
const name = flags.name;
|
|
62
|
+
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
63
|
+
const policies = await api.listPolicies(jwt);
|
|
64
|
+
const policy = policies.find(p => p.name === name);
|
|
65
|
+
if (!policy) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
|
|
66
|
+
await api.deletePolicy(jwt, policy.id);
|
|
67
|
+
console.log(`\n Policy "${name}" deleted ✓\n`);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
case 'enable':
|
|
72
|
+
case 'disable': {
|
|
73
|
+
const name = flags.name;
|
|
74
|
+
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
75
|
+
const policies = await api.listPolicies(jwt);
|
|
76
|
+
const policy = policies.find(p => p.name === name);
|
|
77
|
+
if (!policy) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
|
|
78
|
+
await api.updatePolicy(jwt, policy.id, { enabled: sub === 'enable' });
|
|
79
|
+
console.log(`\n Policy "${name}" ${sub}d ✓\n`);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
default:
|
|
84
|
+
console.error(` Unknown subcommand: ${sub}`);
|
|
85
|
+
console.error(' Usage: npx troxy policies [list|create|delete|enable|disable]\n');
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/print.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Print a simple ASCII table. */
|
|
2
|
+
export function table(headers, rows) {
|
|
3
|
+
const cols = headers.length;
|
|
4
|
+
const widths = headers.map((h, i) =>
|
|
5
|
+
Math.max(String(h).length, ...rows.map(r => String(r[i] ?? '').length))
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
const line = () => ' ' + widths.map(w => '─'.repeat(w + 2)).join('┼') + '';
|
|
9
|
+
const row = (cells, pad = ' ') =>
|
|
10
|
+
' ' + cells.map((c, i) => ` ${String(c ?? '').padEnd(widths[i], pad)} `).join('│');
|
|
11
|
+
|
|
12
|
+
console.log(row(headers));
|
|
13
|
+
console.log(' ' + widths.map(w => '─'.repeat(w + 2)).join('┼'));
|
|
14
|
+
rows.forEach(r => console.log(row(r)));
|
|
15
|
+
console.log();
|
|
16
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
// Point config at a temp dir so tests don't touch ~/.troxy
|
|
8
|
+
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-test-'));
|
|
9
|
+
process.env.HOME = TMP;
|
|
10
|
+
|
|
11
|
+
const { loadConfig, saveConfig } = await import('../config.js');
|
|
12
|
+
|
|
13
|
+
describe('config', () => {
|
|
14
|
+
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
|
15
|
+
|
|
16
|
+
it('returns null when no config exists', () => {
|
|
17
|
+
assert.equal(loadConfig(), null);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('saves and loads config', () => {
|
|
21
|
+
saveConfig({ apiKey: 'txy-test123' });
|
|
22
|
+
const cfg = loadConfig();
|
|
23
|
+
assert.equal(cfg.apiKey, 'txy-test123');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
describe('flag validation', () => {
|
|
5
|
+
it('rejects missing key', async () => {
|
|
6
|
+
// Dynamically import and test the validation logic
|
|
7
|
+
// We test the guard condition directly rather than calling runInit
|
|
8
|
+
// (which would make real network calls)
|
|
9
|
+
const key = undefined;
|
|
10
|
+
assert.ok(!key || !String(key).startsWith('txy-'));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('rejects key without txy- prefix', () => {
|
|
14
|
+
const key = 'sk-notavalid';
|
|
15
|
+
assert.ok(!key.startsWith('txy-'));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('accepts valid key format', () => {
|
|
19
|
+
const key = 'txy-abc123xyz';
|
|
20
|
+
assert.ok(key.startsWith('txy-'));
|
|
21
|
+
});
|
|
22
|
+
});
|