@antseed/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/.env.example +15 -0
- package/README.md +169 -0
- package/dist/cli/commands/balance.d.ts +3 -0
- package/dist/cli/commands/balance.d.ts.map +1 -0
- package/dist/cli/commands/balance.js +64 -0
- package/dist/cli/commands/balance.js.map +1 -0
- package/dist/cli/commands/browse.d.ts +7 -0
- package/dist/cli/commands/browse.d.ts.map +1 -0
- package/dist/cli/commands/browse.js +100 -0
- package/dist/cli/commands/browse.js.map +1 -0
- package/dist/cli/commands/config.d.ts +20 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +239 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/connect.d.ts +14 -0
- package/dist/cli/commands/connect.d.ts.map +1 -0
- package/dist/cli/commands/connect.js +298 -0
- package/dist/cli/commands/connect.js.map +1 -0
- package/dist/cli/commands/connect.test.d.ts +2 -0
- package/dist/cli/commands/connect.test.d.ts.map +1 -0
- package/dist/cli/commands/connect.test.js +54 -0
- package/dist/cli/commands/connect.test.js.map +1 -0
- package/dist/cli/commands/dashboard.d.ts +6 -0
- package/dist/cli/commands/dashboard.d.ts.map +1 -0
- package/dist/cli/commands/dashboard.js +48 -0
- package/dist/cli/commands/dashboard.js.map +1 -0
- package/dist/cli/commands/deposit.d.ts +3 -0
- package/dist/cli/commands/deposit.d.ts.map +1 -0
- package/dist/cli/commands/deposit.js +48 -0
- package/dist/cli/commands/deposit.js.map +1 -0
- package/dist/cli/commands/dev.d.ts +3 -0
- package/dist/cli/commands/dev.d.ts.map +1 -0
- package/dist/cli/commands/dev.js +94 -0
- package/dist/cli/commands/dev.js.map +1 -0
- package/dist/cli/commands/init.d.ts +3 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +91 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/plugin-create.d.ts +11 -0
- package/dist/cli/commands/plugin-create.d.ts.map +1 -0
- package/dist/cli/commands/plugin-create.js +201 -0
- package/dist/cli/commands/plugin-create.js.map +1 -0
- package/dist/cli/commands/plugin-create.test.d.ts +2 -0
- package/dist/cli/commands/plugin-create.test.d.ts.map +1 -0
- package/dist/cli/commands/plugin-create.test.js +53 -0
- package/dist/cli/commands/plugin-create.test.js.map +1 -0
- package/dist/cli/commands/plugin.d.ts +3 -0
- package/dist/cli/commands/plugin.d.ts.map +1 -0
- package/dist/cli/commands/plugin.js +279 -0
- package/dist/cli/commands/plugin.js.map +1 -0
- package/dist/cli/commands/plugin.test.d.ts +2 -0
- package/dist/cli/commands/plugin.test.d.ts.map +1 -0
- package/dist/cli/commands/plugin.test.js +53 -0
- package/dist/cli/commands/plugin.test.js.map +1 -0
- package/dist/cli/commands/profile.d.ts +10 -0
- package/dist/cli/commands/profile.d.ts.map +1 -0
- package/dist/cli/commands/profile.js +89 -0
- package/dist/cli/commands/profile.js.map +1 -0
- package/dist/cli/commands/seed.d.ts +11 -0
- package/dist/cli/commands/seed.d.ts.map +1 -0
- package/dist/cli/commands/seed.js +397 -0
- package/dist/cli/commands/seed.js.map +1 -0
- package/dist/cli/commands/seed.test.d.ts +2 -0
- package/dist/cli/commands/seed.test.d.ts.map +1 -0
- package/dist/cli/commands/seed.test.js +57 -0
- package/dist/cli/commands/seed.test.js.map +1 -0
- package/dist/cli/commands/status.d.ts +8 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/status.js +55 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/types.d.ts +14 -0
- package/dist/cli/commands/types.d.ts.map +1 -0
- package/dist/cli/commands/types.js +41 -0
- package/dist/cli/commands/types.js.map +1 -0
- package/dist/cli/commands/withdraw.d.ts +3 -0
- package/dist/cli/commands/withdraw.d.ts.map +1 -0
- package/dist/cli/commands/withdraw.js +48 -0
- package/dist/cli/commands/withdraw.js.map +1 -0
- package/dist/cli/formatters.d.ts +29 -0
- package/dist/cli/formatters.d.ts.map +1 -0
- package/dist/cli/formatters.js +67 -0
- package/dist/cli/formatters.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +41 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/shutdown.d.ts +11 -0
- package/dist/cli/shutdown.d.ts.map +1 -0
- package/dist/cli/shutdown.js +34 -0
- package/dist/cli/shutdown.js.map +1 -0
- package/dist/config/defaults.d.ts +6 -0
- package/dist/config/defaults.d.ts.map +1 -0
- package/dist/config/defaults.js +48 -0
- package/dist/config/defaults.js.map +1 -0
- package/dist/config/effective.d.ts +26 -0
- package/dist/config/effective.d.ts.map +1 -0
- package/dist/config/effective.js +84 -0
- package/dist/config/effective.js.map +1 -0
- package/dist/config/effective.test.d.ts +2 -0
- package/dist/config/effective.test.d.ts.map +1 -0
- package/dist/config/effective.test.js +65 -0
- package/dist/config/effective.test.js.map +1 -0
- package/dist/config/loader.d.ts +12 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +212 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/loader.test.d.ts +2 -0
- package/dist/config/loader.test.d.ts.map +1 -0
- package/dist/config/loader.test.js +77 -0
- package/dist/config/loader.test.js.map +1 -0
- package/dist/config/types.d.ts +133 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/config/validation.d.ts +10 -0
- package/dist/config/validation.d.ts.map +1 -0
- package/dist/config/validation.js +50 -0
- package/dist/config/validation.js.map +1 -0
- package/dist/env/load-env.d.ts +6 -0
- package/dist/env/load-env.d.ts.map +1 -0
- package/dist/env/load-env.js +18 -0
- package/dist/env/load-env.js.map +1 -0
- package/dist/plugins/loader.d.ts +7 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +70 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/plugins/manager.d.ts +11 -0
- package/dist/plugins/manager.d.ts.map +1 -0
- package/dist/plugins/manager.js +52 -0
- package/dist/plugins/manager.js.map +1 -0
- package/dist/plugins/registry.d.ts +8 -0
- package/dist/plugins/registry.d.ts.map +1 -0
- package/dist/plugins/registry.js +39 -0
- package/dist/plugins/registry.js.map +1 -0
- package/dist/proxy/buyer-proxy.d.ts +30 -0
- package/dist/proxy/buyer-proxy.d.ts.map +1 -0
- package/dist/proxy/buyer-proxy.js +488 -0
- package/dist/proxy/buyer-proxy.js.map +1 -0
- package/dist/status/node-status.d.ts +22 -0
- package/dist/status/node-status.d.ts.map +1 -0
- package/dist/status/node-status.js +83 -0
- package/dist/status/node-status.js.map +1 -0
- package/package.json +39 -0
- package/src/cli/commands/balance.ts +77 -0
- package/src/cli/commands/browse.ts +113 -0
- package/src/cli/commands/config.ts +271 -0
- package/src/cli/commands/connect.test.ts +69 -0
- package/src/cli/commands/connect.ts +342 -0
- package/src/cli/commands/dashboard.ts +59 -0
- package/src/cli/commands/deposit.ts +61 -0
- package/src/cli/commands/dev.ts +107 -0
- package/src/cli/commands/init.ts +99 -0
- package/src/cli/commands/plugin-create.test.ts +60 -0
- package/src/cli/commands/plugin-create.ts +230 -0
- package/src/cli/commands/plugin.test.ts +55 -0
- package/src/cli/commands/plugin.ts +295 -0
- package/src/cli/commands/profile.ts +95 -0
- package/src/cli/commands/seed.test.ts +70 -0
- package/src/cli/commands/seed.ts +447 -0
- package/src/cli/commands/status.ts +73 -0
- package/src/cli/commands/types.ts +56 -0
- package/src/cli/commands/withdraw.ts +61 -0
- package/src/cli/formatters.ts +64 -0
- package/src/cli/index.ts +46 -0
- package/src/cli/shutdown.ts +38 -0
- package/src/config/defaults.ts +49 -0
- package/src/config/effective.test.ts +80 -0
- package/src/config/effective.ts +119 -0
- package/src/config/loader.test.ts +95 -0
- package/src/config/loader.ts +251 -0
- package/src/config/types.ts +139 -0
- package/src/config/validation.ts +78 -0
- package/src/env/load-env.ts +20 -0
- package/src/plugins/loader.ts +96 -0
- package/src/plugins/manager.ts +66 -0
- package/src/plugins/registry.ts +45 -0
- package/src/proxy/buyer-proxy.ts +604 -0
- package/src/status/node-status.ts +105 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createDefaultConfig } from '../../config/defaults.js';
|
|
4
|
+
import { resolveEffectiveBuyerConfig } from '../../config/effective.js';
|
|
5
|
+
import {
|
|
6
|
+
buildBuyerRuntimeOverridesFromFlags,
|
|
7
|
+
buildBuyerBootstrapEntries,
|
|
8
|
+
buildRouterRuntimeEnvFromBuyerConfig,
|
|
9
|
+
} from './connect.js';
|
|
10
|
+
|
|
11
|
+
test('connect runtime overrides are runtime-only and win over env/config', () => {
|
|
12
|
+
const config = createDefaultConfig();
|
|
13
|
+
config.buyer.proxyPort = 7777;
|
|
14
|
+
config.buyer.maxPricing.defaults.inputUsdPerMillion = 50;
|
|
15
|
+
config.buyer.maxPricing.defaults.outputUsdPerMillion = 60;
|
|
16
|
+
const beforeResolution = JSON.parse(JSON.stringify(config));
|
|
17
|
+
|
|
18
|
+
const env = {
|
|
19
|
+
ANTSEED_BUYER_MAX_INPUT_USD_PER_MILLION: '70',
|
|
20
|
+
ANTSEED_BUYER_MAX_OUTPUT_USD_PER_MILLION: '80',
|
|
21
|
+
} as NodeJS.ProcessEnv;
|
|
22
|
+
|
|
23
|
+
const overrides = buildBuyerRuntimeOverridesFromFlags({
|
|
24
|
+
port: 9000,
|
|
25
|
+
maxInputUsdPerMillion: 90,
|
|
26
|
+
maxOutputUsdPerMillion: 95,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const effective = resolveEffectiveBuyerConfig({
|
|
30
|
+
config,
|
|
31
|
+
env,
|
|
32
|
+
buyerOverrides: overrides,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
assert.equal(effective.proxyPort, 9000);
|
|
36
|
+
assert.equal(effective.maxPricing.defaults.inputUsdPerMillion, 90);
|
|
37
|
+
assert.equal(effective.maxPricing.defaults.outputUsdPerMillion, 95);
|
|
38
|
+
assert.deepEqual(config, beforeResolution);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('connect maps effective buyer config into router runtime env keys', () => {
|
|
42
|
+
const config = createDefaultConfig();
|
|
43
|
+
config.buyer.minPeerReputation = 72;
|
|
44
|
+
config.buyer.preferredProviders = ['anthropic', 'openai'];
|
|
45
|
+
config.buyer.maxPricing.defaults.inputUsdPerMillion = 21;
|
|
46
|
+
config.buyer.maxPricing.defaults.outputUsdPerMillion = 63;
|
|
47
|
+
|
|
48
|
+
const runtimeEnv = buildRouterRuntimeEnvFromBuyerConfig(config.buyer);
|
|
49
|
+
assert.equal(runtimeEnv['ANTSEED_MIN_REPUTATION'], '72');
|
|
50
|
+
assert.equal(runtimeEnv['ANTSEED_PREFERRED_PROVIDERS'], 'anthropic,openai');
|
|
51
|
+
|
|
52
|
+
const parsed = JSON.parse(runtimeEnv['ANTSEED_MAX_PRICING_JSON'] ?? '{}') as {
|
|
53
|
+
defaults?: { inputUsdPerMillion?: number; outputUsdPerMillion?: number };
|
|
54
|
+
};
|
|
55
|
+
assert.equal(parsed.defaults?.inputUsdPerMillion, 21);
|
|
56
|
+
assert.equal(parsed.defaults?.outputUsdPerMillion, 63);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('connect bootstrap entries use official nodes when config is empty and include local seeder first', () => {
|
|
60
|
+
const entries = buildBuyerBootstrapEntries([], 6881);
|
|
61
|
+
assert.equal(entries[0], '127.0.0.1:6881');
|
|
62
|
+
assert.ok(entries.length > 1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('connect bootstrap entries respect explicit configured nodes', () => {
|
|
66
|
+
const entries = buildBuyerBootstrapEntries(['10.0.0.2:6881'], 6889);
|
|
67
|
+
assert.equal(entries[0], '127.0.0.1:6889');
|
|
68
|
+
assert.deepEqual(entries.slice(1), ['10.0.0.2:6881']);
|
|
69
|
+
});
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import ora from 'ora'
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { getGlobalOptions } from './types.js'
|
|
8
|
+
import { loadConfig } from '../../config/loader.js'
|
|
9
|
+
import { AntseedNode, BaseEscrowClient, loadOrCreateIdentity, identityToEvmAddress, getInstance } from '@antseed/node'
|
|
10
|
+
import type { NodePaymentsConfig } from '@antseed/node'
|
|
11
|
+
import { OFFICIAL_BOOTSTRAP_NODES, parseBootstrapList, toBootstrapConfig } from '@antseed/node/discovery'
|
|
12
|
+
import { setupShutdownHandler } from '../shutdown.js'
|
|
13
|
+
import { loadRouterPlugin, buildPluginConfig } from '../../plugins/loader.js'
|
|
14
|
+
import { BuyerProxy } from '../../proxy/buyer-proxy.js'
|
|
15
|
+
import { resolveEffectiveBuyerConfig, type BuyerRuntimeOverrides } from '../../config/effective.js'
|
|
16
|
+
import type { BuyerCLIConfig } from '../../config/types.js'
|
|
17
|
+
|
|
18
|
+
interface LocalSeederInfo {
|
|
19
|
+
dhtPort: number
|
|
20
|
+
signalingPort: number
|
|
21
|
+
pid: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildBuyerRuntimeOverridesFromFlags(options: {
|
|
25
|
+
port?: number
|
|
26
|
+
minPeerReputation?: number
|
|
27
|
+
preferredProviders?: string[]
|
|
28
|
+
maxInputUsdPerMillion?: number
|
|
29
|
+
maxOutputUsdPerMillion?: number
|
|
30
|
+
}): BuyerRuntimeOverrides {
|
|
31
|
+
const overrides: BuyerRuntimeOverrides = {}
|
|
32
|
+
if (options.port !== undefined) {
|
|
33
|
+
overrides.proxyPort = options.port
|
|
34
|
+
}
|
|
35
|
+
if (options.minPeerReputation !== undefined) {
|
|
36
|
+
overrides.minPeerReputation = options.minPeerReputation
|
|
37
|
+
}
|
|
38
|
+
if (options.preferredProviders && options.preferredProviders.length > 0) {
|
|
39
|
+
overrides.preferredProviders = options.preferredProviders
|
|
40
|
+
}
|
|
41
|
+
if (options.maxInputUsdPerMillion !== undefined) {
|
|
42
|
+
overrides.maxInputUsdPerMillion = options.maxInputUsdPerMillion
|
|
43
|
+
}
|
|
44
|
+
if (options.maxOutputUsdPerMillion !== undefined) {
|
|
45
|
+
overrides.maxOutputUsdPerMillion = options.maxOutputUsdPerMillion
|
|
46
|
+
}
|
|
47
|
+
return overrides
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildRouterRuntimeEnvFromBuyerConfig(buyerConfig: BuyerCLIConfig): Record<string, string> {
|
|
51
|
+
const runtimeEnv: Record<string, string> = {
|
|
52
|
+
ANTSEED_MIN_REPUTATION: String(buyerConfig.minPeerReputation),
|
|
53
|
+
ANTSEED_MAX_PRICING_JSON: JSON.stringify(buyerConfig.maxPricing),
|
|
54
|
+
}
|
|
55
|
+
if (buyerConfig.preferredProviders.length > 0) {
|
|
56
|
+
runtimeEnv['ANTSEED_PREFERRED_PROVIDERS'] = buyerConfig.preferredProviders.join(',')
|
|
57
|
+
}
|
|
58
|
+
return runtimeEnv
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildBuyerBootstrapEntries(
|
|
62
|
+
configuredBootstrapNodes: string[] | undefined,
|
|
63
|
+
localSeederDhtPort?: number,
|
|
64
|
+
): string[] {
|
|
65
|
+
const configured = Array.isArray(configuredBootstrapNodes)
|
|
66
|
+
? configuredBootstrapNodes.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
|
67
|
+
: []
|
|
68
|
+
|
|
69
|
+
// Respect explicit user bootstrap config. If none is provided, keep default public bootstrap nodes.
|
|
70
|
+
const baseEntries = configured.length > 0
|
|
71
|
+
? configured
|
|
72
|
+
: OFFICIAL_BOOTSTRAP_NODES.map((node) => `${node.host}:${node.port}`)
|
|
73
|
+
|
|
74
|
+
const entries = [...baseEntries]
|
|
75
|
+
|
|
76
|
+
if (Number.isFinite(localSeederDhtPort) && (localSeederDhtPort ?? 0) > 0) {
|
|
77
|
+
const localBootstrap = `127.0.0.1:${Math.floor(localSeederDhtPort as number)}`
|
|
78
|
+
if (!entries.includes(localBootstrap)) {
|
|
79
|
+
entries.unshift(localBootstrap)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return entries
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseOptionalBoolEnv(value: string | undefined): boolean | null {
|
|
87
|
+
if (value === undefined) return null
|
|
88
|
+
const normalized = value.trim().toLowerCase()
|
|
89
|
+
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
|
|
90
|
+
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function isRpcReachable(rpcUrl: string, timeoutMs = 1500): Promise<boolean> {
|
|
95
|
+
const controller = new AbortController()
|
|
96
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch(rpcUrl, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'content-type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
jsonrpc: '2.0',
|
|
104
|
+
id: 1,
|
|
105
|
+
method: 'eth_chainId',
|
|
106
|
+
params: [],
|
|
107
|
+
}),
|
|
108
|
+
signal: controller.signal,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
if (!response.ok) return false
|
|
112
|
+
|
|
113
|
+
const payload = await response.json() as { result?: unknown }
|
|
114
|
+
return typeof payload.result === 'string' && payload.result.startsWith('0x')
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
} finally {
|
|
118
|
+
clearTimeout(timeout)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function getLocalSeederInfo(): Promise<LocalSeederInfo | null> {
|
|
123
|
+
try {
|
|
124
|
+
const stateFile = join(homedir(), '.antseed', 'daemon.state.json')
|
|
125
|
+
const raw = await readFile(stateFile, 'utf-8')
|
|
126
|
+
const state = JSON.parse(raw) as { state?: string; dhtPort?: number; signalingPort?: number; pid?: number }
|
|
127
|
+
if (state.state === 'seeding' && state.dhtPort && state.pid) {
|
|
128
|
+
try {
|
|
129
|
+
process.kill(state.pid, 0)
|
|
130
|
+
const signalingPort = state.signalingPort ?? state.dhtPort
|
|
131
|
+
return { dhtPort: state.dhtPort, signalingPort, pid: state.pid }
|
|
132
|
+
} catch {
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// No state file or unreadable
|
|
138
|
+
}
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function registerConnectCommand(program: Command): void {
|
|
143
|
+
program
|
|
144
|
+
.command('connect')
|
|
145
|
+
.description('Start the buyer proxy and connect to sellers on the P2P network')
|
|
146
|
+
.option('-p, --port <number>', 'local proxy port', (v) => parseInt(v, 10))
|
|
147
|
+
.option('--router <name>', 'router plugin name or npm package')
|
|
148
|
+
.option('--instance <id>', 'use a configured plugin instance by ID')
|
|
149
|
+
.option('--max-input-usd-per-million <number>', 'runtime-only max input pricing override in USD per 1M tokens', parseFloat)
|
|
150
|
+
.option('--max-output-usd-per-million <number>', 'runtime-only max output pricing override in USD per 1M tokens', parseFloat)
|
|
151
|
+
.action(async (options) => {
|
|
152
|
+
const globalOpts = getGlobalOptions(program)
|
|
153
|
+
const config = await loadConfig(globalOpts.config)
|
|
154
|
+
const runtimeOverrides = buildBuyerRuntimeOverridesFromFlags({
|
|
155
|
+
port: options.port as number | undefined,
|
|
156
|
+
maxInputUsdPerMillion: options.maxInputUsdPerMillion as number | undefined,
|
|
157
|
+
maxOutputUsdPerMillion: options.maxOutputUsdPerMillion as number | undefined,
|
|
158
|
+
})
|
|
159
|
+
const effectiveBuyerConfig = resolveEffectiveBuyerConfig({
|
|
160
|
+
config,
|
|
161
|
+
buyerOverrides: runtimeOverrides,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
let router
|
|
165
|
+
let toolHints: Array<{ name: string; envVar: string }> = []
|
|
166
|
+
|
|
167
|
+
if (options.instance) {
|
|
168
|
+
const configPath = join(homedir(), '.antseed', 'config.json')
|
|
169
|
+
const instance = await getInstance(configPath, options.instance)
|
|
170
|
+
if (!instance) {
|
|
171
|
+
console.error(chalk.red(`Instance "${options.instance}" not found.`))
|
|
172
|
+
process.exit(1)
|
|
173
|
+
}
|
|
174
|
+
if (instance.type !== 'router') {
|
|
175
|
+
console.error(chalk.red(`Instance "${options.instance}" is a ${instance.type}, not a router.`))
|
|
176
|
+
process.exit(1)
|
|
177
|
+
}
|
|
178
|
+
const spinner = ora(`Loading router plugin "${instance.package}"...`).start()
|
|
179
|
+
try {
|
|
180
|
+
const plugin = await loadRouterPlugin(instance.package)
|
|
181
|
+
const runtimeEnv = buildRouterRuntimeEnvFromBuyerConfig(effectiveBuyerConfig)
|
|
182
|
+
const pluginConfig = buildPluginConfig(plugin.configSchema ?? plugin.configKeys ?? [], runtimeEnv, instance.config as Record<string, string>)
|
|
183
|
+
router = await plugin.createRouter(pluginConfig)
|
|
184
|
+
spinner.succeed(chalk.green(`Router "${plugin.displayName}" loaded`))
|
|
185
|
+
toolHints = (plugin as any).TOOL_HINTS ?? []
|
|
186
|
+
} catch (err) {
|
|
187
|
+
spinner.fail(chalk.red(`Failed to load router: ${(err as Error).message}`))
|
|
188
|
+
process.exit(1)
|
|
189
|
+
}
|
|
190
|
+
} else if (options.router) {
|
|
191
|
+
const spinner = ora(`Loading router plugin "${options.router}"...`).start()
|
|
192
|
+
try {
|
|
193
|
+
const plugin = await loadRouterPlugin(options.router)
|
|
194
|
+
const runtimeEnv = buildRouterRuntimeEnvFromBuyerConfig(effectiveBuyerConfig)
|
|
195
|
+
const pluginConfig = buildPluginConfig(plugin.configSchema ?? plugin.configKeys ?? [], runtimeEnv)
|
|
196
|
+
router = await plugin.createRouter(pluginConfig)
|
|
197
|
+
spinner.succeed(chalk.green(`Router "${plugin.displayName}" loaded`))
|
|
198
|
+
toolHints = (plugin as any).TOOL_HINTS ?? []
|
|
199
|
+
} catch (err) {
|
|
200
|
+
spinner.fail(chalk.red(`Failed to load router: ${(err as Error).message}`))
|
|
201
|
+
process.exit(1)
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
console.error(chalk.red('Error: No router specified.'))
|
|
205
|
+
console.error(chalk.dim('Run: antseed connect --router <name> or antseed connect --instance <id>'))
|
|
206
|
+
process.exit(1)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Auto-discover local seeder
|
|
210
|
+
const seederInfo = await getLocalSeederInfo()
|
|
211
|
+
const allBootstrapEntries = buildBuyerBootstrapEntries(
|
|
212
|
+
config.network?.bootstrapNodes,
|
|
213
|
+
seederInfo?.dhtPort,
|
|
214
|
+
)
|
|
215
|
+
const bootstrapNodes = toBootstrapConfig(parseBootstrapList(allBootstrapEntries))
|
|
216
|
+
|
|
217
|
+
const nodeSpinner = ora('Connecting to P2P network...').start()
|
|
218
|
+
|
|
219
|
+
// Build payment config from CLI config if available and reachable
|
|
220
|
+
let paymentsConfig: NodePaymentsConfig | undefined
|
|
221
|
+
const settlementEnv = parseOptionalBoolEnv(process.env['ANTSEED_ENABLE_SETTLEMENT'])
|
|
222
|
+
const crypto = config.payments?.crypto
|
|
223
|
+
let settlementEnabled = settlementEnv ?? Boolean(crypto)
|
|
224
|
+
|
|
225
|
+
if (settlementEnabled && crypto && settlementEnv !== true) {
|
|
226
|
+
const rpcUp = await isRpcReachable(crypto.rpcUrl)
|
|
227
|
+
if (!rpcUp) {
|
|
228
|
+
settlementEnabled = false
|
|
229
|
+
console.log(chalk.yellow(`Payments disabled: RPC node unreachable at ${crypto.rpcUrl}`))
|
|
230
|
+
console.log(chalk.dim('Start your chain node or set ANTSEED_ENABLE_SETTLEMENT=true to force-enable payments.'))
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (settlementEnabled && crypto) {
|
|
235
|
+
paymentsConfig = {
|
|
236
|
+
enabled: true,
|
|
237
|
+
rpcUrl: crypto.rpcUrl,
|
|
238
|
+
contractAddress: crypto.escrowContractAddress,
|
|
239
|
+
usdcAddress: crypto.usdcContractAddress,
|
|
240
|
+
defaultEscrowAmountUSDC: crypto.defaultLockAmountUSDC
|
|
241
|
+
? String(Math.round(parseFloat(crypto.defaultLockAmountUSDC) * 1_000_000))
|
|
242
|
+
: '1000000',
|
|
243
|
+
platformFeeRate: config.payments.platformFeeRate,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
console.log(chalk.bold('Effective buyer settings:'))
|
|
248
|
+
console.log(
|
|
249
|
+
chalk.dim(
|
|
250
|
+
` preferred providers: ${effectiveBuyerConfig.preferredProviders.length > 0 ? effectiveBuyerConfig.preferredProviders.join(', ') : '(any)'}`
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
console.log(
|
|
254
|
+
chalk.dim(
|
|
255
|
+
` max pricing defaults (USD/1M): input=${effectiveBuyerConfig.maxPricing.defaults.inputUsdPerMillion}, output=${effectiveBuyerConfig.maxPricing.defaults.outputUsdPerMillion}`
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
console.log(chalk.dim(` min peer reputation: ${effectiveBuyerConfig.minPeerReputation}`))
|
|
259
|
+
console.log(chalk.dim(` proxy port: ${effectiveBuyerConfig.proxyPort}`))
|
|
260
|
+
console.log('')
|
|
261
|
+
|
|
262
|
+
const node = new AntseedNode({
|
|
263
|
+
role: 'buyer',
|
|
264
|
+
bootstrapNodes,
|
|
265
|
+
allowPrivateIPs: true,
|
|
266
|
+
dataDir: globalOpts.dataDir,
|
|
267
|
+
payments: paymentsConfig,
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
node.setRouter(router)
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
await node.start()
|
|
274
|
+
nodeSpinner.succeed(chalk.green('Connected to P2P network'))
|
|
275
|
+
} catch (err) {
|
|
276
|
+
nodeSpinner.fail(chalk.red(`Failed to connect: ${(err as Error).message}`))
|
|
277
|
+
process.exit(1)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Display available USDC balance only if payments are active
|
|
281
|
+
if (paymentsConfig?.enabled && config.payments?.crypto) {
|
|
282
|
+
try {
|
|
283
|
+
const identity = await loadOrCreateIdentity(globalOpts.dataDir)
|
|
284
|
+
const address = identityToEvmAddress(identity)
|
|
285
|
+
const escrowClient = new BaseEscrowClient({
|
|
286
|
+
rpcUrl: config.payments.crypto.rpcUrl,
|
|
287
|
+
contractAddress: config.payments.crypto.escrowContractAddress,
|
|
288
|
+
usdcAddress: config.payments.crypto.usdcContractAddress,
|
|
289
|
+
})
|
|
290
|
+
const account = await escrowClient.getBuyerAccount(address)
|
|
291
|
+
console.log(chalk.dim(`Wallet: ${address}`))
|
|
292
|
+
const availUsdc = Number(account.available) / 1_000_000
|
|
293
|
+
console.log(chalk.dim(`Escrow available: ${availUsdc.toFixed(6)} USDC`))
|
|
294
|
+
} catch {
|
|
295
|
+
// Non-fatal — chain may not be available
|
|
296
|
+
console.log(chalk.dim('Payment balance unavailable (chain not reachable)'))
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Start the local HTTP proxy
|
|
301
|
+
const proxyPort = effectiveBuyerConfig.proxyPort
|
|
302
|
+
const proxySpinner = ora(`Starting local proxy on port ${proxyPort}...`).start()
|
|
303
|
+
|
|
304
|
+
const proxy = new BuyerProxy({
|
|
305
|
+
port: proxyPort,
|
|
306
|
+
node,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
await proxy.start()
|
|
311
|
+
proxySpinner.succeed(chalk.green(`Proxy listening on http://localhost:${proxyPort}`))
|
|
312
|
+
} catch (err) {
|
|
313
|
+
proxySpinner.fail(chalk.red(`Failed to start proxy: ${(err as Error).message}`))
|
|
314
|
+
await node.stop()
|
|
315
|
+
process.exit(1)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const proxyUrl = `http://localhost:${proxyPort}`
|
|
319
|
+
console.log('')
|
|
320
|
+
|
|
321
|
+
if (toolHints.length > 0) {
|
|
322
|
+
console.log(chalk.bold('Configure your tools:'))
|
|
323
|
+
for (const hint of toolHints) {
|
|
324
|
+
console.log(` export ${hint.envVar}=${proxyUrl} # ${hint.name}`)
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
console.log(chalk.bold('Configure your CLI tools:'))
|
|
328
|
+
console.log(` export ANTHROPIC_BASE_URL=${proxyUrl}`)
|
|
329
|
+
console.log(` export OPENAI_BASE_URL=${proxyUrl}`)
|
|
330
|
+
}
|
|
331
|
+
console.log('')
|
|
332
|
+
console.log(chalk.dim('Enable debug logs: export ANTSEED_DEBUG=1'))
|
|
333
|
+
console.log('')
|
|
334
|
+
|
|
335
|
+
setupShutdownHandler(async () => {
|
|
336
|
+
nodeSpinner.start('Shutting down...')
|
|
337
|
+
await proxy.stop()
|
|
338
|
+
await node.stop()
|
|
339
|
+
nodeSpinner.succeed('Disconnected. All sessions finalized.')
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import open from 'open';
|
|
5
|
+
import { getGlobalOptions } from './types.js';
|
|
6
|
+
import { loadConfig } from '../../config/loader.js';
|
|
7
|
+
import { createDashboardServer } from 'antseed-dashboard';
|
|
8
|
+
import type { DashboardServer } from 'antseed-dashboard';
|
|
9
|
+
import { setupShutdownHandler } from '../shutdown.js';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_DASHBOARD_PORT = 3117;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Register the `antseed dashboard` command on the Commander program.
|
|
15
|
+
*/
|
|
16
|
+
export function registerDashboardCommand(program: Command): void {
|
|
17
|
+
program
|
|
18
|
+
.command('dashboard')
|
|
19
|
+
.description('Start the web dashboard for monitoring and configuration')
|
|
20
|
+
.option('-p, --port <number>', 'dashboard port', (v) => parseInt(v, 10), DEFAULT_DASHBOARD_PORT)
|
|
21
|
+
.option('--no-open', 'do not open browser automatically')
|
|
22
|
+
.action(async (options) => {
|
|
23
|
+
const globalOpts = getGlobalOptions(program);
|
|
24
|
+
const config = await loadConfig(globalOpts.config);
|
|
25
|
+
|
|
26
|
+
const port = (options.port ?? DEFAULT_DASHBOARD_PORT) as number;
|
|
27
|
+
const spinner = ora('Starting dashboard server...').start();
|
|
28
|
+
|
|
29
|
+
let server: DashboardServer;
|
|
30
|
+
try {
|
|
31
|
+
server = await createDashboardServer(
|
|
32
|
+
config as unknown as Parameters<typeof createDashboardServer>[0],
|
|
33
|
+
port,
|
|
34
|
+
{ configPath: globalOpts.config }
|
|
35
|
+
);
|
|
36
|
+
await server.start();
|
|
37
|
+
spinner.succeed(chalk.green('Dashboard running'));
|
|
38
|
+
} catch (err) {
|
|
39
|
+
spinner.fail(chalk.red(`Failed to start dashboard: ${(err as Error).message}`));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const url = `http://localhost:${port}`;
|
|
44
|
+
console.log('');
|
|
45
|
+
console.log(chalk.bold('Dashboard: ') + chalk.cyan(url));
|
|
46
|
+
console.log(chalk.dim('Press Ctrl+C to stop'));
|
|
47
|
+
console.log('');
|
|
48
|
+
|
|
49
|
+
if (options.open !== false) {
|
|
50
|
+
await open(url);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setupShutdownHandler(async () => {
|
|
54
|
+
spinner.start('Stopping dashboard server...');
|
|
55
|
+
await server.stop();
|
|
56
|
+
spinner.succeed('Dashboard stopped.');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { getGlobalOptions } from './types.js';
|
|
5
|
+
import { loadConfig } from '../../config/loader.js';
|
|
6
|
+
import {
|
|
7
|
+
loadOrCreateIdentity,
|
|
8
|
+
BaseEscrowClient,
|
|
9
|
+
identityToEvmWallet,
|
|
10
|
+
identityToEvmAddress,
|
|
11
|
+
} from '@antseed/node';
|
|
12
|
+
|
|
13
|
+
export function registerDepositCommand(program: Command): void {
|
|
14
|
+
program
|
|
15
|
+
.command('deposit <amount>')
|
|
16
|
+
.description('Deposit USDC into the escrow contract (amount in human-readable USDC, e.g. "5" = 5 USDC)')
|
|
17
|
+
.action(async (amount: string) => {
|
|
18
|
+
const globalOpts = getGlobalOptions(program);
|
|
19
|
+
const config = await loadConfig(globalOpts.config);
|
|
20
|
+
|
|
21
|
+
const payments = config.payments;
|
|
22
|
+
if (!payments?.crypto) {
|
|
23
|
+
console.error(chalk.red('Error: No crypto payment configuration found.'));
|
|
24
|
+
console.error(chalk.dim('Configure payments.crypto in your config file or run: antseed init'));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const amountFloat = parseFloat(amount);
|
|
29
|
+
if (isNaN(amountFloat) || amountFloat <= 0) {
|
|
30
|
+
console.error(chalk.red('Error: Amount must be a positive number.'));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Convert human-readable USDC to base units (6 decimals)
|
|
35
|
+
const amountBaseUnits = BigInt(Math.round(amountFloat * 1_000_000));
|
|
36
|
+
|
|
37
|
+
const identity = await loadOrCreateIdentity(globalOpts.dataDir);
|
|
38
|
+
const wallet = identityToEvmWallet(identity);
|
|
39
|
+
const address = identityToEvmAddress(identity);
|
|
40
|
+
|
|
41
|
+
const escrowClient = new BaseEscrowClient({
|
|
42
|
+
rpcUrl: payments.crypto.rpcUrl,
|
|
43
|
+
contractAddress: payments.crypto.escrowContractAddress,
|
|
44
|
+
usdcAddress: payments.crypto.usdcContractAddress,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
console.log(chalk.dim(`Wallet: ${address}`));
|
|
48
|
+
console.log(chalk.dim(`Amount: ${amountFloat} USDC (${amountBaseUnits} base units)`));
|
|
49
|
+
|
|
50
|
+
const spinner = ora('Depositing USDC into escrow...').start();
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const txHash = await escrowClient.deposit(wallet, amountBaseUnits);
|
|
54
|
+
spinner.succeed(chalk.green(`Deposited ${amountFloat} USDC into escrow`));
|
|
55
|
+
console.log(chalk.dim(`Transaction: ${txHash}`));
|
|
56
|
+
} catch (err) {
|
|
57
|
+
spinner.fail(chalk.red(`Deposit failed: ${(err as Error).message}`));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { getGlobalOptions } from './types.js';
|
|
6
|
+
import { loadConfig } from '../../config/loader.js';
|
|
7
|
+
import { AntseedNode } from '@antseed/node';
|
|
8
|
+
import { DHTNode } from '@antseed/node/discovery';
|
|
9
|
+
import { toPeerId } from '@antseed/node';
|
|
10
|
+
import { parseBootstrapList, toBootstrapConfig } from '@antseed/node/discovery';
|
|
11
|
+
import { setupShutdownHandler } from '../shutdown.js';
|
|
12
|
+
|
|
13
|
+
export function registerDevCommand(program: Command): void {
|
|
14
|
+
program
|
|
15
|
+
.command('dev')
|
|
16
|
+
.description('Run seller + buyer locally for development and testing')
|
|
17
|
+
.option('-p, --port <number>', 'buyer proxy port', (v) => parseInt(v, 10))
|
|
18
|
+
.action(async (options) => {
|
|
19
|
+
const globalOpts = getGlobalOptions(program);
|
|
20
|
+
const config = await loadConfig(globalOpts.config);
|
|
21
|
+
|
|
22
|
+
if (options.port !== undefined) {
|
|
23
|
+
config.buyer.proxyPort = options.port as number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const spinner = ora('Starting local network...').start();
|
|
27
|
+
|
|
28
|
+
// 1. Create and start local bootstrap DHT node
|
|
29
|
+
const bootstrapPeerId = toPeerId(crypto.randomBytes(32).toString('hex'));
|
|
30
|
+
const bootstrapDht = new DHTNode({
|
|
31
|
+
peerId: bootstrapPeerId,
|
|
32
|
+
port: 0,
|
|
33
|
+
bootstrapNodes: [],
|
|
34
|
+
reannounceIntervalMs: 60_000,
|
|
35
|
+
operationTimeoutMs: 10_000,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await bootstrapDht.start();
|
|
40
|
+
} catch (err) {
|
|
41
|
+
spinner.fail(chalk.red(`Failed to start local DHT: ${(err as Error).message}`));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const bootstrapPort = bootstrapDht.getPort();
|
|
46
|
+
spinner.text = 'Starting seller node...';
|
|
47
|
+
|
|
48
|
+
// 2. Bootstrap config pointing at local node
|
|
49
|
+
const localBootstrap = toBootstrapConfig(parseBootstrapList([`127.0.0.1:${bootstrapPort}`]));
|
|
50
|
+
|
|
51
|
+
// 3. Start seller node
|
|
52
|
+
const seller = new AntseedNode({
|
|
53
|
+
role: 'seller',
|
|
54
|
+
bootstrapNodes: localBootstrap,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await seller.start();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
await bootstrapDht.stop();
|
|
61
|
+
spinner.fail(chalk.red(`Failed to start seller: ${(err as Error).message}`));
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
spinner.text = 'Starting buyer node...';
|
|
66
|
+
|
|
67
|
+
// 4. Start buyer node
|
|
68
|
+
const buyer = new AntseedNode({
|
|
69
|
+
role: 'buyer',
|
|
70
|
+
bootstrapNodes: localBootstrap,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await buyer.start();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
await seller.stop();
|
|
77
|
+
await bootstrapDht.stop();
|
|
78
|
+
spinner.fail(chalk.red(`Failed to start buyer proxy: ${(err as Error).message}`));
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
spinner.succeed(chalk.green('Dev mode running (local network)'));
|
|
83
|
+
|
|
84
|
+
const proxyUrl = `http://localhost:${config.buyer.proxyPort}`;
|
|
85
|
+
|
|
86
|
+
console.log('');
|
|
87
|
+
console.log(chalk.bold('Proxy running at: ') + chalk.cyan(proxyUrl));
|
|
88
|
+
console.log(chalk.dim(`Local DHT bootstrap on port ${bootstrapPort}`));
|
|
89
|
+
console.log('');
|
|
90
|
+
console.log(chalk.bold('Configure your CLI tools:'));
|
|
91
|
+
console.log(chalk.dim(' # Anthropic (Claude Code)'));
|
|
92
|
+
console.log(` export ANTHROPIC_BASE_URL=${proxyUrl}`);
|
|
93
|
+
console.log(chalk.dim(' # OpenAI (Codex)'));
|
|
94
|
+
console.log(` export OPENAI_BASE_URL=${proxyUrl}`);
|
|
95
|
+
console.log(chalk.dim(' # Google'));
|
|
96
|
+
console.log(` export GOOGLE_API_BASE_URL=${proxyUrl}`);
|
|
97
|
+
console.log('');
|
|
98
|
+
|
|
99
|
+
setupShutdownHandler(async () => {
|
|
100
|
+
spinner.start('Shutting down dev environment...');
|
|
101
|
+
await buyer.stop();
|
|
102
|
+
await seller.stop();
|
|
103
|
+
await bootstrapDht.stop();
|
|
104
|
+
spinner.succeed('Dev environment stopped.');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|