zyndo 0.1.2 → 0.1.4

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/dist/config.js CHANGED
@@ -48,6 +48,33 @@ export function loadSellerConfig(configPath) {
48
48
  }
49
49
  const harness = rawHarness;
50
50
  const claudeCodeBinary = optionalString(data, 'claude_code_binary');
51
+ // Validate that an explicit harness matches the binary it will spawn.
52
+ // Mismatch produces silent failure: e.g. harness=codex with binary=claude
53
+ // sends codex-style flags (--quiet, --approval-mode) to the Claude Code
54
+ // CLI, which rejects them and exits 1 on every task. Catch it at config
55
+ // load instead of at first-task time.
56
+ if (harness !== undefined && harness !== 'generic') {
57
+ const effectiveBinary = claudeCodeBinary ?? 'claude';
58
+ const binaryBaseName = effectiveBinary.split(/[\\/]/).pop()?.toLowerCase().replace(/\.(exe|cmd|bat|ps1)$/, '') ?? '';
59
+ let detected = 'generic';
60
+ if (binaryBaseName === 'codex' || binaryBaseName.startsWith('codex-'))
61
+ detected = 'codex';
62
+ else if (binaryBaseName === 'claude' || binaryBaseName.startsWith('claude-'))
63
+ detected = 'claude';
64
+ if (detected !== 'generic' && detected !== harness) {
65
+ throw new Error(`Config: harness="${harness}" does not match binary "${effectiveBinary}" (detected as "${detected}" harness). ` +
66
+ `Pick one: either set "harness: ${detected}" to match the binary, or set "claude_code_binary" to a ${harness} binary path.`);
67
+ }
68
+ // Bare names on Windows are fragile because spawn uses shell:true to
69
+ // resolve .cmd shims, and a bare name passed through cmd.exe gets
70
+ // re-tokenized in surprising ways when the cwd contains spaces. Warn
71
+ // (do not fail) so existing configs keep working but the user knows
72
+ // to re-run init for a fully-qualified path.
73
+ if (process.platform === 'win32' && !effectiveBinary.includes('\\') && !effectiveBinary.includes('/')) {
74
+ process.stderr.write(`[zyndo] Warning: claude_code_binary "${effectiveBinary}" is a bare name. ` +
75
+ `On Windows this is fragile. Re-run \`zyndo init\` to write a fully-qualified path.\n`);
76
+ }
77
+ }
51
78
  const claudeCodeTimeoutMs = typeof data.claude_code_timeout_ms === 'number' ? data.claude_code_timeout_ms : undefined;
52
79
  const claudeCodeMaxBudgetUsd = typeof data.claude_code_max_budget_usd === 'number' ? data.claude_code_max_budget_usd : undefined;
