diff --git a/__tests__/bootstrap-cli.test.ts b/__tests__/bootstrap-cli.test.ts new file mode 100644 index 00000000..3d143386 --- /dev/null +++ b/__tests__/bootstrap-cli.test.ts @@ -0,0 +1,62 @@ +import { EventEmitter } from 'node:events' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' + +import { runBootstrap } from '../scripts/cli/bootstrap.mjs' + +function createSuccessfulSpawn() { + return vi.fn(() => { + const child = new EventEmitter() + queueMicrotask(() => child.emit('close', 0)) + return child + }) +} + +describe('open-stellar bootstrap', () => { + it('scaffolds an agent project and runs npm install', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'open-stellar-bootstrap-')) + const spawnCommand = createSuccessfulSpawn() + + await runBootstrap(['my-agent'], { + cwd: tempDir, + spawnCommand, + }) + + const projectDir = path.join(tempDir, 'my-agent') + const expectedFiles = [ + 'package.json', + 'tsconfig.json', + 'next.config.mjs', + '.env.example', + 'README.md', + 'lib/agent.ts', + 'app/page.tsx', + 'app/layout.tsx', + ] + + await Promise.all( + expectedFiles.map(async (fileName) => { + await expect(fs.stat(path.join(projectDir, fileName))).resolves.toBeTruthy() + }), + ) + + await expect(fs.readFile(path.join(projectDir, 'package.json'), 'utf8')).resolves.toContain('"name": "my-agent"') + await expect(fs.readFile(path.join(projectDir, 'README.md'), 'utf8')).resolves.toContain('# my-agent') + await expect(fs.readFile(path.join(projectDir, '.env.example'), 'utf8')).resolves.toContain('ANTHROPIC_API_KEY=') + expect(spawnCommand).toHaveBeenCalledWith('npm', ['install'], expect.objectContaining({ cwd: projectDir })) + }) + + it('errors when the project directory already exists', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'open-stellar-bootstrap-')) + await fs.mkdir(path.join(tempDir, 'my-agent')) + + await expect( + runBootstrap(['my-agent'], { + cwd: tempDir, + spawnCommand: createSuccessfulSpawn(), + }), + ).rejects.toThrow('Directory "my-agent" already exists.') + }) +}) diff --git a/package-lock.json b/package-lock.json index 1cd78589..3d7cb716 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "my-project", + "name": "open-stellar", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "my-project", + "name": "open-stellar", "version": "0.1.0", "workspaces": [ "packages/*" @@ -78,6 +78,9 @@ "wagmi": "^2.14.0", "zod": "^3.24.1" }, + "bin": { + "open-stellar": "scripts/cli/open-stellar.mjs" + }, "devDependencies": { "@playwright/test": "^1.61.1", "@secretlint/secretlint-rule-preset-recommend": "^12.0.0", diff --git a/package.json b/package.json index 7e1a07f6..ad5b62d8 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "my-project", + "name": "open-stellar", "version": "0.1.0", "private": true, "engines": { diff --git a/scripts/cli/bootstrap.mjs b/scripts/cli/bootstrap.mjs new file mode 100644 index 00000000..52a0cbbd --- /dev/null +++ b/scripts/cli/bootstrap.mjs @@ -0,0 +1,90 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const TEMPLATE_DIR = path.resolve(__dirname, '../templates/agent') + +async function pathExists(targetPath) { + try { + await fs.access(targetPath) + return true + } catch { + return false + } +} + +function installDependencies(projectDir, spawnCommand) { + return new Promise((resolve, reject) => { + const child = spawnCommand('npm', ['install'], { + cwd: projectDir, + shell: process.platform === 'win32', + stdio: 'inherit', + }) + + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) { + resolve() + return + } + + reject(new Error(`npm install failed with exit code ${code}`)) + }) + }) +} + +function printSuccess(projectName) { + console.log('') + console.log(`Created ${projectName}`) + console.log('') + console.log('Next steps:') + console.log(` cd ${projectName} && npm run dev`) + console.log('') +} + +export async function runBootstrap(argv = process.argv.slice(2), options = {}) { + const [projectName] = argv + const cwd = options.cwd ?? process.cwd() + const spawnCommand = options.spawnCommand ?? spawn + + if (!projectName) { + throw new Error('Usage: npx open-stellar bootstrap ') + } + + const targetDir = path.resolve(cwd, projectName) + + if (await pathExists(targetDir)) { + throw new Error(`Directory "${projectName}" already exists.`) + } + + await fs.cp(TEMPLATE_DIR, targetDir, { recursive: true }) + + const replacements = { + __PROJECT_NAME__: projectName, + } + + for (const fileName of ['package.json', 'README.md']) { + const filePath = path.join(targetDir, fileName) + let content = await fs.readFile(filePath, 'utf8') + + for (const [token, value] of Object.entries(replacements)) { + content = content.split(token).join(value) + } + + await fs.writeFile(filePath, content, 'utf8') + } + + await installDependencies(targetDir, spawnCommand) + printSuccess(projectName) +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + await runBootstrap() + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) + } +} diff --git a/scripts/cli/open-stellar.mjs b/scripts/cli/open-stellar.mjs new file mode 100644 index 00000000..59183bd7 --- /dev/null +++ b/scripts/cli/open-stellar.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +import { runBootstrap } from './bootstrap.mjs' + +const [command, ...args] = process.argv.slice(2) + +try { + if (command === 'bootstrap') { + await runBootstrap(args) + } else { + console.error('Usage: npx open-stellar bootstrap ') + process.exit(1) + } +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +} diff --git a/scripts/templates/agent/.env.example b/scripts/templates/agent/.env.example new file mode 100644 index 00000000..659cf067 --- /dev/null +++ b/scripts/templates/agent/.env.example @@ -0,0 +1,56 @@ +# Stellar Network - use testnet for local development. +STELLAR_NETWORK=testnet + +# Development mode - disables auth and uses testnet-only flows locally. +DEV_MODE=true + +# Anthropic API key for agent execution. +ANTHROPIC_API_KEY=sk-ant-your-key-here + +# Moltbot gateway token for internal communication. +MOLTBOT_GATEWAY_TOKEN=dev-test-token-12345 + +# Optional Cloudflare Access client ID when DEV_MODE is false. +CF_ACCESS_CLIENT_ID= + +# Optional Cloudflare Access team domain for protected routes. +CF_ACCESS_TEAM_DOMAIN= + +# Optional Cloudflare Access audience tag for protected routes. +CF_ACCESS_AUD= + +# Optional debug routes at /debug/*. +DEBUG_ROUTES=false + +# Optional Stellar testnet public key that receives paid cosmetic purchases. +STELLAR_TREASURY_ADDRESS= + +# Optional Better Stack / Logtail source token for structured API logs. +LOGTAIL_SOURCE_TOKEN= + +# Optional custom BNB RPC URL for x402 settlement. +NEXT_PUBLIC_BNB_RPC_URL= + +# Optional custom Base RPC URL for x402 settlement. +NEXT_PUBLIC_BASE_RPC_URL= + +# Optional OpenAI API key if using OpenAI instead of Anthropic. +OPENAI_API_KEY= + +# Optional Telegram channel token. +TELEGRAM_BOT_TOKEN= + +# Optional Discord channel token. +DISCORD_BOT_TOKEN= + +# Optional Slack bot token. +SLACK_BOT_TOKEN= + +# Optional Slack app token. +SLACK_APP_TOKEN= + +# Optional shared secret for CDP browser automation. +CDP_SECRET= + +# Optional deployed worker URL for CDP browser automation. +WORKER_URL= diff --git a/scripts/templates/agent/README.md b/scripts/templates/agent/README.md new file mode 100644 index 00000000..49f92c92 --- /dev/null +++ b/scripts/templates/agent/README.md @@ -0,0 +1,14 @@ +# __PROJECT_NAME__ + +Minimal Open Stellar agent project scaffolded with `npx open-stellar bootstrap`. + +## Quick start + +```bash +cp .env.example .env.local +npm run dev +``` + +## Agent + +Edit `lib/agent.ts` to register your agent name, capabilities, and handler. diff --git a/scripts/templates/agent/app/layout.tsx b/scripts/templates/agent/app/layout.tsx new file mode 100644 index 00000000..1cdf0f39 --- /dev/null +++ b/scripts/templates/agent/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +export const metadata: Metadata = { + title: 'Open Stellar Agent', + description: 'A minimal Open Stellar agent project.', +} + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/scripts/templates/agent/app/page.tsx b/scripts/templates/agent/app/page.tsx new file mode 100644 index 00000000..36c3f87e --- /dev/null +++ b/scripts/templates/agent/app/page.tsx @@ -0,0 +1,19 @@ +import { agent } from '@/lib/agent' + +export default function Page() { + return ( +
+

