Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ changeset: `pnpm changeset`, pick the package(s), pick patch / minor / major, wr
entry that will appear in the changelog. The `changeset` GitHub check refuses PRs that change a
package without one; add the `no changeset` label for changes that need no release note.

The Claude plugin under `plugins/radius` is released separately. Skill and plugin changes do not
need a changeset unless the same PR also changes a publishable package. Bump the plugin version in
`plugins/radius/.claude-plugin/plugin.json` for release content changes; plugin CI enforces this.
If the skill needs a new CLI or SDK API, publish the npm package first, then release the plugin
guidance with the minimum supported package version.

Releasing is automated by `.github/workflows/release.yml`. Merging changesets to `main` opens (or
refreshes) a **Version Packages** PR on the `changeset-release/main` branch: it runs `pnpm
version-packages` (bumps versions, writes CHANGELOG.md files, bumps `radius-cli` whenever `radius-sdk`
Expand Down
1 change: 0 additions & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
"plugins": [
{
"name": "radius-dev",
"version": "0.0.2",
"description": "Radius Network tools for x402 payments, blockchain development, and testnet faucet",
"author": {
"name": "Radius Technology Systems"
Expand Down
16 changes: 14 additions & 2 deletions .github/workflows/plugin-evals.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,23 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # compare the plugin version with the PR base
persist-credentials: false
- name: Validate plugin structure and scenarios
run: python3 scripts/validate_plugin.py
- name: Require a plugin version bump for release content
if: github.event_name == 'pull_request'
env:
BASE_REF: origin/${{ github.base_ref }}
run: python3 scripts/validate_plugin.py --base-ref "$BASE_REF"
- name: Install Claude Code
run: |
curl -fsSL https://claude.ai/install.sh | bash -s 2.1.272
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Validate Claude plugin and marketplace
run: |
claude plugin validate plugins/radius --strict
claude plugin validate . --strict

eval:
needs: validate
Expand All @@ -49,8 +63,6 @@ jobs:
run: |
curl -fsSL https://claude.ai/install.sh | bash -s 2.1.272
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Validate Claude plugin
run: claude plugin validate plugins/radius
- name: Run Claude plugin evals
working-directory: plugins/radius
shell: bash
Expand Down
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,37 @@ Runnable SDK examples (seller worker, agent buyer, browser demo dapp) are in [`p
The [Radius Claude Code plugin](plugins/radius) contains the `radius-dev`, `x402`,
and `dripping-faucet` skills. The marketplace manifest is at
[`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json). These files
live outside `packages/*`, so plugin changes do not enter the npm release flow
or need a changeset.
live outside `packages/*`. The plugin has its own version in
[`plugins/radius/.claude-plugin/plugin.json`](plugins/radius/.claude-plugin/plugin.json)
and does not enter the npm Changesets release flow. The marketplace's
`metadata.version` describes the catalog, not the plugin; the marketplace entry
does not duplicate the plugin version.

In Claude Code, install from this repository:

```text
/plugin marketplace add https://github.com/radiustechsystems/radius-cli.git
/plugin marketplace add radiustechsystems/radius-cli
/plugin install radius-dev@radius-cli
```

For skill changes, run `python3 scripts/validate_plugin.py` and
`claude plugin validate plugins/radius`. The path-filtered
Update an installed plugin with `claude plugin update radius-dev@radius-cli`.
For every release-worthy change under `plugins/radius/skills`, or to the plugin
manifest or README, bump the plugin manifest version in the same PR: patch for
corrections, minor for new capabilities, major for incompatible changes. The
plugin CI check enforces a version increase independently of Changesets.
After merging to `main`, users of this GitHub marketplace can update; marketplace
automatic updates are off by default unless users enable them. A plugin release
may be tagged `radius-dev--v<version>` for a traceable release point.

When CLI or SDK work changes a documented API, audit the affected skills in
the package PR. Publish plugin instructions for a new package API only after
the corresponding npm version is available, and state the minimum package
version in that guidance. This avoids directing installed plugin users to code
that has merged but has not yet been published to npm.

For skill changes, run `python3 scripts/validate_plugin.py`,
`claude plugin validate plugins/radius --strict`, and
`claude plugin validate . --strict`. The path-filtered
[`plugin-evals.yml`](.github/workflows/plugin-evals.yml) runs Claude Code plugin
evals on trusted plugin changes. Add or update cases under
[`plugins/radius/evals`](plugins/radius/evals) with each behavior change, inspect
Expand Down
7 changes: 5 additions & 2 deletions plugins/radius/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
{
"name": "radius-dev",
"version": "0.0.2",
"version": "0.0.3",
"description": "Radius Network tools for x402 payments, blockchain development, and testnet faucet",
"author": {
"name": "Radius Technology Systems"
}
},
"homepage": "https://github.com/radiustechsystems/radius-cli/tree/main/plugins/radius",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably should have a page either on the docs or www that acts as this projects homepage.

"repository": "https://github.com/radiustechsystems/radius-cli",
"license": "MIT"
}
30 changes: 30 additions & 0 deletions plugins/radius/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Radius development plugin

The `radius-dev` Claude Code plugin provides three skills:

- `radius-dev` for Radius Network application development
- `x402` for Radius payment integrations
- `dripping-faucet` for testnet faucet workflows

The SDK examples target `radius-sdk` 0.3.0 or later, and terminal wallet
examples target `radius-cli` 0.3.0 or later. Install the peer dependency
needed by the SDK entry point you use (`viem` for `/client`, `hono` for `/hono`).

Install it from the [Radius CLI repository](https://github.com/radiustechsystems/radius-cli):

```sh
claude plugin marketplace add radiustechsystems/radius-cli
claude plugin install radius-dev@radius-cli

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still don't love this naming convention.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any other naming ideas?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/plugin marketplace add radiustechsystems/tools
/plugin install radius@skills

It would require this monorepo be renamed to "tools"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm supportive of this rename. Is @radius available in skills marketplaces?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Within this setting "radius" is not part of a global namespace so should be fine

```

After a new plugin version is published, update an existing installation with
`claude plugin update radius-dev@radius-cli`. Automatic updates depend on the
user's marketplace setting.

The plugin is versioned independently from the `radius-cli` and `radius-sdk`
npm packages. Its version is in `.claude-plugin/plugin.json`; the GitHub
marketplace lists the plugin at `.claude-plugin/marketplace.json` in the
repository root. See the [repository release instructions](https://github.com/radiustechsystems/radius-cli#agent-skills)
for validation and compatibility rules.

This plugin is distributed under the repository's [MIT license](https://github.com/radiustechsystems/radius-cli/blob/main/LICENSE).
228 changes: 30 additions & 198 deletions plugins/radius/skills/dripping-faucet/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,210 +128,42 @@ When running bash commands as an agent (e.g. in Claude Code), **every shell invo

Every `curl` and `radius-cli` call in the examples below includes an explicit `echo` of its output. This is not optional — without it, the agent sees `(No output)` and cannot proceed.

## TypeScript Example (viem)
## TypeScript Example (radius-sdk)

For an app that already has an operator-approved signer, use `fund()` from
`radius-sdk/client`. It signs the faucet's EIP-191 challenge and requests a drip.
Install `radius-sdk` and its `viem` peer dependency. A faucet API success is not
receipt confirmation: check the transaction hash before reporting funds as
received.

```typescript
import { defineChain, createPublicClient, http, erc20Abi, isAddress, formatUnits } from 'viem';
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';

// --- Network configuration ---
type Network = 'testnet' | 'mainnet';

const NETWORK_CONFIG: Record<Network, { faucetUrl: string; chain: Chain }> = {
testnet: {
faucetUrl: 'https://testnet.radiustech.xyz/api/v1/faucet',
chain: defineChain({
id: 72344,
name: 'Radius Testnet',
nativeCurrency: { decimals: 18, name: 'RUSD', symbol: 'RUSD' },
rpcUrls: { default: { http: ['https://rpc.testnet.radiustech.xyz'] } },
blockExplorers: {
default: { name: 'Radius Testnet Explorer', url: 'https://testnet.radiustech.xyz' },
},
fees: radiusFees,
}),
},
mainnet: {
faucetUrl: 'https://network.radiustech.xyz/api/v1/faucet',
chain: defineChain({
id: 723487,
name: 'Radius Mainnet',
nativeCurrency: { decimals: 18, name: 'RUSD', symbol: 'RUSD' },
rpcUrls: { default: { http: ['https://rpc.radiustech.xyz'] } },
blockExplorers: {
default: { name: 'Radius Explorer', url: 'https://network.radiustech.xyz' },
},
fees: radiusFees,
}),
},
};

const SBC_CONTRACT = '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb' as const;
const SBC_DECIMALS = 6;

const radiusTestnet = defineChain({
id: 72344,
name: 'Radius Testnet',
nativeCurrency: { decimals: 18, name: 'RUSD', symbol: 'RUSD' },
rpcUrls: { default: { http: ['https://rpc.testnet.radiustech.xyz'] } },
blockExplorers: {
default: { name: 'Radius Testnet Explorer', url: 'https://testnet.radiustech.xyz' },
},
import { createPublicClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { radiusTestnet } from 'radius-sdk';
import { createRadiusFetch } from 'radius-sdk/client';

const key = process.env.RADIUS_PRIVATE_KEY;
if (!key) throw new Error('Configure RADIUS_PRIVATE_KEY through your secrets manager');
const signer = privateKeyToAccount(key as `0x${string}`);
const wallet = createRadiusFetch({

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want a generic fetch that can just answer whatever the seller provides. I started down a path of making the SDK more multi-EVM compatible #40 but am holding off until we can discuss.

network: 'testnet',
signer,
maxPerRequest: '$0.01', // required by the paying client; fund() does not spend it
});
const drip = await wallet.fund();
if (!drip.txHash) throw new Error('Faucet accepted the request without a transaction hash');

// --- Wallet setup ---
// Option A: We have an existing key (user's wallet, stored in .env)
// const privateKey = process.env.PRIVATE_KEY as `0x${string}`;

// Option B: We only have an address (no signer — current deployments will reject it)
// const addressOnly = '0x...' as `0x${string}`;

// Option C: Create a new wallet (we own the key)
const privateKey = generatePrivateKey();
const account = privateKeyToAccount(privateKey);
// SECURITY: only log the address, never the key
console.log('Wallet address:', account.address);

// If using Option B, set account to null — the signed fallback will not be available.
// The dripWithRetry function below handles this.

// --- Faucet drip with eval loop ---
async function dripWithRetry(
address: string,
/** Pass null if no operator-approved signer is available. */
signer: { signMessage: (args: { message: string }) => Promise<string> } | null,
network: Network = 'testnet',
maxAttempts = 3
): Promise<{ success: boolean; network: Network; tx_hash?: string; balance?: string; error?: string }> {
if (!isAddress(address)) {
return { success: false, network, error: `Invalid address: ${address}` };
}

const { faucetUrl, chain } = NETWORK_CONFIG[network];

// Both deployed faucets currently require a signature. Fail fast when no
// approved signer is available rather than making a request known to fail.
if (!signer) {
return {
success: false,
network,
error: 'signature_required_but_no_signer',
};
}

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// 1. Try unsigned drip first (skipping straight to signed flow on mainnet is an
// optimisation you may apply, but the unsigned attempt is safe to make here
// since the signed fallback is implemented below).
const dripRes = await fetch(`${faucetUrl}/drip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address, token: 'SBC' }),
});
let drip = await dripRes.json();

// Error responses currently use { error: { code, message, ... } }.
let errorCode = typeof drip.error === 'string' ? drip.error : drip.error?.code;
let errorMessage = typeof drip.error === 'string' ? drip.message : drip.error?.message;

// 2. If signature required, fall back to signed flow (only if we have a signer)
if (errorCode === 'signature_required') {
if (!signer) {
return {
success: false,
network,
error: 'signature_required_but_no_signer',
};
}
console.log('Signature required — switching to signed flow');

// Check status
const statusRes = await fetch(`${faucetUrl}/status/${address}?token=SBC`);
const status = await statusRes.json();
if (status.rate_limited) {
const waitMs = status.retry_after_ms ?? 60_000;
console.log(`Rate limited. Waiting ${waitMs}ms (attempt ${attempt}/${maxAttempts})`);
// On mainnet, retry_after_ms can be ~86_400_000 (24 hours). Do not loop — report to user.
if (waitMs > 3_600_000) {
return { success: false, network, error: `rate_limited_long_wait_ms:${waitMs}` };
}
await new Promise((r) => setTimeout(r, waitMs));
continue;
}

// Get challenge — extract only the "message" field
const challengeRes = await fetch(`${faucetUrl}/challenge/${address}?token=SBC`);
const challenge = await challengeRes.json();
const message: string = challenge.message;

// Sign (EIP-191)
const signature = await signer.signMessage({ message });

// Retry drip with signature
const signedRes = await fetch(`${faucetUrl}/drip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address, token: 'SBC', signature }),
});
drip = await signedRes.json();
errorCode = typeof drip.error === 'string' ? drip.error : drip.error?.code;
errorMessage = typeof drip.error === 'string' ? drip.message : drip.error?.message;
}

// 3. Evaluate
if (drip.success) {
// Verify on-chain (the receipt is ground truth, not the API response)
const publicClient = createPublicClient({ chain, transport: http() });
const balance = await publicClient.readContract({
address: SBC_CONTRACT,
abi: erc20Abi,
functionName: 'balanceOf',
args: [address as `0x${string}`],
});
const formatted = formatUnits(balance, SBC_DECIMALS);
console.log(`SBC balance (${network}): ${formatted}`);
return { success: true, network, tx_hash: drip.tx_hash, balance: formatted };
}

// Critique: map error to action
console.error(`Attempt ${attempt} failed: ${errorCode} — ${errorMessage ?? ''}`);

if (errorCode === 'rate_limited') {
const waitMs = drip.error?.retry_after_ms ?? drip.retry_after_ms ?? 60_000;
// On mainnet, a rate_limited response means ~24h. Stop immediately.
if (waitMs > 3_600_000) {
return { success: false, network, error: `rate_limited_long_wait_ms:${waitMs}` };
}
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
if (errorCode === 'invalid_signature') {
// Re-fetch challenge in case it rotated
continue;
}
if (['faucet_empty', 'sbc_not_configured', 'internal_error'].includes(errorCode)) {
return { success: false, network, error: errorCode };
}
}

return { success: false, network, error: 'max_attempts_exceeded' };
}

// Testnet — create a throwaway wallet and sign the configured challenge
const testnetResult = await dripWithRetry(account.address, account, 'testnet');
console.log('Testnet result:', JSON.stringify(testnetResult, null, 2));

// Mainnet — use an existing wallet with an approved signer; signature currently required
// const mainnetAccount = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
// const mainnetResult = await dripWithRetry(mainnetAccount.address, mainnetAccount, 'mainnet');
// console.log('Mainnet result:', JSON.stringify(mainnetResult, null, 2));

// If you only have an address and no signer on testnet (unsigned-only):
// dripWithRetry(addressOnly, null, 'testnet');
// NOTE: the currently deployed services require a signature, so address-only
// calls return immediately with signature_required_but_no_signer.
const publicClient = createPublicClient({ chain: radiusTestnet.chain, transport: http() });
const receipt = await publicClient.waitForTransactionReceipt({ hash: drip.txHash });
if (receipt.status !== 'success') throw new Error('Faucet transaction reverted');
console.log('Funded address:', wallet.address, 'transaction:', drip.txHash);
```

For mainnet, choose `network: 'mainnet'` deliberately and use an existing,
approved signer. If you only have an address, this signed flow cannot run; use
the web faucet or obtain signer access through the wallet owner. Do not create
an unmanaged wallet or ask anyone to paste a private key.

## Agent-created wallet

For a fresh wallet in an agent demo, use `radius-cli` with a scoped
Expand Down
Loading
Loading