feat(setup): multi-field JSON capture + validate-on-capture — derive Discord creds from one call

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:<re>`
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 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-06-27 23:15:23 +03:00
parent 8455eb0809
commit d997d545cc
5 changed files with 208 additions and 30 deletions
+25 -29
View File
@@ -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 1720 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"; 1720 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:<channelId>` (if Discord refuses, the bot
+94
View File
@@ -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:<re> 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>=<dot-path> var as defined for the downstream env-set', () => {
expect(validate(parseDirectives(MULTI_CAPTURE_SKILL))).toEqual([]);
});
it('single capture:<var> (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', () => {
+52 -1
View File
@@ -357,6 +357,53 @@ function whenMet(when: string, vars: Map<string, { value: string; secret: boolea
return vars.get(when.slice(0, eq).trim())?.value === when.slice(eq + 1).trim();
}
// Resolve a jq-style dot-path (`.id`, `.owner.id`) into a parsed JSON value.
// A missing/non-object hop yields undefined — the caller coerces that to ''.
function dotPath(obj: unknown, path: string): unknown {
let cur: unknown = obj;
for (const key of path.replace(/^\./, '').split('.').filter(Boolean)) {
if (cur === null || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[key];
}
return cur;
}
// Bind a `run capture:<spec>` 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:<re>` 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<string, { value: string; secret: boolean }>,
): 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:<re>` 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:<re> — 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).
+24
View File
@@ -218,4 +218,28 @@ describe('when: guard + multi-field capture', () => {
].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
it('registers each capture:<var>=<dot-path> (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:<re> 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:<re> regex', () => {
const md = ['```nc:run capture:app_id=.id effect:fetch validate:^\\d+$', 'curl x', '```'].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
});
+13
View File
@@ -28,6 +28,11 @@
// (rebuild, or a setup that restarts once) skips it via ApplyOptions.
// skipEffects. capture:<var> 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:<var>=<dot-path>[,<var2>=<dot-path2>…] 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:<re> 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:<re> (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;
}