From d997d545cc330513ee7154b2c12d17fff7072aab Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 27 Jun 2026 23:15:23 +0300 Subject: [PATCH] =?UTF-8?q?feat(setup):=20multi-field=20JSON=20capture=20+?= =?UTF-8?q?=20validate-on-capture=20=E2=80=94=20derive=20Discord=20creds?= =?UTF-8?q?=20from=20one=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the stdout-capture branch (effect:fetch/external) so a `capture:a=.x,b=.owner.id` spec parses the command stdout as JSON and binds each var to its jq-style dot-path — one API call resolves several values. A bare `capture:var` (no =) still binds stdout as-is. effect:step's terminal- block field capture is untouched (distinguished by effect). A `validate:` on a run capture shape-guards each bound value; a mismatch (or unparseable JSON) throws → bounces to an agent (a command's output has no human to re-prompt). The linter validates the run-capture regex too. Rewrite add-discord Resolve to derive application_id + public_key + owner_handle from one `GET /oauth2/applications/@me` call, removing the two hand-pastes and the shape-only prompt validation. Resolved owner_handle / platform_id stay byte-identical to the prior hand-entered flow. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/add-discord/SKILL.md | 54 ++++++++--------- scripts/skill-apply.test.ts | 94 +++++++++++++++++++++++++++++ scripts/skill-apply.ts | 53 +++++++++++++++- scripts/skill-directives.test.ts | 24 ++++++++ scripts/skill-directives.ts | 13 ++++ 5 files changed, 208 insertions(+), 30 deletions(-) diff --git a/.claude/skills/add-discord/SKILL.md b/.claude/skills/add-discord/SKILL.md index 4f3f02055..ab836f358 100644 --- a/.claude/skills/add-discord/SKILL.md +++ b/.claude/skills/add-discord/SKILL.md @@ -70,27 +70,35 @@ server with you. Tell the user: ```nc:operator Create the Discord bot: 1. Go to https://discord.com/developers/applications → New Application. Name it (e.g. "NanoClaw Assistant"). -2. General Information → copy the Application ID and the Public Key. -3. Bot tab → Add Bot if needed → Reset Token, then copy the Bot Token (it's shown only once). -4. Bot tab → Privileged Gateway Intents → enable Message Content Intent. -5. OAuth2 → URL Generator → Scopes: bot; Bot Permissions: Send Messages, Read Message History, Add Reactions, Attach Files, Use Slash Commands. -6. Open the generated URL and invite the bot to a server you're also in (a personal server is fine) — the bot can only DM you once you share a server. +2. Bot tab → Add Bot if needed → Reset Token, then copy the Bot Token (it's shown only once). +3. Bot tab → Privileged Gateway Intents → enable Message Content Intent. +4. OAuth2 → URL Generator → Scopes: bot; Bot Permissions: Send Messages, Read Message History, Add Reactions, Attach Files, Use Slash Commands. +5. Open the generated URL and invite the bot to a server you're also in (a personal server is fine) — the bot can only DM you once you share a server. ``` -Collect the three values and store them — the adapter reads them from `.env` and -fails to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`. They go -to `.env` (set-if-absent — a value you've already filled in is never -overwritten) and sync to the container: +Paste the Bot Token (it's shown only once). You don't paste the Application ID or +the Public Key by hand — the bot's own application record carries both, so a +single call derives them from the token: ```nc:prompt bot_token secret validate:^[A-Za-z0-9._-]{50,}$ Paste the Bot Token — Bot tab. Click `Reset Token` if you need a new one. ``` -```nc:prompt application_id validate:^\d{17,20}$ -Paste the Application ID — General Information tab. -``` -```nc:prompt public_key validate:^[a-fA-F0-9]{64}$ -Paste the Public Key — General Information tab. + +Read the application's own record. `GET /oauth2/applications/@me` returns the +Application ID (`id`), the Public Key (`verify_key`), and your own account as the +app's owner (`owner.id`) — so the App ID, the Public Key, and your Discord user ID +all come from this one call instead of being copied by hand. A bad token fails +here, before the restart, rather than silently later: + +```nc:run capture:application_id=.id,public_key=.verify_key,owner_handle=.owner.id effect:fetch +curl -sf https://discord.com/api/v10/oauth2/applications/@me -H "Authorization: Bot {{bot_token}}" ``` + +Store the token and the two derived credentials — the adapter reads them from +`.env` and fails to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID` +(set-if-absent, so a value you've already filled in is never overwritten) — and +sync them to the container: + ```nc:env-set DISCORD_BOT_TOKEN={{bot_token}} DISCORD_APPLICATION_ID={{application_id}} @@ -110,21 +118,9 @@ bash setup/lib/restart.sh ## Resolve your DM channel -The agent talks to you in your direct-message channel with the bot. Resolve its -address so the owner-wiring step can target it. You'll need your Discord user ID: -open **Settings → Advanced → Developer Mode** on, then right-click your own -name and **Copy User ID** — it's 17–20 digits. - -```nc:prompt owner_handle validate:^\d{17,20}$ -Your Discord user ID (Settings → Advanced → Developer Mode on, then right-click your name → "Copy User ID"; 17–20 digits). -``` - -Confirm the bot token works and capture the bot identity — `/users/@me` returns -the bot user and fails here if the token is bad: - -```nc:run capture:connected_as effect:fetch -curl -sf https://discord.com/api/v10/users/@me -H "Authorization: Bot {{bot_token}}" | jq -er '"@" + .username' -``` +The agent talks to you in your direct-message channel with the bot. Your Discord +user ID was already derived as the application's owner (`owner_handle`), so all +that's left is to open the DM and read back its channel id. Open the DM with `POST /users/@me/channels` and take the channel id it returns as the conversation address `discord:@me:` (if Discord refuses, the bot diff --git a/scripts/skill-apply.test.ts b/scripts/skill-apply.test.ts index 3e61912d4..44a14fecc 100644 --- a/scripts/skill-apply.test.ts +++ b/scripts/skill-apply.test.ts @@ -333,6 +333,100 @@ describe('nc:run capture', () => { }); }); +// Multi-field JSON capture: a `capture:a=.x,b=.owner.id` on an effect:fetch parses +// the command's stdout as JSON and binds each var to its jq-style dot-path — so ONE +// API call (Discord's /oauth2/applications/@me) resolves several values at once. +// A single `capture:var` (no =) still binds stdout as-is. validate: shape-guards +// a captured value; a mismatch bounces to an agent (a command's output has no human +// to re-prompt). effect:step's terminal-block field capture (distinguished by +// effect) is untouched — see the effect:step describe above. +const MULTI_CAPTURE_SKILL = `# multi-field capture demo + +## Derive three values from one call +\`\`\`nc:run capture:application_id=.id,public_key=.verify_key,owner_handle=.owner.id effect:fetch +curl -sf https://example/app +\`\`\` + +## Store the derived values +\`\`\`nc:env-set +APP_ID={{application_id}} +PUB_KEY={{public_key}} +\`\`\` +`; + +const CAPTURE_VALIDATE_SKILL = `# capture validate demo + +## Resolve an id that must be numeric +\`\`\`nc:run capture:app_id=.id effect:fetch validate:^\\d+$ +curl -sf https://example/app +\`\`\` + +## Use it +\`\`\`nc:env-set +APP_ID={{app_id}} +\`\`\` +`; + +describe('nc:run multi-field JSON capture + validate', () => { + let mroot: string; + let mskill: string; + beforeEach(() => { + mskill = mkdtempSync(join(tmpdir(), 'nc-multi-skill-')); + mroot = mkdtempSync(join(tmpdir(), 'nc-multi-proj-')); + writeFileSync(join(mroot, 'package.json'), '{"name":"scratch"}'); + writeFileSync(join(mroot, '.env'), ''); + }); + + it('binds three vars from one JSON stdout via dot-paths (incl. a nested .owner.id) and feeds them downstream', async () => { + writeFileSync(join(mskill, 'SKILL.md'), MULTI_CAPTURE_SKILL); + const json = JSON.stringify({ id: '111111111111111111', verify_key: 'abc123', owner: { id: '999999999999999999' } }); + const res = await applySkill(mskill, mroot, { inputs: {}, exec: () => json + '\n' }); + expect(fullyApplied(res)).toBe(true); + expect(res.vars.application_id).toBe('111111111111111111'); + expect(res.vars.public_key).toBe('abc123'); + expect(res.vars.owner_handle).toBe('999999999999999999'); // nested dot-path resolved + const env = readFileSync(join(mroot, '.env'), 'utf8'); + expect(env).toContain('APP_ID=111111111111111111'); // flowed into env-set + expect(env).toContain('PUB_KEY=abc123'); + }); + + it('lint registers each capture:= var as defined for the downstream env-set', () => { + expect(validate(parseDirectives(MULTI_CAPTURE_SKILL))).toEqual([]); + }); + + it('single capture: (no =) still binds stdout as-is — unchanged', async () => { + writeFileSync(join(mskill, 'SKILL.md'), '# single\n\n```nc:run capture:dm effect:fetch\nresolve\n```\n```nc:env-set\nDM={{dm}}\n```\n'); + const res = await applySkill(mskill, mroot, { inputs: {}, exec: () => 'D123\n' }); + expect(res.vars.dm).toBe('D123'); + expect(readFileSync(join(mroot, '.env'), 'utf8')).toContain('DM=D123'); + }); + + it('a validate mismatch on a captured value bounces to an agent — never binds the var', async () => { + writeFileSync(join(mskill, 'SKILL.md'), CAPTURE_VALIDATE_SKILL); + const res = await applySkill(mskill, mroot, { inputs: {}, exec: () => JSON.stringify({ id: 'not-a-number' }) }); + expect(res.agentTasks).toHaveLength(1); // bounce, not re-ask + expect(res.agentTasks[0].kind).toBe('run'); + expect(res.vars.app_id).toBeUndefined(); // validate failed before binding + // the downstream env-set then defers on the unresolved {{app_id}} + expect(res.deferred.some((d) => /unresolved \{\{app_id\}\}/.test(d))).toBe(true); + expect(readFileSync(join(mroot, '.env'), 'utf8')).not.toContain('APP_ID='); + }); + + it('a validate match binds the captured value and applies clean', async () => { + writeFileSync(join(mskill, 'SKILL.md'), CAPTURE_VALIDATE_SKILL); + const res = await applySkill(mskill, mroot, { inputs: {}, exec: () => JSON.stringify({ id: '42' }) }); + expect(fullyApplied(res)).toBe(true); + expect(res.vars.app_id).toBe('42'); + }); + + it('unparseable JSON stdout for a multi-field capture bounces (degrade, not crash)', async () => { + writeFileSync(join(mskill, 'SKILL.md'), MULTI_CAPTURE_SKILL); + const res = await applySkill(mskill, mroot, { inputs: {}, exec: () => 'not json at all' }); + expect(res.agentTasks).toHaveLength(1); + expect(res.vars.application_id).toBeUndefined(); + }); +}); + // operator: the parts addressed to the human (UI steps), delineated so the agent // relays them and the engine renders them — the output twin of prompt. describe('nc:operator', () => { diff --git a/scripts/skill-apply.ts b/scripts/skill-apply.ts index 90f925747..4fa434157 100644 --- a/scripts/skill-apply.ts +++ b/scripts/skill-apply.ts @@ -357,6 +357,53 @@ function whenMet(when: string, vars: Map)[key]; + } + return cur; +} + +// Bind a `run capture:` from a command's stdout into one or more {{vars}}. +// • bare `capture:var` → binds the trimmed stdout as-is (unchanged). +// • `capture:a=.x,b=.owner.id` → parses the stdout as JSON and binds each var +// to its dot-path, so ONE API call resolves +// several values (the structured twin of the +// effect:step terminal-block capture — those +// two are distinguished by effect: step reads +// the status block, fetch/external read JSON +// stdout). Unparseable JSON throws → the outer +// catch bounces it to an agent. +// An optional `validate:` is enforced against every bound value; a mismatch +// THROWS so the run bounces to an agent — a command's output has no human to +// re-prompt, so an invalid capture is a real failure, not a re-ask. +function bindCapture( + spec: string, + stdout: string, + validate: string | undefined, + vars: Map, +): void { + const re = validate ? new RegExp(validate) : undefined; + const set = (name: string, value: string): void => { + if (re && !re.test(value)) throw new Error(`captured ${name}="${value}" does not match validate:${validate}`); + vars.set(name, { value, secret: false }); + }; + if (!spec.includes('=')) { + set(spec, stdout); + return; + } + const json = JSON.parse(stdout) as unknown; // not JSON → throws → outer catch bounces + for (const pair of spec.split(',')) { + const eq = pair.indexOf('='); + if (eq < 1) continue; + set(pair.slice(0, eq).trim(), String(dotPath(json, pair.slice(eq + 1).trim()) ?? '')); + } +} + // The mutating twin of selfStatus. Records what it did to the journal so remove // is derivable. Throws on failure → caught and bounced to an agent. async function applyOne( @@ -418,6 +465,8 @@ async function applyOne( // API (e.g. Slack conversations.open → the DM channel id) and feed it to a // later directive, so a flow that validates/resolves stays pure directives. const capture = typeof d.attrs.capture === 'string' ? d.attrs.capture : undefined; + // A `validate:` shape-guard the stdout capture enforces (see bindCapture). + const validate = typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined; // effect:check runs the body as a shell PREDICATE — a precondition gate // that mutates NOTHING. It pushes no journal entry and binds no capture: a // zero exit is a silent pass; a non-zero exit throws → the outer catch @@ -457,7 +506,9 @@ async function applyOne( // throws → caught → deferred (the prompt hasn't been answered yet). const out = await exec(substitute(cmd, vars)); // Last command wins for capture (a capture run should be a single command). - if (capture) vars.set(capture, { value: typeof out === 'string' ? out.trim() : '', secret: false }); + // bindCapture binds stdout-as-is OR a multi-field JSON spec, and enforces + // validate: — a mismatch / unparseable JSON throws → bounced to an agent. + if (capture) bindCapture(capture, typeof out === 'string' ? out.trim() : '', validate, vars); // Journal the ORIGINAL command (placeholders intact) — never the // substituted form — so a secret interpolated into a run never lands in // the journal (or a remove replay). diff --git a/scripts/skill-directives.test.ts b/scripts/skill-directives.test.ts index db6eba609..c2b7e12fc 100644 --- a/scripts/skill-directives.test.ts +++ b/scripts/skill-directives.test.ts @@ -218,4 +218,28 @@ describe('when: guard + multi-field capture', () => { ].join('\n'); expect(validate(parseDirectives(md))).toEqual([]); }); + + it('registers each capture:= (JSON multi-field) var as defined for downstream {{vars}}', () => { + const md = [ + '```nc:run capture:application_id=.id,public_key=.verify_key,owner_handle=.owner.id effect:fetch', + 'curl -sf https://example/app', + '```', + '```nc:env-set', + 'APP={{application_id}}', + 'PUB={{public_key}}', + 'OWN={{owner_handle}}', + '```', + ].join('\n'); + expect(validate(parseDirectives(md))).toEqual([]); + }); + + it('flags an invalid run capture validate: regex', () => { + const md = ['```nc:run capture:app_id=.id effect:fetch validate:^[', 'curl x', '```'].join('\n'); + expect(validate(parseDirectives(md)).some((p) => /run validate:.*is not a valid regex/.test(p.message))).toBe(true); + }); + + it('accepts a valid run capture validate: regex', () => { + const md = ['```nc:run capture:app_id=.id effect:fetch validate:^\\d+$', 'curl x', '```'].join('\n'); + expect(validate(parseDirectives(md))).toEqual([]); + }); }); diff --git a/scripts/skill-directives.ts b/scripts/skill-directives.ts index 93df2d5d3..6c5a1dc88 100644 --- a/scripts/skill-directives.ts +++ b/scripts/skill-directives.ts @@ -28,6 +28,11 @@ // (rebuild, or a setup that restarts once) skips it via ApplyOptions. // skipEffects. capture: binds the command's stdout into {{var}} (twin // of prompt) — e.g. resolve an id from an API and feed it to a later step. +// capture:=[,=…] parses the stdout as JSON +// and binds each var to its jq-style dot-path (.id, .owner.id), so ONE API +// call resolves several values at once. validate: shape-guards each +// captured value (e.g. validate:^discord:); a mismatch bounces the run to +// an agent (a command's output has no human to re-prompt — unlike prompt). // effect:step runs a long-running, operator-interactive step (a pairing // code, a QR device-link) through the streaming exec: its // `=== NANOCLAW SETUP: … ===` status blocks render to the operator live and @@ -257,6 +262,14 @@ export function validate(directives: Directive[], ctx?: { chatVersion?: string } if (d.kind === 'run' && typeof d.attrs.capture === 'string') { for (const v of captureVars(d.attrs.capture)) defined.add(v); } + // A run's capture validate: (the stdout shape-guard) must be a valid regex. + if (d.kind === 'run' && typeof d.attrs.validate === 'string') { + try { + new RegExp(d.attrs.validate); + } catch { + flag(d, `run validate:${d.attrs.validate} is not a valid regex`); + } + } } return problems; }