diff --git a/bun.lock b/bun.lock index 72b46b8..0d78272 100644 --- a/bun.lock +++ b/bun.lock @@ -35,7 +35,7 @@ }, "packages/core": { "name": "@google/jules-sdk", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "yaml": "^2.8.2", "zod": "^3.25.76", @@ -52,6 +52,16 @@ "vitest": "^3.2.4", }, }, + "packages/core/examples/custom-cli": { + "name": "jules-custom-cli-example", + "version": "1.0.0", + "dependencies": { + "@google/jules-sdk": "workspace:*", + "citty": "^0.1.6", + "niftty": "^0.1.3", + "zod": "^3.24.0", + }, + }, "packages/core/examples/github-actions": { "name": "jules-github-actions-example", "version": "1.0.0", @@ -79,7 +89,7 @@ }, "packages/fleet": { "name": "@google/jules-fleet", - "version": "0.0.1-experimental.31", + "version": "0.0.1-experimental.32", "bin": { "jules-fleet": "dist/cli/index.mjs", }, @@ -105,7 +115,7 @@ }, "packages/mcp": { "name": "@google/jules-mcp", - "version": "0.1.0", + "version": "0.2.0", "bin": { "jules-mcp": "./dist/cli.mjs", }, @@ -771,6 +781,8 @@ "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + "jules-custom-cli-example": ["jules-custom-cli-example@workspace:packages/core/examples/custom-cli"], + "jules-github-actions-example": ["jules-github-actions-example@workspace:packages/core/examples/github-actions"], "jules-sdk-example": ["jules-sdk-example@workspace:examples/simple"], @@ -1145,8 +1157,14 @@ "@google/jules-fleet/@google/jules-merge": ["@google/jules-merge@0.0.2", "", { "dependencies": { "@google/jules-sdk": "^0.1.0", "@octokit/auth-app": "^8.2.0", "@octokit/rest": "^21.0.0", "citty": "^0.1.6", "zod": "^3.25.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.1" }, "optionalPeers": ["@modelcontextprotocol/sdk"], "bin": { "jules-merge": "dist/cli/index.mjs" } }, "sha512-VPpbdBt48AbmFByg5RGztv2sQPPJ5fFJARXTvX41lY/b9M+qdyZPp19+ay3qfxEHgj1EvnRMwf/HFOrMMXZ7vQ=="], + "@google/jules-fleet/@google/jules-sdk": ["@google/jules-sdk@0.1.0", "", { "dependencies": { "yaml": "^2.8.2", "zod": "^3.25.76" } }, "sha512-DBVhFOsLfWaVtO0miEeX+zQoazB55EYmK5/0VJSX/YHdeheZqNuPlKRV1vrnd9uSQAtU6ACRicQssMbnvJM6ng=="], + "@google/jules-fleet/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "@google/jules-mcp/@google/jules-sdk": ["@google/jules-sdk@0.1.0", "", { "dependencies": { "yaml": "^2.8.2", "zod": "^3.25.76" } }, "sha512-DBVhFOsLfWaVtO0miEeX+zQoazB55EYmK5/0VJSX/YHdeheZqNuPlKRV1vrnd9uSQAtU6ACRicQssMbnvJM6ng=="], + + "@google/jules-merge/@google/jules-sdk": ["@google/jules-sdk@0.1.0", "", { "dependencies": { "yaml": "^2.8.2", "zod": "^3.25.76" } }, "sha512-DBVhFOsLfWaVtO0miEeX+zQoazB55EYmK5/0VJSX/YHdeheZqNuPlKRV1vrnd9uSQAtU6ACRicQssMbnvJM6ng=="], + "@microsoft/api-extractor/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], "@microsoft/api-extractor/typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], @@ -1251,6 +1269,8 @@ "@actions/github/@octokit/request-error/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + "@google/jules-fleet/@google/jules-merge/@google/jules-sdk": ["@google/jules-sdk@workspace:packages/core"], + "@google/jules-fleet/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "@google/jules-fleet/glob/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], diff --git a/packages/core/README.md b/packages/core/README.md index c3d32e7..8f82459 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -11,6 +11,7 @@ Orchestrate complex, long-running coding tasks to an ephemeral cloud environment - [Agent Workflow](./examples/agent/README.md) - [Webhook Integration](./examples/webhook/README.md) - [GitHub Actions](./examples/github-actions/README.md) +- [Custom Cli Tools](./examples/custom-cli/README.md) ## Send work to a Cloud based session diff --git a/packages/core/examples/custom-cli/README.md b/packages/core/examples/custom-cli/README.md new file mode 100644 index 0000000..baf829b --- /dev/null +++ b/packages/core/examples/custom-cli/README.md @@ -0,0 +1,72 @@ +# Custom CLI Tools Example + +This example demonstrates how to use the Jules SDK to create a custom command-line interface (CLI) tool. The tool integrates with the user's **local file system** while treating repoless Jules sessions as **powerful, autonomous serverless compute containers**. + +It uses `citty` for command structure, `niftty` for terminal rendering, and the native Node.js `fs` module to orchestrate moving data between your local machine and the cloud. + +Crucially, this CLI is optimized for **Agent DX**. It follows best practices for building CLIs that are robust against agent hallucinations by: +- Employing auto-discovery for scaling commands. +- Defining a "Typed Service Contract" using Zod (`spec.ts` + `handler.ts`) for input hardening and API predictability. +- Exposing a raw `--json` flag so agents can map directly to schemas. +- Exposing an `--output json` flag so agents can parse outputs deterministically. + +## Requirements + +- Node.js >= 18 or Bun +- A Jules API Key (`JULES_API_KEY` environment variable) + +## Setup + +1. Make sure you have installed the SDK dependencies in the project root by running `bun install`. +2. Build the SDK in `packages/core` by running `npm run build` inside the `packages/core` directory. + +3. Export your Jules API key: + +```bash +export JULES_API_KEY="your-api-key-here" +``` + +## Running the Example: Cloud Compute Tasks + +The primary utility included in this example is the `run` command. Instead of just talking to an LLM, this tool treats the Jules session as a sandbox where an autonomous agent can **write and execute Python or Node.js scripts**. + +You can pass a local file to the cloud container, instruct the compute instance to run complex analysis, scrape websites, or convert data formats, and it will write the final processed file back to your local machine. + +### Bold Use Cases +- **Data Analysis**: `run --input "sales.csv" --instruction "Use Python pandas to aggregate sales by month and calculate the moving average." --output-file "report.json"` +- **Web Scraping**: `run --instruction "Write a Node.js puppeteer script to scrape the headlines from news.ycombinator.com and output them as JSON." --output-file "hn.json"` +- **Format Conversion**: `run --input "old_config.xml" --instruction "Write a python script to parse this XML and convert it to a modern YAML structure." --output-file "new_config.yaml"` + +### Human DX + +You can run the CLI tool passing standard flags. + +```bash +bun run index.ts run \ + --input="./raw_data.csv" \ + --instruction="Use python pandas to clean the missing values and output as JSON." \ + --output-file="./cleaned_data.json" +``` + +View the help text: + +```bash +bun run index.ts --help +bun run index.ts run --help +``` + +### Agent DX + +Agents are prone to hallucination when creating strings but are very good at forming JSON matching strict schemas. For best results, expose `--json` flags. + +```bash +bun run index.ts run --json='{"instruction": "Scrape the current temperature in Paris using a python script", "outputFile": "./temp.json"}' --output="json" +``` + +## Architecture + +This project splits its logic to avoid monolithic file structures and merge conflicts: +- **`index.ts`**: The auto-discovery entry point that dynamically mounts available sub-commands. +- **`commands/*/spec.ts`**: The Zod schema defining the strict Typed Service Contract for a tool. +- **`commands/*/handler.ts`**: The pure business logic that consumes the contract, maps local data into the cloud, extracts results, and never crashes directly. +- **`commands/*/index.ts`**: The `citty` command definition that parses flags and outputs data back to the environment. diff --git a/packages/core/examples/custom-cli/commands/run/handler.ts b/packages/core/examples/custom-cli/commands/run/handler.ts new file mode 100644 index 0000000..51aca77 --- /dev/null +++ b/packages/core/examples/custom-cli/commands/run/handler.ts @@ -0,0 +1,155 @@ +import { jules } from '@google/jules-sdk'; +import { RunTaskRequest, RunTaskResponse, runTaskRequestSchema } from './spec.js'; +import { z } from 'zod'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** + * Treats Jules as a powerful, on-demand serverless compute instance. + * Sends local file context to a cloud environment where an AI agent + * runs scripts (e.g., Python scraping, data analysis), and writes the + * final processed output back to the local file system. + */ +export async function handleRunTaskRequest(input: unknown): Promise { + try { + // 1. Input Hardening + const validParams = runTaskRequestSchema.parse(input); + + if (!process.env.JULES_API_KEY) { + return { + status: 'error', + error: 'JULES_API_KEY environment variable is not set.', + }; + } + + let fileContext = ''; + + // 2. Local File Context Integration + if (validParams.inputFile) { + const inputFilePath = path.resolve(process.cwd(), validParams.inputFile); + try { + const content = await fs.readFile(inputFilePath, 'utf-8'); + const parsedPath = path.parse(inputFilePath); + + fileContext = ` +## Input Data +You have been provided with the following data from a local file named \`${parsedPath.base}\`: + +\`\`\` +${content} +\`\`\` + `; + } catch (e: any) { + return { + status: 'error', + error: `Failed to read input file at ${inputFilePath}: ${e.message}`, + }; + } + } + + // 3. Formulate the "Serverless Compute" Prompt + // We strictly instruct the agent on its environment capabilities and output constraints. + const EXPECTED_OUTPUT_FILE = 'final_output.txt'; + const prompt = ` +You are an autonomous Cloud Compute Agent operating within a secure serverless container. +You have access to a full Linux environment with Node.js, Python, Rust, and Bun installed. +You have unrestricted outbound internet access. + +## Your Objective +${validParams.instruction} +${fileContext} + +## Execution Rules +1. You may write and execute scripts (e.g., Python, Node) to solve this objective. This includes scraping websites, processing data, querying APIs, or running analysis. +2. DO NOT just write the script and ask me to run it. YOU MUST run the script yourself in your container to get the final result. +3. Install any necessary dependencies using your environment's package managers (npm, pip). +4. Once you have the final, processed result for the user's objective, you MUST write that final text/JSON result to a file named \`${EXPECTED_OUTPUT_FILE}\` in your current working directory. +5. Do not include conversational filler in \`${EXPECTED_OUTPUT_FILE}\`, only the exact output requested by the objective. + +Remember: The success of this objective relies entirely on you generating and populating \`${EXPECTED_OUTPUT_FILE}\`. + `; + + // 4. Delegate to the Jules SDK Cloud Session + const session = await jules.session({ prompt }); + const outcome = await session.result(); + + if (outcome.state !== 'completed') { + return { + status: 'error', + error: `The cloud compute session failed or timed out. Status: ${outcome.state}`, + }; + } + + // 5. Retrieve the requested output file + const files = outcome.generatedFiles(); + let finalOutputContent: string | null = null; + + if (files.has(EXPECTED_OUTPUT_FILE)) { + finalOutputContent = files.get(EXPECTED_OUTPUT_FILE)!.content; + } else { + // Fallback: search for any generated file if the agent ignored instructions + if (files.size > 0) { + const firstFile = Array.from(files.values())[0]; + finalOutputContent = firstFile.content; + } else { + // Fallback 2: Check messages if the agent just messaged the response instead of writing to disk + const snapshot = await session.snapshot(); + const agentMessages = snapshot.activities + .filter((a: any) => a.type === 'agentMessaged') + .sort((a: any, b: any) => new Date(b.createTime).getTime() - new Date(a.createTime).getTime()); + + if (agentMessages.length > 0) { + finalOutputContent = agentMessages[0].message; + } + } + } + + if (!finalOutputContent) { + return { + status: 'error', + error: `Cloud compute session completed but failed to produce the expected output data.`, + }; + } + + // 6. Write to the local file system + const targetOutputPath = path.resolve(process.cwd(), validParams.outputFile); + + if (!validParams.dryRun) { + try { + await fs.writeFile(targetOutputPath, finalOutputContent, 'utf-8'); + } catch (e: any) { + return { + status: 'error', + error: `Failed to write output to ${targetOutputPath}: ${e.message}`, + }; + } + } + + return { + status: 'success', + message: validParams.dryRun + ? `[DRY-RUN] Would have written processed output to ${targetOutputPath}` + : `Successfully wrote processed output to ${targetOutputPath}`, + data: { + sessionId: session.id, + outputFile: targetOutputPath, + contentPreview: finalOutputContent.substring(0, 500) + (finalOutputContent.length > 500 ? '...' : ''), + dryRun: validParams.dryRun, + } + }; + + } catch (error) { + if (error instanceof z.ZodError) { + return { + status: 'error', + error: `Validation Error: ${error.message}`, + }; + } + + const errMsg = error instanceof Error ? error.message : String(error); + return { + status: 'error', + error: errMsg, + }; + } +} diff --git a/packages/core/examples/custom-cli/commands/run/index.ts b/packages/core/examples/custom-cli/commands/run/index.ts new file mode 100644 index 0000000..26151f0 --- /dev/null +++ b/packages/core/examples/custom-cli/commands/run/index.ts @@ -0,0 +1,94 @@ +import { defineCommand } from 'citty'; +import { handleRunTaskRequest } from './handler.js'; +import { niftty } from 'niftty'; + +export default defineCommand({ + meta: { + name: 'run', + description: 'Offloads complex tasks (web scraping, data analysis, scripting) to an autonomous serverless container.', + }, + args: { + json: { + type: 'string', + description: 'Raw JSON payload mapped directly to the API schema.', + }, + output: { + type: 'string', + description: 'Format of the output (e.g., "json" or "text"). Defaults to text for humans, but "json" is critical for agents.', + default: 'text', + }, + instruction: { + type: 'string', + description: 'A description of the complex task or script you want the compute instance to execute in the cloud.', + }, + input: { + type: 'string', + description: 'Optional path to a local file containing data you want to send to the compute instance.', + }, + 'output-file': { + type: 'string', + description: 'Path where the compute instance should save the final processed result locally.', + }, + 'dry-run': { + type: 'boolean', + description: 'Execute the compute instance and fetch the result, but do not write it to the local disk.', + default: false, + }, + }, + async run({ args }) { + let payload: any = {}; + + // Favor raw JSON payloads for agent predictability + if (args.json) { + try { + payload = JSON.parse(args.json); + } catch (err) { + console.error(JSON.stringify({ status: 'error', error: 'Invalid JSON payload format' })); + process.exit(1); + } + } else if (args.instruction && args['output-file']) { + payload.instruction = args.instruction; + payload.outputFile = args['output-file']; + if (args.input) payload.inputFile = args.input; + if (args['dry-run']) payload.dryRun = true; + } else { + console.error(JSON.stringify({ status: 'error', error: 'Must provide either --json or both --instruction and --output-file' })); + process.exit(1); + } + + const isJsonOutput = args.output === 'json' || process.env.OUTPUT_FORMAT === 'json'; + + if (!isJsonOutput) { + console.log(`\n☁️ Sending task to Cloud Compute container...\n`); + if (payload.inputFile) { + console.log(`Uploading local context: ${payload.inputFile}`); + } + console.log(`Waiting for serverless execution to run scripts and return final output...\n`); + } + + // Call the Typed Service Contract handler + const response = await handleRunTaskRequest(payload); + + if (isJsonOutput) { + // Agent DX: Provide deterministic, machine-readable JSON + console.log(JSON.stringify(response, null, 2)); + } else { + // Human DX: Render readable output + if (response.status === 'error') { + console.error(`Error: ${response.error}`); + process.exit(1); + } + + console.log(response.message); + + if (response.data?.contentPreview) { + console.log('\n--- Output Preview ---'); + console.log(niftty(`\`\`\`\n${response.data.contentPreview}\n\`\`\``)); + } + } + + if (response.status === 'error') { + process.exit(1); + } + }, +}); diff --git a/packages/core/examples/custom-cli/commands/run/spec.ts b/packages/core/examples/custom-cli/commands/run/spec.ts new file mode 100644 index 0000000..dadfe2f --- /dev/null +++ b/packages/core/examples/custom-cli/commands/run/spec.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const runTaskRequestSchema = z.object({ + instruction: z.string().min(1, 'Task instruction is required.'), + inputFile: z.string().optional(), + outputFile: z.string().min(1, 'Output file path is required to save the result.'), + timeoutMins: z.number().optional().default(5), + dryRun: z.boolean().optional().default(false), +}); + +export type RunTaskRequest = z.infer; + +export const runTaskResponseSchema = z.object({ + status: z.enum(['success', 'error']), + message: z.string().optional(), + data: z.object({ + outputFile: z.string().optional(), + sessionId: z.string().optional(), + contentPreview: z.string().optional(), + dryRun: z.boolean().optional(), + }).optional(), + error: z.string().optional(), +}); + +export type RunTaskResponse = z.infer; diff --git a/packages/core/examples/custom-cli/index.ts b/packages/core/examples/custom-cli/index.ts new file mode 100644 index 0000000..d696024 --- /dev/null +++ b/packages/core/examples/custom-cli/index.ts @@ -0,0 +1,53 @@ +import { defineCommand, runMain } from 'citty'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function loadCommands() { + const commandsDir = path.join(__dirname, 'commands'); + const commands: Record = {}; + + try { + const entries = await fs.readdir(commandsDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + const commandPath = path.join(commandsDir, entry.name, 'index.ts'); + + try { + await fs.access(commandPath); + const commandModule = await import(`./commands/${entry.name}/index.ts`); + if (commandModule.default) { + commands[entry.name] = commandModule.default; + } + } catch (e) { + // Ignore if index.ts doesn't exist in the folder + } + } + } + } catch (e) { + console.error('Failed to load commands:', e); + } + + return commands; +} + +async function start() { + const subCommands = await loadCommands(); + + const main = defineCommand({ + meta: { + name: 'jules-cli', + version: '1.0.0', + description: 'A custom AI CLI tool optimized for Agent DX using the Jules SDK', + }, + subCommands, + }); + + runMain(main); +} + +start(); diff --git a/packages/core/examples/custom-cli/package.json b/packages/core/examples/custom-cli/package.json new file mode 100644 index 0000000..a515651 --- /dev/null +++ b/packages/core/examples/custom-cli/package.json @@ -0,0 +1,15 @@ +{ + "name": "jules-custom-cli-example", + "version": "1.0.0", + "description": "Custom CLI Example for the Jules SDK", + "type": "module", + "scripts": { + "start": "bun run index.ts" + }, + "dependencies": { + "@google/jules-sdk": "workspace:*", + "citty": "^0.1.6", + "niftty": "^0.1.3", + "zod": "^3.24.0" + } +}