53
80
  const skills = requireArray(data, 'skills').map((s) => {
package/dist/index.js CHANGED
@@ -27,7 +27,7 @@ async function main() {
27
27
  }
28
28
  case 'init': {
29
29
  const { runInit } = await import('./init.js');
30
- await runInit();
30
+ await runInit(args.slice(1));
31
31
  break;
32
32
  }
33
33
  case 'mcp': {
@@ -47,7 +47,7 @@ async function main() {
47
47
  break;
48
48
  }
49
49
  case 'version':
50
- process.stdout.write('zyndo 0.1.0\n');
50
+ process.stdout.write('zyndo 0.1.4\n');
51
51
  break;
52
52
  case 'help':
53
53
  case undefined:
@@ -65,6 +65,7 @@ zyndo — AI agent daemon for the Zyndo marketplace
65
65
 
66
66
  Usage:
67
67
  zyndo init Interactive setup (create seller config)
68
+ zyndo init --non-interactive Non-interactive setup (use --name, --skill-id, etc.)
68
69
  zyndo serve [--config <path>] Start seller daemon
69
70
  zyndo mcp Start MCP server for buyers
70
71
  zyndo wallet [balance|topup|ledger] Manage wallet (opens dashboard)
package/dist/init.d.ts CHANGED
@@ -1 +1 @@
1
- export declare function runInit(): Promise<void>;
1
+ export declare function runInit(argv?: ReadonlyArray<string>): Promise<void>;
package/dist/init.js CHANGED
@@ -1,131 +1,447 @@
1
1
  // ---------------------------------------------------------------------------
2
- // zyndo init — interactive seller config setup
2
+ // zyndo init — interactive and non-interactive seller config setup
3
+ //
4
+ // Cross-platform: works on Windows (USERPROFILE, PATHEXT, .cmd shims) and
5
+ // POSIX. Resolves binaries via manual PATH walk so we never depend on the
6
+ // `which` command being present, and always writes a fully-qualified binary
7
+ // path into the config so the daemon never has to re-resolve at runtime.
3
8
  // ---------------------------------------------------------------------------
4
9
  import { createInterface } from 'node:readline';
5
10
  import { writeFileSync, mkdirSync, existsSync, accessSync, readdirSync, constants as fsConstants } from 'node:fs';
6
- import { resolve } from 'node:path';
7
- import { execFileSync } from 'node:child_process';
11
+ import { resolve, delimiter, sep, isAbsolute } from 'node:path';
12
+ import { spawnSync } from 'node:child_process';
8
13
  import { stringify as yamlStringify } from 'yaml';
9
14
  import { printBanner } from './banner.js';
10
15
  // ---------------------------------------------------------------------------
11
- // Readline helper
12
- // ---------------------------------------------------------------------------
13
- function createPrompt() {
14
- const rl = createInterface({ input: process.stdin, output: process.stdout });
15
- const ask = (question, defaultValue) => new Promise((resolve) => {
16
- const suffix = defaultValue !== undefined ? ` (${defaultValue})` : '';
17
- rl.question(` \x1b[36m?\x1b[0m ${question}${suffix}: `, (answer) => {
18
- resolve(answer.trim() || defaultValue || '');
19
- });
20
- });
21
- return { ask, close: () => rl.close() };
22
- }
23
- // ---------------------------------------------------------------------------
24
- // Harness detection
16
+ // Cross-platform binary resolution
25
17
  // ---------------------------------------------------------------------------
26
- function binaryExists(name) {
27
- // If it looks like an absolute path, check file exists and is executable
28
- if (name.startsWith('/')) {
18
+ /**
19
+ * Resolve a binary name to an absolute path. Returns undefined if not found.
20
+ *
21
+ * Honors PATHEXT on Windows so `codex` resolves to `codex.cmd`. Skips the
22
+ * external `which` command entirely (Linux-only) and walks PATH manually.
23
+ *
24
+ * If `name` already contains a path separator or is absolute, just verifies
25
+ * the file exists.
26
+ */
27
+ function resolveBinary(name) {
28
+ if (name === '')
29
+ return undefined;
30
+ if (isAbsolute(name) || name.includes(sep) || name.includes('/')) {
29
31
  try {
30
- accessSync(name, fsConstants.X_OK);
31
- return true;
32
+ accessSync(name, fsConstants.F_OK);
33
+ return name;
32
34
  }
33
35
  catch {
34
- return false;
36
+ return undefined;
35
37
  }
36
38
  }
37
- // Otherwise check PATH via `which` (using execFileSync to avoid shell injection)
38
- try {
39
- execFileSync('which', [name], { stdio: 'pipe' });
40
- return true;
41
- }
42
- catch {
43
- return false;
39
+ const pathDirs = (process.env.PATH ?? '').split(delimiter).filter((d) => d.length > 0);
40
+ const exts = process.platform === 'win32'
41
+ ? ['', ...(process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').map((e) => e.toLowerCase())]
42
+ : [''];
43
+ for (const dir of pathDirs) {
44
+ for (const ext of exts) {
45
+ const candidate = resolve(dir, `${name}${ext}`);
46
+ try {
47
+ accessSync(candidate, fsConstants.F_OK);
48
+ return candidate;
49
+ }
50
+ catch {
51
+ // try next
52
+ }
53
+ }
44
54
  }
55
+ return undefined;
45
56
  }
57
+ /**
58
+ * Try harder to find Claude Code by checking common VS Code / Cursor
59
+ * extension paths on macOS, Linux, and Windows.
60
+ */
46
61
  function findClaudeBinary() {
47
- // Check PATH first
48
- if (binaryExists('claude'))
49
- return 'claude';
50
- // Search common VS Code extension locations
51
- const home = process.env.HOME ?? '';
62
+ const onPath = resolveBinary('claude');
63
+ if (onPath !== undefined)
64
+ return onPath;
65
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
52
66
  if (home === '')
53
67
  return undefined;
54
- const vscodeDirs = [
68
+ const candidateDirs = [
55
69
  resolve(home, '.vscode', 'extensions'),
56
70
  resolve(home, '.vscode-insiders', 'extensions'),
57
71
  resolve(home, '.cursor', 'extensions')
58
72
  ];
59
- for (const extDir of vscodeDirs) {
73
+ // Windows VS Code per-user extension dirs
74
+ if (process.platform === 'win32') {
75
+ const appData = process.env.APPDATA ?? '';
76
+ const localAppData = process.env.LOCALAPPDATA ?? '';
77
+ if (appData !== '')
78
+ candidateDirs.push(resolve(appData, 'Code', 'User', 'extensions'));
79
+ if (localAppData !== '')
80
+ candidateDirs.push(resolve(localAppData, 'Programs', 'Microsoft VS Code', 'resources', 'app', 'extensions'));
81
+ }
82
+ for (const extDir of candidateDirs) {
60
83
  if (!existsSync(extDir))
61
84
  continue;
62
85
  try {
63
86
  const entries = readdirSync(extDir).filter((e) => e.startsWith('anthropic.claude-code-'));
64
- // Sort descending to prefer latest version
65
87
  entries.sort().reverse();
66
88
  for (const entry of entries) {
67
- const candidate = resolve(extDir, entry, 'resources', 'native-binary', 'claude');
68
- if (binaryExists(candidate))
89
+ const binName = process.platform === 'win32' ? 'claude.exe' : 'claude';
90
+ const candidate = resolve(extDir, entry, 'resources', 'native-binary', binName);
91
+ try {
92
+ accessSync(candidate, fsConstants.F_OK);
69
93
  return candidate;
94
+ }
95
+ catch {
96
+ // try next
97
+ }
70
98
  }
71
99
  }
72
100
  catch {
73
- // Permission denied or other FS error, skip
101
+ // permission denied or read failure, skip
74
102
  }
75
103
  }
76
104
  return undefined;
77
105
  }
106
+ /**
107
+ * Codex is usually installed via npm-global. On Windows that puts a
108
+ * `codex.cmd` shim under `%APPDATA%\npm`. Check that explicitly in case
109
+ * PATH does not include it (some shells launched without inheriting the
110
+ * user PATH).
111
+ */
112
+ function findCodexBinary() {
113
+ const onPath = resolveBinary('codex');
114
+ if (onPath !== undefined)
115
+ return onPath;
116
+ if (process.platform === 'win32') {
117
+ const appData = process.env.APPDATA ?? '';
118
+ if (appData !== '') {
119
+ const candidate = resolve(appData, 'npm', 'codex.cmd');
120
+ try {
121
+ accessSync(candidate, fsConstants.F_OK);
122
+ return candidate;
123
+ }
124
+ catch { /* not there */ }
125
+ }
126
+ }
127
+ return undefined;
128
+ }
129
+ /**
130
+ * Run `<binary> --version` to confirm the binary actually starts. Catches
131
+ * the silent-failure case where init writes a config that crashes at first
132
+ * task because the binary is broken or wrong.
133
+ */
134
+ function quoteWinArg(arg) {
135
+ if (arg === '')
136
+ return '""';
137
+ if (!/[\s"&|<>^()]/.test(arg))
138
+ return arg;
139
+ return `"${arg.replace(/"/g, '""')}"`;
140
+ }
141
+ function testBinary(binary) {
142
+ try {
143
+ // Same DEP0190 dance as the harness spawn: on Windows pass a quoted
144
+ // command string with shell:true (no args array) so .cmd shims work
145
+ // without triggering the deprecation. On POSIX spawn directly.
146
+ const result = process.platform === 'win32'
147
+ ? spawnSync(`${quoteWinArg(binary)} --version`, {
148
+ stdio: ['ignore', 'pipe', 'pipe'],
149
+ timeout: 5000,
150
+ shell: true,
151
+ encoding: 'utf-8'
152
+ })
153
+ : spawnSync(binary, ['--version'], {
154
+ stdio: ['ignore', 'pipe', 'pipe'],
155
+ timeout: 5000,
156
+ encoding: 'utf-8'
157
+ });
158
+ if (result.error)
159
+ return { ok: false, error: result.error.message };
160
+ if (result.status !== 0) {
161
+ return { ok: false, error: `exit code ${result.status}: ${(result.stderr ?? '').trim().slice(0, 200)}` };
162
+ }
163
+ const version = ((result.stdout ?? '') + (result.stderr ?? '')).trim().split('\n')[0] ?? '';
164
+ return { ok: true, version };
165
+ }
166
+ catch (err) {
167
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
168
+ }
169
+ }
78
170
  const HARNESS_OPTIONS = [
79
171
  { label: 'Claude Code', harness: 'claude', binary: 'claude', defaultModel: 'sonnet' },
80
- { label: 'Codex CLI', harness: 'codex', binary: 'codex', defaultModel: 'o4-mini' },
172
+ { label: 'Codex CLI', harness: 'codex', binary: 'codex', defaultModel: 'gpt-5-codex' },
81
173
  { label: 'Other / Custom binary', harness: 'generic', binary: '', defaultModel: 'default' }
82
174
  ];
175
+ function detectInstalledHarness() {
176
+ const claudeBin = findClaudeBinary();
177
+ if (claudeBin !== undefined)
178
+ return { harness: 'claude', binary: claudeBin, model: 'sonnet' };
179
+ const codexBin = findCodexBinary();
180
+ if (codexBin !== undefined)
181
+ return { harness: 'codex', binary: codexBin, model: 'gpt-5-codex' };
182
+ return undefined;
183
+ }
184
+ function parseFlags(argv) {
185
+ const flags = {};
186
+ for (let i = 0; i < argv.length; i++) {
187
+ const a = argv[i];
188
+ if (!a.startsWith('--'))
189
+ continue;
190
+ const key = a.slice(2);
191
+ const next = argv[i + 1];
192
+ if (next === undefined || next.startsWith('--')) {
193
+ flags[key] = true;
194
+ }
195
+ else {
196
+ flags[key] = next;
197
+ i++;
198
+ }
199
+ }
200
+ const harnessRaw = typeof flags.harness === 'string' ? flags.harness : undefined;
201
+ const harness = harnessRaw === 'claude' || harnessRaw === 'codex' || harnessRaw === 'generic' ? harnessRaw : undefined;
202
+ const skillPriceRaw = typeof flags['skill-price-cents'] === 'string' ? flags['skill-price-cents'] : undefined;
203
+ const skillPriceCents = skillPriceRaw !== undefined ? Number.parseInt(skillPriceRaw, 10) : undefined;
204
+ return {
205
+ nonInteractive: flags['non-interactive'] === true,
206
+ name: typeof flags.name === 'string' ? flags.name : undefined,
207
+ description: typeof flags.description === 'string' ? flags.description : undefined,
208
+ harness,
209
+ binary: typeof flags.binary === 'string' ? flags.binary : undefined,
210
+ model: typeof flags.model === 'string' ? flags.model : undefined,
211
+ workingDirectory: typeof flags['working-directory'] === 'string' ? flags['working-directory'] : undefined,
212
+ apiKey: typeof flags['api-key'] === 'string' ? flags['api-key'] : undefined,
213
+ bridgeUrl: typeof flags['bridge-url'] === 'string' ? flags['bridge-url'] : undefined,
214
+ skillId: typeof flags['skill-id'] === 'string' ? flags['skill-id'] : undefined,
215
+ skillName: typeof flags['skill-name'] === 'string' ? flags['skill-name'] : undefined,
216
+ skillDesc: typeof flags['skill-desc'] === 'string' ? flags['skill-desc'] : undefined,
217
+ skillPriceCents,
218
+ force: flags.force === true
219
+ };
220
+ }
83
221
  // ---------------------------------------------------------------------------
84
- // Main
222
+ // Readline helper
85
223
  // ---------------------------------------------------------------------------
86
- export async function runInit() {
224
+ function createPrompt() {
225
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
226
+ const ask = (question, defaultValue) => new Promise((resolveAns) => {
227
+ const suffix = defaultValue !== undefined ? ` (${defaultValue})` : '';
228
+ rl.question(` \x1b[36m?\x1b[0m ${question}${suffix}: `, (answer) => {
229
+ resolveAns(answer.trim() || defaultValue || '');
230
+ });
231
+ });
232
+ return { ask, close: () => rl.close() };
233
+ }
234
+ const YAML_HEADER = `# Zyndo seller configuration.
235
+ #
236
+ # Generated by \`zyndo init\`. Edit fields as needed and re-run \`zyndo serve\`.
237
+ # Get an API key from https://zyndo.ai/dashboard (API Keys tab).
238
+ #
239
+ # Field reference:
240
+ # name Display name shown to buyer agents on the marketplace.
241
+ # description One-line pitch shown next to your name in browse results.
242
+ # bridge_url Zyndo broker URL. Leave as default unless self-hosting.
243
+ # api_key Your Zyndo API key (zyndo_live_sk_...). Required to connect.
244
+ # provider Always 'claude-code' (the spawn-a-CLI provider).
245
+ # harness 'claude' | 'codex' | 'generic'. MUST match the binary below.
246
+ # claude_code_binary Absolute path to your harness binary. Set by \`zyndo init\`.
247
+ # model Model name passed to the harness (e.g. 'sonnet', 'gpt-5-codex').
248
+ # working_directory Where the seller can read/write files.
249
+ # max_concurrent_tasks How many tasks the daemon will run in parallel.
250
+ # skills What you offer. price_cents is REQUIRED ($0.10 floor).
251
+ # categories Free-form tags used in marketplace browse filters.
252
+ `;
253
+ function writeConfig(out, force) {
254
+ const configDir = resolve(process.cwd(), '.zyndo');
255
+ const configPath = resolve(configDir, 'seller.yaml');
256
+ const configObj = {
257
+ name: out.name,
258
+ description: out.description,
259
+ bridge_url: out.bridgeUrl,
260
+ api_key: out.apiKey,
261
+ provider: 'claude-code',
262
+ harness: out.harness,
263
+ claude_code_binary: out.binary,
264
+ model: out.model,
265
+ working_directory: out.workingDirectory,
266
+ max_concurrent_tasks: 1,
267
+ skills: [
268
+ {
269
+ id: out.skillId,
270
+ name: out.skillName,
271
+ description: out.skillDesc,
272
+ price_cents: out.skillPriceCents
273
+ }
274
+ ],
275
+ categories: [out.skillId.split('.')[0] || 'general']
276
+ };
277
+ const yaml = YAML_HEADER + '\n' + yamlStringify(configObj);
278
+ mkdirSync(configDir, { recursive: true });
279
+ if (existsSync(configPath) && !force) {
280
+ return { path: configPath, written: false };
281
+ }
282
+ writeFileSync(configPath, yaml, 'utf-8');
283
+ return { path: configPath, written: true };
284
+ }
285
+ // ---------------------------------------------------------------------------
286
+ // Non-interactive entry
287
+ // ---------------------------------------------------------------------------
288
+ function runNonInteractive(flags) {
289
+ const missing = [];
290
+ if (flags.name === undefined)
291
+ missing.push('--name');
292
+ if (flags.skillId === undefined)
293
+ missing.push('--skill-id');
294
+ if (flags.skillName === undefined)
295
+ missing.push('--skill-name');
296
+ if (flags.skillDesc === undefined)
297
+ missing.push('--skill-desc');
298
+ if (flags.skillPriceCents === undefined)
299
+ missing.push('--skill-price-cents');
300
+ if (flags.apiKey === undefined)
301
+ missing.push('--api-key');
302
+ if (missing.length > 0) {
303
+ throw new Error(`Non-interactive init requires: ${missing.join(', ')}\n\n` +
304
+ `Example:\n` +
305
+ ` zyndo init --non-interactive \\\n` +
306
+ ` --name "My Code Reviewer" \\\n` +
307
+ ` --skill-id coding.review.v1 \\\n` +
308
+ ` --skill-name "Code Review" \\\n` +
309
+ ` --skill-desc "Reviews TypeScript code for bugs" \\\n` +
310
+ ` --skill-price-cents 500 \\\n` +
311
+ ` --api-key zyndo_live_sk_xxxxx`);
312
+ }
313
+ if (!Number.isInteger(flags.skillPriceCents) || flags.skillPriceCents < 10) {
314
+ throw new Error(`--skill-price-cents must be an integer >= 10 ($0.10 minimum). Got: ${flags.skillPriceCents}`);
315
+ }
316
+ // Resolve harness + binary
317
+ let harness;
318
+ let binary;
319
+ let model;
320
+ if (flags.harness !== undefined && flags.binary !== undefined) {
321
+ harness = flags.harness;
322
+ const resolved = resolveBinary(flags.binary);
323
+ if (resolved === undefined) {
324
+ throw new Error(`Binary "${flags.binary}" not found on PATH. Pass an absolute path with --binary.`);
325
+ }
326
+ binary = resolved;
327
+ }
328
+ else if (flags.harness !== undefined) {
329
+ // Harness specified, binary not. Look up the default binary for that harness.
330
+ harness = flags.harness;
331
+ const lookup = harness === 'claude' ? findClaudeBinary() : harness === 'codex' ? findCodexBinary() : undefined;
332
+ if (lookup === undefined) {
333
+ throw new Error(`--harness ${harness} specified but no ${harness} binary found on PATH. Install it or pass --binary <path>.`);
334
+ }
335
+ binary = lookup;
336
+ }
337
+ else {
338
+ // Auto-detect
339
+ const detected = detectInstalledHarness();
340
+ if (detected === undefined) {
341
+ throw new Error('No AI harness found. Install Claude Code or Codex CLI, or pass --harness and --binary explicitly.');
342
+ }
343
+ harness = detected.harness;
344
+ binary = detected.binary;
345
+ }
346
+ // Pick model: explicit > harness default
347
+ if (flags.model !== undefined) {
348
+ model = flags.model;
349
+ }
350
+ else if (harness === 'claude') {
351
+ model = 'sonnet';
352
+ }
353
+ else if (harness === 'codex') {
354
+ model = 'gpt-5-codex';
355
+ }
356
+ else {
357
+ model = 'default';
358
+ }
359
+ // Functional test
360
+ const test = testBinary(binary);
361
+ if (!test.ok) {
362
+ throw new Error(`Binary at ${binary} failed --version test: ${test.error}`);
363
+ }
364
+ process.stdout.write(` \x1b[32mBinary OK:\x1b[0m ${binary} (${test.version})\n`);
365
+ const result = writeConfig({
366
+ name: flags.name,
367
+ description: flags.description ?? `AI agent offering ${flags.skillName} on the Zyndo marketplace`,
368
+ harness,
369
+ binary,
370
+ model,
371
+ workingDirectory: flags.workingDirectory ?? process.cwd(),
372
+ apiKey: flags.apiKey,
373
+ bridgeUrl: flags.bridgeUrl ?? 'https://bridge.zyndo.ai',
374
+ skillId: flags.skillId,
375
+ skillName: flags.skillName,
376
+ skillDesc: flags.skillDesc,
377
+ skillPriceCents: flags.skillPriceCents
378
+ }, flags.force === true);
379
+ if (!result.written) {
380
+ throw new Error(`${result.path} already exists. Pass --force to overwrite.`);
381
+ }
382
+ process.stdout.write(`\n \x1b[32mConfig saved to ${result.path}\x1b[0m\n`);
383
+ process.stdout.write(` Run: \x1b[1mzyndo serve\x1b[0m (from this directory)\n\n`);
384
+ }
385
+ // ---------------------------------------------------------------------------
386
+ // Interactive entry
387
+ // ---------------------------------------------------------------------------
388
+ async function runInteractive() {
87
389
  printBanner();
88
390
  process.stdout.write(' Welcome to the Zyndo marketplace agent CLI.\n');
89
391
  process.stdout.write(' This will create your seller agent config.\n\n');
90
392
  const { ask, close } = createPrompt();
91
393
  try {
92
- // Harness selection
394
+ // Auto-detect available harnesses up front so the menu shows what is found.
395
+ const detectedClaude = findClaudeBinary();
396
+ const detectedCodex = findCodexBinary();
397
+ const autoPick = detectedClaude !== undefined ? 0 : detectedCodex !== undefined ? 1 : -1;
93
398
  process.stdout.write(' \x1b[1mSelect your AI harness:\x1b[0m\n');
94
399
  for (let i = 0; i < HARNESS_OPTIONS.length; i++) {
95
400
  const opt = HARNESS_OPTIONS[i];
96
- const available = opt.binary !== '' && (binaryExists(opt.binary) || (opt.harness === 'claude' && findClaudeBinary() !== undefined));
97
- const tag = available ? '\x1b[32m (found)\x1b[0m' : '';
98
- process.stdout.write(` ${i + 1}) ${opt.label}${tag}\n`);
401
+ let tag = '';
402
+ if (opt.harness === 'claude' && detectedClaude !== undefined)
403
+ tag = '\x1b[32m (found)\x1b[0m';
404
+ if (opt.harness === 'codex' && detectedCodex !== undefined)
405
+ tag = '\x1b[32m (found)\x1b[0m';
406
+ const marker = i === autoPick ? ' \x1b[33m← default\x1b[0m' : '';
407
+ process.stdout.write(` ${i + 1}) ${opt.label}${tag}${marker}\n`);
99
408
  }
100
409
  process.stdout.write('\n');
101
- const choiceRaw = await ask('Choice [1-3]', '1');
102
- const parsed = parseInt(choiceRaw, 10);
410
+ const defaultChoice = autoPick === -1 ? '1' : String(autoPick + 1);
411
+ const choiceRaw = await ask('Choice [1-3]', defaultChoice);
412
+ const parsed = Number.parseInt(choiceRaw, 10);
103
413
  const choiceIdx = Number.isNaN(parsed) ? 0 : Math.max(0, Math.min(HARNESS_OPTIONS.length - 1, parsed - 1));
104
414
  const selected = HARNESS_OPTIONS[choiceIdx];
105
- let binary = selected.binary;
106
- if (selected.harness === 'generic') {
107
- binary = await ask('Binary path or command');
108
- if (binary === '') {
415
+ let binary = '';
416
+ if (selected.harness === 'claude') {
417
+ binary = detectedClaude ?? '';
418
+ }
419
+ else if (selected.harness === 'codex') {
420
+ binary = detectedCodex ?? '';
421
+ }
422
+ if (binary === '') {
423
+ const promptDefault = selected.binary !== '' ? selected.binary : '';
424
+ const entered = await ask('Binary path or command', promptDefault);
425
+ if (entered === '') {
109
426
  process.stdout.write(' No binary specified. Exiting.\n');
110
427
  return;
111
428
  }
112
- }
113
- // Auto-detect claude binary in VS Code extensions if not on PATH
114
- if (selected.harness === 'claude' && !binaryExists(binary)) {
115
- const found = findClaudeBinary();
116
- if (found !== undefined) {
117
- process.stdout.write(` \x1b[32mFound:\x1b[0m ${found}\n`);
118
- binary = found;
429
+ const resolved = resolveBinary(entered);
430
+ if (resolved === undefined) {
431
+ process.stdout.write(` \x1b[31mNot found:\x1b[0m "${entered}" is not on PATH and is not an absolute path.\n`);
432
+ return;
119
433
  }
434
+ binary = resolved;
120
435
  }
121
- // Verify binary exists
122
- if (binary !== '' && !binaryExists(binary)) {
123
- process.stdout.write(` \x1b[33mWarning:\x1b[0m "${binary}" not found on PATH. You may need to provide the full path.\n`);
124
- const fullPath = await ask('Full path to binary (or press Enter to keep as-is)', binary);
125
- if (fullPath !== '')
126
- binary = fullPath;
436
+ process.stdout.write(` \x1b[32mUsing:\x1b[0m ${binary}\n`);
437
+ // Functional test
438
+ const test = testBinary(binary);
439
+ if (!test.ok) {
440
+ process.stdout.write(` \x1b[31mBinary test failed:\x1b[0m ${test.error}\n`);
441
+ process.stdout.write(` Make sure ${selected.label} is installed correctly, then re-run \`zyndo init\`.\n`);
442
+ return;
127
443
  }
128
- process.stdout.write('\n');
444
+ process.stdout.write(` \x1b[32mVersion:\x1b[0m ${test.version}\n\n`);
129
445
  // Agent details
130
446
  const name = await ask('Agent name', 'My Zyndo Seller');
131
447
  const description = await ask('Description', 'AI agent offering services on the Zyndo marketplace');
@@ -139,41 +455,40 @@ export async function runInit() {
139
455
  if (!Number.isInteger(skillPriceCents) || skillPriceCents < 10) {
140
456
  throw new Error(`Skill price must be an integer of at least 10 cents ($0.10). Got: "${skillPriceRaw}"`);
141
457
  }
142
- const category = skillId.split('.')[0] || 'coding';
143
458
  process.stdout.write('\n');
144
459
  const model = await ask('Model', selected.defaultModel);
145
- const workingDir = await ask('Working directory', '.');
460
+ const workingDir = await ask('Working directory', process.cwd());
146
461
  process.stdout.write('\n \x1b[1mZyndo Marketplace\x1b[0m\n');
147
- const bridgeApiKey = await ask('Bridge API key (leave empty if not required)', '');
148
- // Build config object and serialize with yaml library
149
- // Save to .zyndo/ in cwd (per-folder seller identity)
150
- const configDir = resolve(process.cwd(), '.zyndo');
151
- const configPath = resolve(configDir, 'seller.yaml');
152
- const configObj = {
153
- name,
154
- description,
155
- provider: 'claude-code',
156
- harness: selected.harness,
157
- ...(binary !== selected.binary ? { claude_code_binary: binary } : {}),
158
- model,
159
- ...(bridgeApiKey !== '' ? { api_key: bridgeApiKey } : {}),
160
- working_directory: workingDir,
161
- max_concurrent_tasks: 1,
162
- skills: [{ id: skillId, name: skillName, description: skillDesc, price_cents: skillPriceCents }],
163
- categories: [category]
164
- };
165
- const yaml = yamlStringify(configObj);
166
- mkdirSync(configDir, { recursive: true });
462
+ process.stdout.write(' Get an API key at https://zyndo.ai/dashboard (API Keys tab)\n');
463
+ const bridgeApiKey = await ask('Bridge API key', '');
464
+ const bridgeUrl = await ask('Bridge URL', 'https://bridge.zyndo.ai');
465
+ // Confirm overwrite if exists
466
+ const configPath = resolve(process.cwd(), '.zyndo', 'seller.yaml');
467
+ let force = true;
167
468
  if (existsSync(configPath)) {
168
469
  const overwrite = await ask(`${configPath} already exists. Overwrite? [y/N]`, 'N');
169
470
  if (overwrite.toLowerCase() !== 'y') {
170
471
  process.stdout.write(' Aborted. Config not changed.\n');
171
472
  return;
172
473
  }
474
+ force = true;
173
475
  }
174
- writeFileSync(configPath, yaml, 'utf-8');
476
+ const result = writeConfig({
477
+ name,
478
+ description,
479
+ harness: selected.harness,
480
+ binary,
481
+ model,
482
+ workingDirectory: workingDir,
483
+ apiKey: bridgeApiKey,
484
+ bridgeUrl,
485
+ skillId,
486
+ skillName,
487
+ skillDesc,
488
+ skillPriceCents
489
+ }, force);
175
490
  process.stdout.write('\n');
176
- process.stdout.write(` \x1b[32mConfig saved to ${configPath}\x1b[0m\n`);
491
+ process.stdout.write(` \x1b[32mConfig saved to ${result.path}\x1b[0m\n`);
177
492
  process.stdout.write(` This seller lives in this folder. Add a CLAUDE.md for custom skills/rules.\n`);
178
493
  process.stdout.write(` Run: \x1b[1mzyndo serve\x1b[0m (from this directory)\n\n`);
179
494
  }
@@ -181,3 +496,14 @@ export async function runInit() {
181
496
  close();
182
497
  }
183
498
  }
499
+ // ---------------------------------------------------------------------------
500
+ // Main entry
501
+ // ---------------------------------------------------------------------------
502
+ export async function runInit(argv = []) {
503
+ const flags = parseFlags(argv);
504
+ if (flags.nonInteractive) {
505
+ runNonInteractive(flags);
506
+ return;
507
+ }
508
+ await runInteractive();
509
+ }
@@ -3,7 +3,10 @@
3
3
  // instead of making raw LLM API calls.
4
4
  // ---------------------------------------------------------------------------
5
5
  import { spawn } from 'node:child_process';
6
- import { basename } from 'node:path';
6
+ import { basename, join } from 'node:path';
7
+ import { tmpdir } from 'node:os';
8
+ import { randomBytes } from 'node:crypto';
9
+ import { readFileSync, unlinkSync } from 'node:fs';
7
10
  // ---------------------------------------------------------------------------
8
11
  // Harness detection and args
9
12
  // ---------------------------------------------------------------------------
@@ -15,7 +18,7 @@ export function detectHarness(binary) {
15
18
  return 'claude';
16
19
  return 'generic';
17
20
  }
18
- function buildHarnessArgs(harness, config, systemPrompt) {
21
+ function buildHarnessSpawn(harness, config, systemPrompt) {
19
22
  switch (harness) {
20
23
  case 'claude': {
21
24
  const args = [
@@ -30,16 +33,33 @@ function buildHarnessArgs(harness, config, systemPrompt) {
30
33
  if (config.claudeCodeMaxBudgetUsd !== undefined) {
31
34
  args.push('--max-budget-usd', String(config.claudeCodeMaxBudgetUsd));
32
35
  }
33
- return args;
36
+ return { args };
34
37
  }
35
- case 'codex':
36
- return [
37
- '--quiet',
38
- '--model', config.model,
39
- '--approval-mode', 'full-auto'
38
+ case 'codex': {
39
+ // Codex CLI 0.110+ uses `codex exec` for non-interactive runs.
40
+ // - `-` reads the prompt from stdin (we still pipe the seller prompt in).
41
+ // - `--dangerously-bypass-approvals-and-sandbox` is required because
42
+ // the seller daemon spawns codex headless and there is nobody to
43
+ // approve commands. The user explicitly opted in by running `zyndo serve`.
44
+ // - `-o <tempfile>` captures only the agent's last message, which is
45
+ // what we want as the deliverable text. Stdout includes progress
46
+ // events that would otherwise pollute parseOutput.
47
+ // - `--color never` strips ANSI escapes from any stderr we might log.
48
+ const outputFile = join(tmpdir(), `zyndo-codex-${Date.now()}-${randomBytes(4).toString('hex')}.txt`);
49
+ const args = [
50
+ 'exec',
51
+ '-',
52
+ '-m', config.model,
53
+ '--dangerously-bypass-approvals-and-sandbox',
54
+ '--skip-git-repo-check',
55
+ '-C', config.workingDirectory,
56
+ '--color', 'never',
57
+ '-o', outputFile
40
58
  ];
59
+ return { args, outputFile };
60
+ }
41
61
  case 'generic':
42
- return [];
62
+ return { args: [] };
43
63
  }
44
64
  }
45
65
  // ---------------------------------------------------------------------------
@@ -120,6 +140,49 @@ function parseOutput(raw, harness) {
120
140
  return { output: trimmed, paused: false };
121
141
  }
122
142
  // ---------------------------------------------------------------------------
143
+ // Cross-platform spawn helpers
144
+ // ---------------------------------------------------------------------------
145
+ /**
146
+ * Quote a single arg for cmd.exe. Wraps in double quotes if it contains
147
+ * any whitespace, quote, or shell metacharacter. Internal double quotes are
148
+ * doubled per cmd.exe convention. We only ever pass flag values and paths
149
+ * here (the prompt goes via stdin), so this is sufficient.
150
+ */
151
+ function quoteWinArg(arg) {
152
+ if (arg === '')
153
+ return '""';
154
+ if (!/[\s"&|<>^()]/.test(arg))
155
+ return arg;
156
+ return `"${arg.replace(/"/g, '""')}"`;
157
+ }
158
+ /**
159
+ * Spawn a child process. On Windows, builds a single shell-quoted command
160
+ * string and passes shell:true so .cmd / .bat shims (codex.cmd, claude.cmd)
161
+ * resolve correctly. On POSIX, spawns the binary directly with the args
162
+ * array — no shell, no quoting concerns.
163
+ *
164
+ * Avoids Node DEP0190 (the warning fires when you pass shell:true together
165
+ * with an args array, because Node concatenates without escaping).
166
+ */
167
+ function spawnHarness(binary, args, opts) {
168
+ if (process.platform === 'win32') {
169
+ const cmdLine = [binary, ...args].map(quoteWinArg).join(' ');
170
+ return spawn(cmdLine, {
171
+ cwd: opts.cwd,
172
+ signal: opts.signal,
173
+ stdio: ['pipe', 'pipe', 'pipe'],
174
+ env: opts.env,
175
+ shell: true
176
+ });
177
+ }
178
+ return spawn(binary, [...args], {
179
+ cwd: opts.cwd,
180
+ signal: opts.signal,
181
+ stdio: ['pipe', 'pipe', 'pipe'],
182
+ env: opts.env
183
+ });
184
+ }
185
+ // ---------------------------------------------------------------------------
123
186
  // Main entry point
124
187
  // ---------------------------------------------------------------------------
125
188
  const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
@@ -129,19 +192,24 @@ export async function runClaudeCodeTask(taskContext, config, logger) {
129
192
  const timeoutMs = config.claudeCodeTimeoutMs ?? DEFAULT_TIMEOUT_MS;
130
193
  const prompt = buildPrompt(taskContext, config);
131
194
  const systemPrompt = config.systemPrompt;
132
- const args = buildHarnessArgs(harness, config, systemPrompt);
195
+ const { args, outputFile } = buildHarnessSpawn(harness, config, systemPrompt);
133
196
  logger.info(`Spawning ${harness} harness: ${binary} ${args.join(' ')}`);
134
197
  return new Promise((resolve) => {
135
198
  const controller = new AbortController();
136
199
  const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
137
- const proc = spawn(binary, [...args], {
200
+ // Windows .cmd shims need shell:true to resolve. spawnHarness builds a
201
+ // pre-quoted command string for that case so we don't trigger DEP0190
202
+ // (passing shell:true + args array). On POSIX we spawn directly.
203
+ const proc = spawnHarness(binary, args, {
138
204
  cwd: config.workingDirectory,
139
205
  signal: controller.signal,
140
- stdio: ['pipe', 'pipe', 'pipe'],
141
206
  env: { ...process.env }
142
207
  });
143
208
  const stdoutChunks = [];
144
209
  const stderrChunks = [];
210
+ // stdio is always ['pipe','pipe','pipe'] in spawnHarness, so the
211
+ // streams are guaranteed non-null at runtime. The wrapped return type
212
+ // does not narrow that, so assert.
145
213
  proc.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
146
214
  proc.stderr.on('data', (chunk) => stderrChunks.push(chunk));
147
215
  proc.on('error', (err) => {
@@ -161,12 +229,28 @@ export async function runClaudeCodeTask(taskContext, config, logger) {
161
229
  if (stderr.length > 0) {
162
230
  logger.info(`Harness stderr: ${stderr.slice(0, 500)}`);
163
231
  }
164
- if (code !== 0 && stdout.length === 0) {
232
+ // Codex writes the agent's final message to a tempfile via `-o`.
233
+ // Read it back, then unlink. If the file is missing or unreadable
234
+ // (e.g. codex crashed before writing it), fall through to stdout.
235
+ let agentOutput = stdout;
236
+ if (outputFile !== undefined) {
237
+ try {
238
+ agentOutput = readFileSync(outputFile, 'utf-8');
239
+ }
240
+ catch {
241
+ // Tempfile not written. Keep stdout as a best-effort fallback.
242
+ }
243
+ try {
244
+ unlinkSync(outputFile);
245
+ }
246
+ catch { /* ignore */ }
247
+ }
248
+ if (code !== 0 && agentOutput.length === 0) {
165
249
  logger.error(`Harness exited with code ${code}`);
166
250
  resolve({ output: `Harness failed (exit ${code}): ${stderr.slice(0, 1000)}`, paused: false });
167
251
  return;
168
252
  }
169
- resolve(parseOutput(stdout, harness));
253
+ resolve(parseOutput(agentOutput, harness));
170
254
  });
171
255
  proc.stdin.write(prompt);
172
256
  proc.stdin.end();
@@ -0,0 +1,73 @@
1
+ # Zyndo seller configuration — annotated example.
2
+ #
3
+ # Copy this file to .zyndo/seller.yaml in the directory you want your seller
4
+ # to live in, then run `zyndo serve` from that directory. Most users should
5
+ # run `zyndo init` instead, which writes this file with the right defaults
6
+ # and a verified binary path.
7
+ #
8
+ # Get an API key at https://zyndo.ai/dashboard (API Keys tab).
9
+
10
+ # Display name shown to buyer agents on the marketplace.
11
+ name: My Code Reviewer
12
+
13
+ # One-line pitch shown next to your name in browse results.
14
+ description: Reviews TypeScript and Python code for bugs and improvements.
15
+
16
+ # Zyndo broker URL. Leave as default unless self-hosting the broker.
17
+ bridge_url: https://bridge.zyndo.ai
18
+
19
+ # Your Zyndo API key. REQUIRED. Format: zyndo_live_sk_xxxxx
20
+ api_key: zyndo_live_sk_REPLACE_ME
21
+
22
+ # Always 'claude-code' for now. This selects the spawn-a-CLI provider that
23
+ # routes tasks to your local Claude Code or Codex binary.
24
+ provider: claude-code
25
+
26
+ # Which CLI harness to use. MUST match the binary below.
27
+ # claude → Anthropic Claude Code (`claude` or `claude.exe`)
28
+ # codex → OpenAI Codex CLI (`codex` or `codex.cmd`)
29
+ # generic → any other CLI; you handle args via env / wrapper
30
+ harness: claude
31
+
32
+ # Absolute path to the harness binary. Always use a full path here.
33
+ # zyndo init writes this for you. Examples:
34
+ # macOS/Linux: /usr/local/bin/claude
35
+ # Windows: C:\Users\you\AppData\Roaming\npm\codex.cmd
36
+ claude_code_binary: /usr/local/bin/claude
37
+
38
+ # Model name passed to the harness.
39
+ # claude harness: sonnet | opus | haiku | claude-sonnet-4-6 | ...
40
+ # codex harness: gpt-5-codex | o4-mini | ...
41
+ model: sonnet
42
+
43
+ # The directory the seller can read/write inside. Use an absolute path
44
+ # for clarity. The seller daemon does NOT chroot — the harness binary's
45
+ # own permission model controls what is reachable.
46
+ working_directory: /Users/you/projects/zyndo-seller-workspace
47
+
48
+ # How many buyer tasks the daemon will process in parallel. Start at 1.
49
+ max_concurrent_tasks: 1
50
+
51
+ # What you offer. Each skill MUST have price_cents (minimum 10 = $0.10).
52
+ # The marketplace adds a 25% buyer markup on top of price_cents, so a
53
+ # price_cents of 500 ($5.00) is shown to buyers as $6.25.
54
+ skills:
55
+ - id: coding.review.v1
56
+ name: Code Review
57
+ description: Reviews TypeScript or Python code for bugs and improvements.
58
+ price_cents: 500
59
+
60
+ # Add more skills as needed. Each must be unique by id.
61
+ # - id: coding.test.v1
62
+ # name: Test Generation
63
+ # description: Writes Vitest unit tests for an existing module.
64
+ # price_cents: 800
65
+
66
+ # Free-form tags used in marketplace browse filters. The first segment of
67
+ # your skill id is a good default (e.g. coding.review.v1 → coding).
68
+ categories:
69
+ - coding
70
+
71
+ # Optional: how to spawn the harness.
72
+ # claude_code_timeout_ms: 600000 # 10 minutes per task (default)
73
+ # claude_code_max_budget_usd: 5.0 # cap per task (claude only)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zyndo",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "The agent-to-agent CLI tool for sellers in the Zyndo Marketplace",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,7 +10,8 @@
10
10
  "main": "dist/index.js",
11
11
  "types": "dist/index.d.ts",
12
12
  "files": [
13
- "dist"
13
+ "dist",
14
+ "examples"
14
15
  ],
15
16
  "exports": {
16
17
  ".": {