Open Stellar Agent

+

{agent.name}

+

{agent.description}

+
+

Capabilities

+
    + {agent.capabilities.map((capability) => ( +
  • {capability}
  • + ))} +
+
+
+ ) +} diff --git a/scripts/templates/agent/lib/agent.ts b/scripts/templates/agent/lib/agent.ts new file mode 100644 index 00000000..da3f9098 --- /dev/null +++ b/scripts/templates/agent/lib/agent.ts @@ -0,0 +1,17 @@ +export type AgentRegistration = { + id: string + name: string + description: string + capabilities: string[] + run: (input: string) => Promise +} + +export const agent: AgentRegistration = { + id: 'starter-agent', + name: 'Starter Agent', + description: 'A minimal Open Stellar agent ready for local development.', + capabilities: ['status', 'echo'], + async run(input: string): Promise { + return `Starter Agent received: ${input}` + }, +} diff --git a/scripts/templates/agent/next-env.d.ts b/scripts/templates/agent/next-env.d.ts new file mode 100644 index 00000000..381dfc4b --- /dev/null +++ b/scripts/templates/agent/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited. +// See https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/scripts/templates/agent/next.config.mjs b/scripts/templates/agent/next.config.mjs new file mode 100644 index 00000000..1d614782 --- /dev/null +++ b/scripts/templates/agent/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {} + +export default nextConfig diff --git a/scripts/templates/agent/package.json b/scripts/templates/agent/package.json new file mode 100644 index 00000000..984e3991 --- /dev/null +++ b/scripts/templates/agent/package.json @@ -0,0 +1,23 @@ +{ + "name": "__PROJECT_NAME__", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --webpack", + "build": "next build --webpack", + "start": "next start", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "next": "16.2.0", + "react": "19.2.4", + "react-dom": "19.2.4", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/node": "^22", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "5.7.3" + } +} diff --git a/scripts/templates/agent/tsconfig.json b/scripts/templates/agent/tsconfig.json new file mode 100644 index 00000000..d8b93235 --- /dev/null +++ b/scripts/templates/agent/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}