@consensus-tools/consensus-tools 0.1.0 → 0.1.2
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/README.md +17 -14
- package/bin/consensus-tools.js +34 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +11 -6
- package/src/cli.ts +214 -109
- package/src/cliConfig.ts +97 -0
- package/src/initWizard.ts +236 -0
- package/src/standalone.ts +409 -0
- package/src/testing/consensusTestRunner.ts +251 -0
- /package/{LICENSE → LICENSE.txt} +0 -0
package/src/cliConfig.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export type ConsensusCliConfig = {
|
|
6
|
+
agentId?: string;
|
|
7
|
+
activeBoard: 'local' | 'remote';
|
|
8
|
+
boards: {
|
|
9
|
+
local: { type: 'local'; root: string; jobsPath: string; ledgerPath: string };
|
|
10
|
+
remote: { type: 'remote'; url: string; boardId: string; auth: { type: 'apiKey'; apiKeyEnv: string } };
|
|
11
|
+
};
|
|
12
|
+
defaults: { policy: string; reward: number; stake: number; leaseSeconds: number };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const defaultConsensusCliConfig: ConsensusCliConfig = {
|
|
16
|
+
activeBoard: 'remote',
|
|
17
|
+
boards: {
|
|
18
|
+
local: {
|
|
19
|
+
type: 'local',
|
|
20
|
+
root: '~/.openclaw/workplace/consensus-board',
|
|
21
|
+
jobsPath: 'jobs',
|
|
22
|
+
ledgerPath: 'ledger.json'
|
|
23
|
+
},
|
|
24
|
+
remote: {
|
|
25
|
+
type: 'remote',
|
|
26
|
+
url: 'https://api.consensus.tools',
|
|
27
|
+
boardId: 'board_all',
|
|
28
|
+
auth: { type: 'apiKey', apiKeyEnv: 'CONSENSUS_API_KEY' }
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
defaults: {
|
|
32
|
+
policy: 'HIGHEST_CONFIDENCE_SINGLE',
|
|
33
|
+
reward: 8,
|
|
34
|
+
stake: 4,
|
|
35
|
+
leaseSeconds: 180
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function expandHome(input: string): string {
|
|
40
|
+
if (!input.startsWith('~')) return input;
|
|
41
|
+
return path.join(os.homedir(), input.slice(1));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveCliConfigPath(cwd: string = process.cwd()): string {
|
|
45
|
+
const envPath = process.env.CONSENSUS_CONFIG;
|
|
46
|
+
if (envPath) return expandHome(envPath);
|
|
47
|
+
|
|
48
|
+
const local = path.join(cwd, '.consensus', 'config.json');
|
|
49
|
+
if (existsSync(local)) return local;
|
|
50
|
+
|
|
51
|
+
return path.join(os.homedir(), '.consensus', 'config.json');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function loadCliConfig(cwd: string = process.cwd()): Promise<ConsensusCliConfig> {
|
|
55
|
+
const filePath = resolveCliConfigPath(cwd);
|
|
56
|
+
try {
|
|
57
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
58
|
+
return JSON.parse(raw) as ConsensusCliConfig;
|
|
59
|
+
} catch {
|
|
60
|
+
return JSON.parse(JSON.stringify(defaultConsensusCliConfig)) as ConsensusCliConfig;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function saveCliConfig(config: ConsensusCliConfig, cwd: string = process.cwd()): Promise<void> {
|
|
65
|
+
const filePath = resolveCliConfigPath(cwd);
|
|
66
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
67
|
+
await fs.writeFile(filePath, JSON.stringify(config, null, 2), 'utf8');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getConfigValue(config: any, key: string): any {
|
|
71
|
+
return key.split('.').reduce((acc, part) => (acc ? acc[part] : undefined), config);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function setConfigValue(config: any, key: string, value: any): void {
|
|
75
|
+
const parts = key.split('.');
|
|
76
|
+
let cur = config as any;
|
|
77
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
78
|
+
if (!cur[parts[i]]) cur[parts[i]] = {};
|
|
79
|
+
cur = cur[parts[i]];
|
|
80
|
+
}
|
|
81
|
+
cur[parts[parts.length - 1]] = value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parseValue(input: string): any {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(input);
|
|
87
|
+
} catch {
|
|
88
|
+
return input;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveRemoteBaseUrl(remoteUrl: string, boardId: string): string {
|
|
93
|
+
const trimmed = remoteUrl.replace(/\/$/, '');
|
|
94
|
+
if (trimmed.includes('/v1/boards/')) return trimmed;
|
|
95
|
+
return `${trimmed}/v1/boards/${boardId}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { createInterface, type Interface } from 'node:readline/promises';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import type { ConsensusCliConfig } from './cliConfig';
|
|
5
|
+
import { defaultConsensusCliConfig } from './cliConfig';
|
|
6
|
+
|
|
7
|
+
export type InitWizardResult = {
|
|
8
|
+
config: ConsensusCliConfig;
|
|
9
|
+
// Values are already shell-escaped for `export KEY=<value>` lines.
|
|
10
|
+
env?: Record<string, string>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const POLICY_CHOICES = [
|
|
14
|
+
'HIGHEST_CONFIDENCE_SINGLE',
|
|
15
|
+
'TOP_K_SPLIT',
|
|
16
|
+
'OWNER_PICK',
|
|
17
|
+
'SINGLE_WINNER',
|
|
18
|
+
'MAJORITY_VOTE',
|
|
19
|
+
'WEIGHTED_VOTE_SIMPLE',
|
|
20
|
+
'WEIGHTED_REPUTATION',
|
|
21
|
+
'TRUSTED_ARBITER'
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
function defaultAgentId(): string {
|
|
25
|
+
const fromEnv = process.env.CONSENSUS_AGENT_ID;
|
|
26
|
+
if (fromEnv) return fromEnv;
|
|
27
|
+
let user = 'cli';
|
|
28
|
+
try {
|
|
29
|
+
const u = os.userInfo();
|
|
30
|
+
if (u?.username) user = u.username;
|
|
31
|
+
} catch {
|
|
32
|
+
// ignore
|
|
33
|
+
}
|
|
34
|
+
return `${user}@${os.hostname()}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function shellEscape(value: string): string {
|
|
38
|
+
// Conservative quoting for bash/zsh.
|
|
39
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`')}"`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toNumber(value: string, fallback: number): number {
|
|
43
|
+
const n = Number(value);
|
|
44
|
+
return Number.isFinite(n) ? n : fallback;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toInt(value: string, fallback: number): number {
|
|
48
|
+
const n = Number.parseInt(value, 10);
|
|
49
|
+
return Number.isFinite(n) ? n : fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function promptLine(rl: Interface, message: string, fallback?: string): Promise<string> {
|
|
53
|
+
const suffix = fallback ? ` [${fallback}]` : '';
|
|
54
|
+
const answer = (await rl.question(`${message}${suffix}: `)).trim();
|
|
55
|
+
return answer || fallback || '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function promptConfirm(rl: Interface, message: string, fallback: boolean): Promise<boolean> {
|
|
59
|
+
const hint = fallback ? 'Y/n' : 'y/N';
|
|
60
|
+
const answer = (await rl.question(`${message} (${hint}): `)).trim().toLowerCase();
|
|
61
|
+
if (!answer) return fallback;
|
|
62
|
+
if (['y', 'yes'].includes(answer)) return true;
|
|
63
|
+
if (['n', 'no'].includes(answer)) return false;
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function promptSelect<T extends string>(
|
|
68
|
+
rl: Interface,
|
|
69
|
+
message: string,
|
|
70
|
+
choices: Array<{ value: T; label: string }>,
|
|
71
|
+
fallback: T
|
|
72
|
+
): Promise<T> {
|
|
73
|
+
process.stdout.write(`${message}\n`);
|
|
74
|
+
for (let i = 0; i < choices.length; i += 1) {
|
|
75
|
+
const c = choices[i];
|
|
76
|
+
process.stdout.write(` ${i + 1}) ${c.label}\n`);
|
|
77
|
+
}
|
|
78
|
+
const answer = (await rl.question(`Select [${choices.findIndex((c) => c.value === fallback) + 1}]: `)).trim();
|
|
79
|
+
if (!answer) return fallback;
|
|
80
|
+
const idx = Number.parseInt(answer, 10);
|
|
81
|
+
if (Number.isFinite(idx) && idx >= 1 && idx <= choices.length) {
|
|
82
|
+
return choices[idx - 1].value;
|
|
83
|
+
}
|
|
84
|
+
// Allow direct value entry.
|
|
85
|
+
const asValue = answer as T;
|
|
86
|
+
if (choices.some((c) => c.value === asValue)) return asValue;
|
|
87
|
+
return fallback;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function promptPassword(message: string): Promise<string> {
|
|
91
|
+
// Minimal masked input for TTY use. Falls back to visible input if raw mode is unavailable.
|
|
92
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY || typeof process.stdin.setRawMode !== 'function') {
|
|
93
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
94
|
+
try {
|
|
95
|
+
return (await rl.question(`${message} (input will be visible): `)).trim();
|
|
96
|
+
} finally {
|
|
97
|
+
rl.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return await new Promise<string>((resolve) => {
|
|
102
|
+
const stdin = process.stdin;
|
|
103
|
+
const stdout = process.stdout;
|
|
104
|
+
let value = '';
|
|
105
|
+
|
|
106
|
+
stdout.write(`${message}: `);
|
|
107
|
+
stdin.setRawMode(true);
|
|
108
|
+
stdin.resume();
|
|
109
|
+
|
|
110
|
+
const cleanup = () => {
|
|
111
|
+
stdin.setRawMode(false);
|
|
112
|
+
stdin.pause();
|
|
113
|
+
stdin.removeListener('data', onData);
|
|
114
|
+
stdout.write('\n');
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const onData = (buf: Buffer) => {
|
|
118
|
+
const s = buf.toString('utf8');
|
|
119
|
+
if (s === '\r' || s === '\n') {
|
|
120
|
+
cleanup();
|
|
121
|
+
resolve(value);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (s === '\u0003') {
|
|
125
|
+
// Ctrl-C
|
|
126
|
+
cleanup();
|
|
127
|
+
process.exit(130);
|
|
128
|
+
}
|
|
129
|
+
if (s === '\u007f') {
|
|
130
|
+
// backspace
|
|
131
|
+
if (value.length > 0) {
|
|
132
|
+
value = value.slice(0, -1);
|
|
133
|
+
stdout.write('\b \b');
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Ignore arrow keys / escape sequences.
|
|
138
|
+
if (s.startsWith('\u001b')) return;
|
|
139
|
+
value += s;
|
|
140
|
+
stdout.write('*');
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
stdin.on('data', onData);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function runInitWizard(rootDir: string): Promise<InitWizardResult> {
|
|
148
|
+
process.stdout.write(
|
|
149
|
+
[
|
|
150
|
+
'+---------------------------------+',
|
|
151
|
+
'| consensus-tools init wizard |',
|
|
152
|
+
'+---------------------------------+',
|
|
153
|
+
`workspace: ${rootDir}`,
|
|
154
|
+
''
|
|
155
|
+
].join('\n') + '\n'
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
159
|
+
try {
|
|
160
|
+
const mode = await promptSelect<'remote' | 'local'>(
|
|
161
|
+
rl,
|
|
162
|
+
'Where should consensus-tools run?',
|
|
163
|
+
[
|
|
164
|
+
{ value: 'remote', label: 'Hosted board (Recommended)' },
|
|
165
|
+
{ value: 'local', label: 'Local files (shell scripts; limited)' }
|
|
166
|
+
],
|
|
167
|
+
'remote'
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const cfg: ConsensusCliConfig = JSON.parse(JSON.stringify(defaultConsensusCliConfig));
|
|
171
|
+
cfg.activeBoard = mode;
|
|
172
|
+
|
|
173
|
+
cfg.agentId = await promptLine(rl, 'Default agent id (sent as agentId)', cfg.agentId || defaultAgentId());
|
|
174
|
+
|
|
175
|
+
cfg.defaults.policy = await promptSelect<string>(
|
|
176
|
+
rl,
|
|
177
|
+
'Default consensus policy',
|
|
178
|
+
POLICY_CHOICES.map((p) => ({ value: p, label: p })),
|
|
179
|
+
cfg.defaults.policy
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
cfg.defaults.reward = toNumber(await promptLine(rl, 'Default reward (credits)', String(cfg.defaults.reward)), cfg.defaults.reward);
|
|
183
|
+
cfg.defaults.stake = toNumber(await promptLine(rl, 'Default stake (credits)', String(cfg.defaults.stake)), cfg.defaults.stake);
|
|
184
|
+
cfg.defaults.leaseSeconds = toInt(
|
|
185
|
+
await promptLine(rl, 'Default leaseSeconds', String(cfg.defaults.leaseSeconds)),
|
|
186
|
+
cfg.defaults.leaseSeconds
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
if (mode === 'remote') {
|
|
190
|
+
cfg.boards.remote.url = await promptLine(rl, 'Hosted board URL (no trailing /v1/boards)', cfg.boards.remote.url);
|
|
191
|
+
cfg.boards.remote.boardId = await promptLine(rl, 'Board id', cfg.boards.remote.boardId);
|
|
192
|
+
cfg.boards.remote.auth.apiKeyEnv = await promptLine(
|
|
193
|
+
rl,
|
|
194
|
+
'Env var name for access token',
|
|
195
|
+
cfg.boards.remote.auth.apiKeyEnv
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const writeEnv = await promptConfirm(rl, 'Write .consensus/.env now? (do not commit it)', false);
|
|
199
|
+
if (!writeEnv) return { config: cfg };
|
|
200
|
+
|
|
201
|
+
const token = (await promptPassword(`Access token value (${cfg.boards.remote.auth.apiKeyEnv})`)).trim();
|
|
202
|
+
const env = buildEnv(cfg, token);
|
|
203
|
+
return { config: cfg, env };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
cfg.boards.local.root = await promptLine(rl, 'Local board root (used by generated shell scripts)', cfg.boards.local.root);
|
|
207
|
+
return { config: cfg };
|
|
208
|
+
} finally {
|
|
209
|
+
rl.close();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildEnv(cfg: ConsensusCliConfig, token: string): Record<string, string> {
|
|
214
|
+
const env: Record<string, string> = {};
|
|
215
|
+
env.CONSENSUS_MODE = cfg.activeBoard === 'remote' ? 'remote' : 'local';
|
|
216
|
+
env.CONSENSUS_AGENT_ID = cfg.agentId || defaultAgentId();
|
|
217
|
+
|
|
218
|
+
env.CONSENSUS_DEFAULT_POLICY = cfg.defaults.policy;
|
|
219
|
+
env.CONSENSUS_DEFAULT_REWARD = String(cfg.defaults.reward);
|
|
220
|
+
env.CONSENSUS_DEFAULT_STAKE = String(cfg.defaults.stake);
|
|
221
|
+
env.CONSENSUS_DEFAULT_LEASE_SECONDS = String(cfg.defaults.leaseSeconds);
|
|
222
|
+
|
|
223
|
+
if (cfg.activeBoard === 'remote') {
|
|
224
|
+
env.CONSENSUS_URL = cfg.boards.remote.url;
|
|
225
|
+
env.CONSENSUS_BOARD_ID = cfg.boards.remote.boardId;
|
|
226
|
+
env.CONSENSUS_API_KEY_ENV = cfg.boards.remote.auth.apiKeyEnv || 'CONSENSUS_API_KEY';
|
|
227
|
+
env[env.CONSENSUS_API_KEY_ENV] = token;
|
|
228
|
+
} else {
|
|
229
|
+
env.CONSENSUS_ROOT = cfg.boards.local.root;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Return a map with already-escaped values for direct file emission.
|
|
233
|
+
const escaped: Record<string, string> = {};
|
|
234
|
+
for (const [k, v] of Object.entries(env)) escaped[k] = shellEscape(v);
|
|
235
|
+
return escaped;
|
|
236
|
+
}
|