diff --git a/bun.lock b/bun.lock index 72b46b8..340135a 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", @@ -65,6 +65,14 @@ "typescript": "^5.0.0", }, }, + "packages/core/examples/gitpatch-local": { + "name": "gitpatch-local-example", + "dependencies": { + "citty": "^0.2.1", + "zod": "^4.3.6", + "zod-to-json-schema": "^3.25.1", + }, + }, "packages/core/examples/webhook": { "name": "webhook", "version": "1.0.0", @@ -79,7 +87,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 +113,7 @@ }, "packages/mcp": { "name": "@google/jules-mcp", - "version": "0.1.0", + "version": "0.2.0", "bin": { "jules-mcp": "./dist/cli.mjs", }, @@ -697,6 +705,8 @@ "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + "gitpatch-local-example": ["gitpatch-local-example@workspace:packages/core/examples/gitpatch-local"], + "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], @@ -1145,8 +1155,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=="], @@ -1213,6 +1229,10 @@ "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "gitpatch-local-example/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], + + "gitpatch-local-example/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "jules-github-actions-example/@types/node": ["@types/node@20.19.37", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw=="], "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], @@ -1251,6 +1271,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..b8c5da7 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -11,6 +11,17 @@ 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) +- [Ai Sdk Integration](./examples/ai-sdk/README.md) +- [Nextjs Integration](./examples/nextjs/README.md) +- [Custom Mcp Server](./examples/custom-mcp-server/README.md) +- [Custom Cli Tools](./examples/custom-cli/README.md) +- [Github Action Agentskills](./examples/github-action-agentskills/README.md) +- [Gitpatch Improve](./examples/gitpatch-improve/README.md) +- [Google Sheets Context](./examples/google-sheets/README.md) +- [Cloudflare Workers](./examples/cloudflare-workers/README.md) +- [Gitpatch Goals](./examples/gitpatch-goals/README.md) +- [Gitpatch Review](./examples/gitpatch-review/README.md) +- [Gitpatch Local](./examples/gitpatch-local/README.md) ## Send work to a Cloud based session @@ -324,3 +335,4 @@ Apache-2.0 > **Note:** This is not an officially supported Google product. This project is not eligible for the [Google Open Source Software Vulnerability Rewards Program](https://bughunters.google.com/open-source-security). + diff --git a/packages/core/examples/ai-sdk/CONTEXT.md b/packages/core/examples/ai-sdk/CONTEXT.md new file mode 100644 index 0000000..9669512 --- /dev/null +++ b/packages/core/examples/ai-sdk/CONTEXT.md @@ -0,0 +1,13 @@ +# Agent Context Guidelines + +This file is explicitly for AI/LLM Agents acting as operators or integrators against this CLI. + +## Overview + +You are invoking the `ai-sdk-example` CLI. This CLI adheres to Agent DX best practices to ensure predictability and defense-in-depth logic. You are interacting with the entrypoints, primarily the `start` command. + +## Invariants + +1. **Output formatting:** Always specify `--output json` when executing the CLI to guarantee machine-readable output rather than human-readable text. Do not rely on stdout text parsing. +2. **Mutations:** Jules sessions represent large, complex mutating cloud tasks. If you are verifying parameters, assessing feasibility, or unsure about an input string, ALWAYS run `--dry-run` first to test the system boundary. +3. **Adversarial inputs:** Be aware that the `JulesCodingTaskSpec` strict schema parsing handles your inputs. Path traversals (`../`), query strings (`?`), fragments (`#`), control chars, or pre-URL encoded characters will be strictly rejected. Ensure the inputs are clean before calling the tool. diff --git a/packages/core/examples/ai-sdk/README.md b/packages/core/examples/ai-sdk/README.md new file mode 100644 index 0000000..106ae4f --- /dev/null +++ b/packages/core/examples/ai-sdk/README.md @@ -0,0 +1,44 @@ +# Vercel AI SDK Integration Example (Agent DX CLI) + +This example demonstrates how to integrate the Jules SDK with the Vercel AI SDK to provide AI-powered coding capabilities within an AI application. + +Following **Agent DX best practices**, this example is packaged as a CLI built with `citty`. It utilizes the `generateText` function from the `ai` package and Google's `gemini-3.1-flash-lite-preview` model. + +The AI is given a composable, isolated tool called `executeCodingTask` that internally uses the Jules SDK to spin up a cloud environment and perform complex coding tasks. The tool is implemented using the **Typed Service Contract** pattern, providing rigorous type safety, input parsing (via Zod), and explicit error handling (Result Pattern). + +## Prerequisites + +- Node.js or Bun installed. +- A Jules API Key. Set it using: + ```bash + export JULES_API_KEY= + ``` +- A Google Generative AI API Key. Set it using: + ```bash + export GOOGLE_GENERATIVE_AI_API_KEY= + ``` + +## Running the Example + +You can run this example using `bun`: + +### Standard Human-Friendly Output +```bash +bun start start --prompt "Fix visibility issues by changing background colors to a zinc palette." --repo "your-org/your-repo" +``` + +### Agent-Friendly JSON Output (Agent DX) +```bash +bun start start --prompt "Fix visibility issues." --output json +``` + +## Architecture + +This project is structured for predictability and minimizing merge conflicts: + +1. **CLI Entrypoint (`src/cli.ts`)**: Minimal registration boundary built with `citty`. It lazily registers subcommands (e.g. `start`) to avoid central hotspots as the CLI scales. +2. **Command Modules (`src/commands/*.ts`)**: Isolated entrypoints for flags, CLI argument parsing, environment variable logic checks, and selecting the output format (`--output json`). +3. **Services (`src/services/agent.ts`)**: Encapsulates the Vercel AI SDK logic (`generateText` with `@ai-sdk/google`) to abstract the specific LLM interactions away from the CLI layer. +4. **Tool Spec (`src/tools/jules-coding-task/spec.ts`)**: The Contract boundary. Parses tool input using Zod and defines a strict `Result` return type. +5. **Tool Handler (`src/tools/jules-coding-task/handler.ts`)**: The impure business logic. It initiates `jules.session()`, waits for the session to complete, and evaluates the `session.result()`. It *never* throws errors. +6. **Tool Wrapper (`src/tools/jules-coding-task/index.ts`)**: Maps the typed contract into a standard Vercel AI SDK `tool()` wrapper. diff --git a/packages/core/examples/ai-sdk/package.json b/packages/core/examples/ai-sdk/package.json new file mode 100644 index 0000000..224dabd --- /dev/null +++ b/packages/core/examples/ai-sdk/package.json @@ -0,0 +1,16 @@ +{ + "name": "ai-sdk-example", + "version": "1.0.0", + "description": "An example integrating Vercel AI SDK with Jules SDK", + "type": "module", + "scripts": { + "start": "bun run src/cli.ts" + }, + "dependencies": { + "@ai-sdk/google": "^1.1.17", + "@google/jules-sdk": "workspace:*", + "ai": "^4.1.45", + "citty": "^0.1.6", + "zod": "^3.24.2" + } +} diff --git a/packages/core/examples/ai-sdk/src/cli.ts b/packages/core/examples/ai-sdk/src/cli.ts new file mode 100644 index 0000000..d076385 --- /dev/null +++ b/packages/core/examples/ai-sdk/src/cli.ts @@ -0,0 +1,15 @@ +import { defineCommand, runMain } from 'citty'; + +const main = defineCommand({ + meta: { + name: 'ai-sdk-example', + version: '1.0.0', + description: + 'A CLI demonstrating Vercel AI SDK integration with Jules SDK using Agent DX principles.', + }, + subCommands: { + start: () => import('./commands/start.js').then((m) => m.default), + }, +}); + +runMain(main); diff --git a/packages/core/examples/ai-sdk/src/commands/start.ts b/packages/core/examples/ai-sdk/src/commands/start.ts new file mode 100644 index 0000000..537c2b8 --- /dev/null +++ b/packages/core/examples/ai-sdk/src/commands/start.ts @@ -0,0 +1,91 @@ +import { defineCommand } from 'citty'; +import { runAgent } from '../services/agent.js'; + +export default defineCommand({ + meta: { + name: 'start', + description: 'Start an agent session to handle a coding prompt.', + }, + args: { + prompt: { + type: 'string', + description: 'The coding prompt to feed the AI.', + required: true, + }, + output: { + type: 'string', + description: 'Output format (json or text). Use json for agents.', + default: 'text', + }, + repo: { + type: 'string', + description: 'Optional GitHub repository (e.g. "owner/repo").', + required: false, + }, + 'dry-run': { + type: 'boolean', + description: 'Validate input and logic without creating a real cloud session.', + default: false, + }, + }, + async run({ args }) { + // 1. Logic Checks: Validate Environment Context explicitly before attempting external operations + if (!process.env.JULES_API_KEY) { + console.error('Error: JULES_API_KEY environment variable is missing.'); + process.exit(1); + } + + if (!process.env.GOOGLE_GENERATIVE_AI_API_KEY) { + console.error('Error: GOOGLE_GENERATIVE_AI_API_KEY environment variable is missing.'); + process.exit(1); + } + + // 2. Encapsulate execution in the service abstraction + const response = await runAgent({ + prompt: args.prompt, + repo: args.repo, + dryRun: args['dry-run'], + }); + + // 3. Render payload strictly conforming to output format expectation (Agent DX vs Human) + if (args.output === 'json') { + if (response.success) { + console.log( + JSON.stringify( + { + success: true, + result: response.result, + toolCalls: response.toolCalls, + }, + null, + 2, + ), + ); + } else { + console.error( + JSON.stringify( + { + success: false, + error: response.error, + }, + null, + 2, + ), + ); + process.exit(1); + } + } else { + if (response.success) { + console.log('\n--- Final Response from AI ---'); + console.log(response.result); + if (response.toolCalls && response.toolCalls.length > 0) { + console.log('\n--- Tools Invoked ---'); + response.toolCalls.forEach((c) => console.log(`- ${c.name}`)); + } + } else { + console.error('Execution Failed:', response.error); + process.exit(1); + } + } + }, +}); diff --git a/packages/core/examples/ai-sdk/src/services/agent.ts b/packages/core/examples/ai-sdk/src/services/agent.ts new file mode 100644 index 0000000..a0be0bc --- /dev/null +++ b/packages/core/examples/ai-sdk/src/services/agent.ts @@ -0,0 +1,51 @@ +import { generateText } from 'ai'; +import { google } from '@ai-sdk/google'; +import { executeCodingTask } from '../tools/jules-coding-task/index.js'; + +export interface AgentRequest { + prompt: string; + repo?: string; + dryRun?: boolean; +} + +export interface AgentResponse { + success: boolean; + result?: string; + toolCalls?: Array<{ name: string; args: any }>; + error?: string; +} + +/** + * Encapsulates the Vercel AI SDK logic. + * This service handles calling the LLM and managing available tools. + */ +export async function runAgent(request: AgentRequest): Promise { + const contextPrompt = request.repo + ? `Task: ${request.prompt}\nContext: Apply this task to the repository "${request.repo}".` + : `Task: ${request.prompt}`; + + try { + const { text, toolCalls } = await generateText({ + model: google('gemini-3.1-flash-lite-preview'), + system: request.dryRun + ? "You are in dry-run mode. ALWAYS pass dryRun: true to any tools you execute." + : "", + prompt: contextPrompt, + tools: { + executeCodingTask, + }, + maxSteps: 2, + }); + + return { + success: true, + result: text, + toolCalls: toolCalls?.map((c) => ({ name: c.toolName, args: c.args })) || [], + }; + } catch (error: any) { + return { + success: false, + error: error.message || String(error), + }; + } +} diff --git a/packages/core/examples/ai-sdk/src/tools/jules-coding-task/handler.ts b/packages/core/examples/ai-sdk/src/tools/jules-coding-task/handler.ts new file mode 100644 index 0000000..d81c408 --- /dev/null +++ b/packages/core/examples/ai-sdk/src/tools/jules-coding-task/handler.ts @@ -0,0 +1,83 @@ +import { jules } from '@google/jules-sdk'; +import { JulesCodingTaskSpec, JulesCodingTaskInput, JulesCodingTaskResult } from './spec.js'; + +export class JulesCodingTaskHandler implements JulesCodingTaskSpec { + async execute(input: JulesCodingTaskInput): Promise { + try { + if (!process.env.JULES_API_KEY) { + return { + success: false, + error: { + code: 'MISSING_CREDENTIALS', + message: 'JULES_API_KEY environment variable is missing.', + recoverable: false, + }, + }; + } + + const sessionOptions: any = { + prompt: input.prompt, + }; + + if (input.githubRepo) { + sessionOptions.source = { + github: input.githubRepo, + baseBranch: input.baseBranch || 'main', + }; + sessionOptions.autoPr = true; + } + + // Agent DX: Dry run validation intercept + if (input.dryRun) { + return { + success: true, + data: { + sessionId: "dry-run-session", + state: "succeeded", + pullRequestUrl: "https://github.com/dry-run/mock-pr", + generatedFilesCount: 0, + } + }; + } + + // Create and start the Jules session + const session = await jules.session(sessionOptions); + + // Await final result (do not use cache/select for the current running task) + const outcome = await session.result(); + + if (outcome.state === 'failed') { + return { + success: false, + error: { + code: 'SESSION_FAILED', + message: `Jules session failed. Session ID: ${session.id}`, + recoverable: false, + }, + }; + } + + const prUrl = outcome.pullRequest?.url; + const filesCount = outcome.generatedFiles().size; + + return { + success: true, + data: { + sessionId: session.id, + state: outcome.state, + pullRequestUrl: prUrl, + generatedFilesCount: filesCount, + }, + }; + } catch (error) { + return { + success: false, + error: { + code: 'UNKNOWN_ERROR', + message: error instanceof Error ? error.message : String(error), + recoverable: false, + }, + }; + } + } +} diff --git a/packages/core/examples/ai-sdk/src/tools/jules-coding-task/index.ts b/packages/core/examples/ai-sdk/src/tools/jules-coding-task/index.ts new file mode 100644 index 0000000..09f01e6 --- /dev/null +++ b/packages/core/examples/ai-sdk/src/tools/jules-coding-task/index.ts @@ -0,0 +1,23 @@ +import { tool } from 'ai'; +import { JulesCodingTaskInputSchema } from './spec.js'; +import { JulesCodingTaskHandler } from './handler.js'; + +export const executeCodingTask = tool({ + description: 'Executes a complex coding task in an ephemeral cloud environment and returns the result (like a PR URL).', + parameters: JulesCodingTaskInputSchema, + execute: async (input) => { + const handler = new JulesCodingTaskHandler(); + const result = await handler.execute(input); + + // Provide output formatting tailored to the AI context to easily parse the final success/failure + if (!result.success) { + return `Failed: ${result.error.code} - ${result.error.message}`; + } + + if (result.data.pullRequestUrl) { + return `Success: Task completed. PR Created at ${result.data.pullRequestUrl}`; + } + + return `Success: Task completed. ${result.data.generatedFilesCount} files generated in repoless session. Session ID: ${result.data.sessionId}`; + }, +}); diff --git a/packages/core/examples/ai-sdk/src/tools/jules-coding-task/spec.ts b/packages/core/examples/ai-sdk/src/tools/jules-coding-task/spec.ts new file mode 100644 index 0000000..aa5eb20 --- /dev/null +++ b/packages/core/examples/ai-sdk/src/tools/jules-coding-task/spec.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; + +// 1. INPUT HARDENING (Agent DX) +// Agents hallucinate and pass malformed inputs. We validate strictly at the boundary. +const SafeStringSchema = z.string() + .refine(s => !/[\x00-\x1F]/.test(s), "Control characters are not allowed") + .refine(s => !s.includes('%'), "Pre-URL encoded strings are not allowed"); + +const SafeRepoSchema = SafeStringSchema + .refine(r => !r.includes('..'), "Path traversals are not allowed in repo names") + .refine(r => !r.includes('?'), "Query parameters are not allowed in repo names") + .refine(r => !r.includes('#'), "Fragments are not allowed in repo names") + .refine(r => r.split('/').length === 2, "Repo must be in the format owner/repo"); + +export const JulesCodingTaskInputSchema = z.object({ + prompt: SafeStringSchema.describe('Detailed instructions for the coding task, including what needs to be changed.'), + githubRepo: SafeRepoSchema.optional().describe('The GitHub repository in the format "owner/repo" (e.g. "google/jules-sdk"). If omitted, it runs a repoless session.'), + baseBranch: SafeStringSchema.optional().describe('The base branch to make the changes against. Defaults to "main" if repo provided.'), + dryRun: z.boolean().default(false).describe('If true, validates the input and returns a success message without actually creating a Jules session.'), +}); + +export type JulesCodingTaskInput = z.infer; + +// 2. ERROR CODES +export const JulesCodingTaskErrorCode = z.enum([ + 'SESSION_FAILED', + 'MISSING_CREDENTIALS', + 'INVALID_INPUT', + 'UNKNOWN_ERROR' +]); + +// 3. RESULT +export const JulesCodingTaskSuccess = z.object({ + success: z.literal(true), + data: z.object({ + sessionId: z.string(), + state: z.string(), + pullRequestUrl: z.string().optional(), + generatedFilesCount: z.number().optional(), + }), +}); + +export const JulesCodingTaskFailure = z.object({ + success: z.literal(false), + error: z.object({ + code: JulesCodingTaskErrorCode, + message: z.string(), + recoverable: z.boolean(), + }) +}); + +export type JulesCodingTaskResult = + | z.infer + | z.infer; + +// 4. INTERFACE +export interface JulesCodingTaskSpec { + execute(input: JulesCodingTaskInput): Promise; +} diff --git a/packages/core/examples/cloudflare-workers/README.md b/packages/core/examples/cloudflare-workers/README.md new file mode 100644 index 0000000..c98c10c --- /dev/null +++ b/packages/core/examples/cloudflare-workers/README.md @@ -0,0 +1,34 @@ +# Cloudflare Workers Example + +This example demonstrates how to use the Jules SDK within a Cloudflare Worker environment. By intercepting incoming HTTP `POST` requests, the worker can automatically trigger and orchestrate a new coding session through the `@google/jules-sdk`. + +This is especially useful for edge-based event processing (e.g., handling incoming webhooks from external services like Stripe or GitHub directly at the edge) and kicking off agent workflows globally without dedicated infrastructure. + +## Prerequisites + +- Ensure you have [Bun](https://bun.sh/) installed, or another compatible runtime like Node.js. +- Ensure your `JULES_API_KEY` is set as an environment variable in your local shell or your Cloudflare environment bindings. + +## Running the Example Locally + +The example uses a mocked entry point for the Cloudflare worker module via `index.ts`. To ensure it builds and can run basic checks in the monorepo context: + +1. Build the module: + + ```bash + bun run build + ``` + +2. To run the handler script as a standard Bun process (noting it simulates the Worker `fetch` function structure): + + ```bash + bun run start + ``` + +*(Note: While `bun run start` simply executes `index.ts`, a true worker testing environment typically requires `wrangler` and a test server setup. This simple repository example demonstrates the SDK's structural integration.)* + +## Notes + +- In a real-world Cloudflare deployment, the `JULES_API_KEY` should be set via Cloudflare secrets using `wrangler secret put JULES_API_KEY`. It would be available on the `env` object inside the `fetch` handler. +- If your environment provides the API key via `env.JULES_API_KEY` rather than the global `process.env`, you can customize the instantiation using `jules.with({ apiKey: env.JULES_API_KEY })`. +- Make sure to modify the target `source` in `index.ts` to match the specific GitHub repository or branching strategy your worker intends to automate. diff --git a/packages/core/examples/cloudflare-workers/index.ts b/packages/core/examples/cloudflare-workers/index.ts new file mode 100644 index 0000000..fae8cd8 --- /dev/null +++ b/packages/core/examples/cloudflare-workers/index.ts @@ -0,0 +1,64 @@ +import { jules } from '@google/jules-sdk'; + +/** + * Cloudflare Worker Example + * + * This example demonstrates how to use the Jules SDK within a Cloudflare Worker environment. + * The worker intercepts incoming HTTP POST requests (e.g., a webhook or custom event trigger) + * and starts a new Jules coding session. + */ +export default { + async fetch(request: Request, env: any, ctx: any): Promise { + // Only accept POST requests for this example + if (request.method !== 'POST') { + return new Response('Method Not Allowed. Please send a POST request.', { status: 405 }); + } + + try { + // Parse the incoming JSON payload + const payload = await request.json().catch(() => ({})); + console.log('Received payload:', payload); + + // We can use the global `jules` object since JULES_API_KEY should be passed as an environment variable + // or configured globally in the environment (e.g. via .env in local dev or Cloudflare bindings). + // Note: If JULES_API_KEY is not available globally, you can construct a custom instance + // of Jules SDK using `jules.with({ apiKey: env.JULES_API_KEY })`. + + // Construct a prompt dynamically from the payload + const promptText = `Process this event triggered from a Cloudflare Worker: ${JSON.stringify(payload)}`; + + // Start a Jules session + const session = await jules.session({ + prompt: promptText, + // Define a target source context (replace with your repository/branch as needed) + source: { github: 'davideast/dataprompt', baseBranch: 'main' }, + }); + + console.log(`Successfully created Jules session: ${session.id}`); + + // Return a successful JSON response with the created session ID + return new Response(JSON.stringify({ + success: true, + message: 'Cloudflare Worker processed event and created a session.', + sessionId: session.id, + }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); + + } catch (error) { + console.error('Error creating session:', error); + + return new Response(JSON.stringify({ + success: false, + message: 'Internal Server Error while creating Jules session', + error: error instanceof Error ? error.message : String(error) + }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + }, +}; diff --git a/packages/core/examples/cloudflare-workers/package.json b/packages/core/examples/cloudflare-workers/package.json new file mode 100644 index 0000000..b0bd7ca --- /dev/null +++ b/packages/core/examples/cloudflare-workers/package.json @@ -0,0 +1,16 @@ +{ + "name": "cloudflare-workers", + "version": "1.0.0", + "description": "Example demonstrating how to use the Jules SDK within a Cloudflare Worker.", + "main": "index.ts", + "scripts": { + "build": "bun build index.ts --target=node", + "start": "bun run index.ts" + }, + "dependencies": { + "@google/jules-sdk": "workspace:*" + }, + "devDependencies": { + "bun-types": "^1.1.8" + } +} \ No newline at end of file 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" + } +} diff --git a/packages/core/examples/custom-mcp-server/README.md b/packages/core/examples/custom-mcp-server/README.md new file mode 100644 index 0000000..8a233cb --- /dev/null +++ b/packages/core/examples/custom-mcp-server/README.md @@ -0,0 +1,42 @@ +# Custom MCP Server CLI Example + +This example demonstrates how to create a custom MCP (Model Context Protocol) server wrapped as a CLI tool using the Jules TypeScript SDK, `citty`, and the **Typed Service Contract** pattern. + +Instead of basic data forwarding, it provides an `analyze_session` tool. It hydrates a Jules session snapshot, extracting the actual file states and final AI context, avoiding partial cache-only issues. + +## Setup and Running + +1. Ensure your `JULES_API_KEY` is set in your environment: + ```bash + export JULES_API_KEY="your-jules-api-key" + ``` + +2. Run the example using Bun (or another TypeScript runner like `tsx`): + ```bash + bun run index.ts + ``` + +## Integration + +You can add this server to your local MCP client configuration (e.g., Claude Desktop, Zed, or a VS Code MCP extension) to allow it to utilize Jules' agentic capabilities directly from your IDE or chat client. + +Example Claude Desktop Config: +```json +{ + "mcpServers": { + "jules-custom-cli": { + "command": "bun", + "args": ["run", "/absolute/path/to/this/example/index.ts"], + "env": { + "JULES_API_KEY": "your-api-key" + } + } + } +} +``` + +## Typed Service Contract Pattern + +This CLI implements the Vertical Slice Architecture using a strict `Spec` and `Handler` pattern in `src/commands/session-analysis/`: +- **spec.ts**: Defines the input Schema using Zod (parsing input, enforcing boundaries), exhaustively declares error codes, and strictly types the return interface as a Discriminated Union `Result`. +- **handler.ts**: The impure execution context. It implements the interface, interacts with the Jules SDK network layer, and maps runtime and API errors to the defined Spec failures instead of throwing them wildly. diff --git a/packages/core/examples/custom-mcp-server/index.ts b/packages/core/examples/custom-mcp-server/index.ts new file mode 100644 index 0000000..a1c262c --- /dev/null +++ b/packages/core/examples/custom-mcp-server/index.ts @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import { defineCommand, runMain } from 'citty'; +import { runMcpServer } from './src/index.js'; + +const main = defineCommand({ + meta: { + name: 'jules-mcp-cli', + version: '1.0.0', + description: 'A Custom Jules MCP Server built as a CLI tool using citty and Typed Service Contracts.', + }, + args: { + port: { + type: 'string', + description: 'Port for alternative transports (currently uses stdio)', + }, + }, + async run({ args }) { + await runMcpServer(); + }, +}); + +runMain(main).catch((error) => { + console.error('Fatal CLI Error:', error); + process.exit(1); +}); diff --git a/packages/core/examples/custom-mcp-server/package.json b/packages/core/examples/custom-mcp-server/package.json new file mode 100644 index 0000000..a7c76a2 --- /dev/null +++ b/packages/core/examples/custom-mcp-server/package.json @@ -0,0 +1,19 @@ +{ + "name": "custom-mcp-server-example", + "version": "1.0.0", + "description": "Example demonstrating how to create a CLI custom MCP server with the Jules SDK", + "private": true, + "type": "module", + "bin": { + "jules-mcp-cli": "./index.ts" + }, + "scripts": { + "start": "bun run index.ts" + }, + "dependencies": { + "@google/jules-sdk": "workspace:*", + "@modelcontextprotocol/sdk": "^1.2.0", + "citty": "^0.2.1", + "zod": "^3.23.0" + } +} diff --git a/packages/core/examples/custom-mcp-server/src/commands/session-analysis/handler.ts b/packages/core/examples/custom-mcp-server/src/commands/session-analysis/handler.ts new file mode 100644 index 0000000..073101a --- /dev/null +++ b/packages/core/examples/custom-mcp-server/src/commands/session-analysis/handler.ts @@ -0,0 +1,65 @@ +import { SessionAnalysisSpec, SessionAnalysisInput, SessionAnalysisResult } from './spec.js'; +import { jules, JulesApiError } from '@google/jules-sdk'; + +export class SessionAnalysisHandler implements SessionAnalysisSpec { + async execute(input: SessionAnalysisInput): Promise { + try { + const session = jules.session(input.sessionId); + + // Load session and activities to avoid cache-only selection issues + const outcome = await session.info(); + + let lastAgentMessage: string | undefined = undefined; + const history = await session.history().toArray(); + const agentMessages = history.filter(a => a.type === 'agentMessaged'); + if (agentMessages.length > 0) { + lastAgentMessage = agentMessages[agentMessages.length - 1].message; + } + + // We rely on full session snapshot rather than partial cache queries + const snapshot = session.snapshot(); + const files = snapshot?.generatedFiles; + let generatedFilesCount = 0; + if (files) { + generatedFilesCount = typeof files.entries === 'function' ? [...files.entries()].length : Object.keys(files).length; + } + + let summary = `Session ${outcome.id} is ${outcome.state}.`; + if (history.length) { + summary += ` It consists of ${history.length} activities.`; + } + + return { + success: true, + data: { + id: outcome.id, + state: outcome.state, + summary, + totalActivities: history.length, + generatedFilesCount, + lastAgentMessage, + }, + }; + + } catch (error) { + if (error instanceof JulesApiError) { + return { + success: false, + error: { + code: 'API_ERROR', + message: error.message, + recoverable: false, + } + }; + } + return { + success: false, + error: { + code: 'UNKNOWN_ERROR', + message: error instanceof Error ? error.message : String(error), + recoverable: false, + }, + }; + } + } +} diff --git a/packages/core/examples/custom-mcp-server/src/commands/session-analysis/index.ts b/packages/core/examples/custom-mcp-server/src/commands/session-analysis/index.ts new file mode 100644 index 0000000..768ed7c --- /dev/null +++ b/packages/core/examples/custom-mcp-server/src/commands/session-analysis/index.ts @@ -0,0 +1,2 @@ +export * from './spec.js'; +export * from './handler.js'; diff --git a/packages/core/examples/custom-mcp-server/src/commands/session-analysis/spec.ts b/packages/core/examples/custom-mcp-server/src/commands/session-analysis/spec.ts new file mode 100644 index 0000000..edd83a6 --- /dev/null +++ b/packages/core/examples/custom-mcp-server/src/commands/session-analysis/spec.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +export const SessionAnalysisInputSchema = z.object({ + sessionId: z.string().min(1, 'Session ID is required').startsWith('jules:session:', 'Invalid Session ID format'), +}); + +export type SessionAnalysisInput = z.infer; + +export const SessionAnalysisErrorCode = z.enum([ + 'SESSION_NOT_FOUND', + 'API_ERROR', + 'UNAUTHORIZED', + 'UNKNOWN_ERROR', +]); + +export const SessionAnalysisSuccess = z.object({ + success: z.literal(true), + data: z.object({ + id: z.string(), + state: z.string(), + summary: z.string(), + totalActivities: z.number(), + generatedFilesCount: z.number(), + lastAgentMessage: z.string().optional(), + }), +}); + +export const SessionAnalysisFailure = z.object({ + success: z.literal(false), + error: z.object({ + code: SessionAnalysisErrorCode, + message: z.string(), + recoverable: z.boolean(), + }), +}); + +export type SessionAnalysisResult = + | z.infer + | z.infer; + +export interface SessionAnalysisSpec { + execute(input: SessionAnalysisInput): Promise; +} diff --git a/packages/core/examples/custom-mcp-server/src/index.ts b/packages/core/examples/custom-mcp-server/src/index.ts new file mode 100644 index 0000000..ff8b6a7 --- /dev/null +++ b/packages/core/examples/custom-mcp-server/src/index.ts @@ -0,0 +1,63 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import { SessionAnalysisHandler } from './commands/session-analysis/handler.js'; +import { SessionAnalysisInputSchema } from './commands/session-analysis/spec.js'; + +export async function runMcpServer() { + if (!process.env.JULES_API_KEY) { + console.error('Warning: JULES_API_KEY environment variable is missing.'); + } + + // 1. Initialize the MCP Server + const server = new McpServer({ + name: 'jules-custom-mcp-server', + version: '1.0.0', + }); + + // 2. Define tools mapped to handlers + server.tool( + 'analyze_session', + 'Provides a detailed analysis and context of a Jules Session, including states and generated artifacts. Replaces brittle local cache lookups with full hydration.', + { + sessionId: z.string().describe('The Jules session ID to analyze, e.g., jules:session:123'), + }, + async ({ sessionId }) => { + const parsedInput = SessionAnalysisInputSchema.safeParse({ sessionId }); + if (!parsedInput.success) { + return { + content: [{ type: 'text', text: `Validation Error: ${parsedInput.error.message}` }], + isError: true, + }; + } + + const handler = new SessionAnalysisHandler(); + const result = await handler.execute(parsedInput.data); + + if (!result.success) { + return { + content: [{ type: 'text', text: `Analysis Failed [${result.error.code}]: ${result.error.message}` }], + isError: true, + }; + } + + const { data } = result; + const formatted = [ + `Session Analysis: ${data.id}`, + `State: ${data.state}`, + `Summary: ${data.summary}`, + `Files Generated: ${data.generatedFilesCount}`, + data.lastAgentMessage ? `\nLast Agent Message:\n${data.lastAgentMessage}` : '', + ].filter(Boolean).join('\n'); + + return { + content: [{ type: 'text', text: formatted }], + }; + } + ); + + // 3. Start server + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Custom Jules MCP Server is running on stdio'); +} diff --git a/packages/core/examples/github-action-agentskills/README.md b/packages/core/examples/github-action-agentskills/README.md new file mode 100644 index 0000000..06b8a66 --- /dev/null +++ b/packages/core/examples/github-action-agentskills/README.md @@ -0,0 +1,55 @@ +# GitHub Action Agent Skills Example + +This example demonstrates how to use the Jules SDK on a scheduled GitHub Action cron job to analyze a repository and generate suggestions for Agent Skills that improve automation. + +## Overview + +The action reads the Agent Skills specification from [https://agentskills.io/specification.md](https://agentskills.io/specification.md) and instructs the Jules agent to: +1. Review the repository structure and workflows. +2. Identify areas where an Agent Skill could be beneficial. +3. Create corresponding Agent Skills configuration files. +4. Generate a pull request with the suggested changes. + +## Files + +- `index.ts`: The main action logic using the Jules SDK. +- `package.json`: Dependencies for the action (`@actions/core`, `@actions/github`, `@google/jules-sdk`). + +## Usage + +You can use this action in your own GitHub workflows by referencing its path or publishing it. + +Here is an example workflow file (`.github/workflows/jules-agentskills.yml`) that triggers the agent on a weekly schedule: + +```yaml +name: Jules Agent Skills Generator + +on: + schedule: + # Run at 00:00 every Monday + - cron: '0 0 * * 1' + +jobs: + run-jules-agentskills: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run Jules Agent Skills Analyzer + uses: ./packages/core/examples/github-action-agentskills + env: + JULES_API_KEY: ${{ secrets.JULES_API_KEY }} +``` + +## Running the Example Locally + +To compile the TypeScript action: + +```bash +cd packages/core/examples/github-action-agentskills +npm install +npm run build +``` + +The compiled JavaScript will be placed in `dist/index.js`, which is what GitHub Actions will execute. diff --git a/packages/core/examples/github-action-agentskills/action.yml b/packages/core/examples/github-action-agentskills/action.yml new file mode 100644 index 0000000..a94e918 --- /dev/null +++ b/packages/core/examples/github-action-agentskills/action.yml @@ -0,0 +1,5 @@ +name: 'Jules Agent Skills Generator' +description: 'Analyze repository for Agent Skills using a scheduled GitHub Action cron job' +runs: + using: 'node20' + main: 'dist/index.js' diff --git a/packages/core/examples/github-action-agentskills/index.ts b/packages/core/examples/github-action-agentskills/index.ts new file mode 100644 index 0000000..a455ce0 --- /dev/null +++ b/packages/core/examples/github-action-agentskills/index.ts @@ -0,0 +1,89 @@ +import * as core from '@actions/core'; +import * as github from '@actions/github'; +import { jules } from '@google/jules-sdk'; + +async function run() { + try { + if (!process.env.JULES_API_KEY) { + throw new Error('JULES_API_KEY environment variable is missing.'); + } + + // 1. Get context about the current repository from the GitHub Action payload + const context = github.context; + // In a cron job, context.repo will still point to the repository where the workflow is running + const owner = context.repo.owner || process.env.GITHUB_REPOSITORY?.split('/')[0] || 'unknown'; + const repo = context.repo.repo || process.env.GITHUB_REPOSITORY?.split('/')[1] || 'unknown'; + const ref = context.ref || process.env.GITHUB_REF || 'refs/heads/main'; + + let baseBranch = 'main'; + if (ref.startsWith('refs/heads/')) { + baseBranch = ref.replace('refs/heads/', ''); + } + + core.info(`Starting Jules session for ${owner}/${repo} on branch ${baseBranch}`); + + // 2. Construct the prompt for generating Agent Skills + const prompt = `Analyze this repository and suggest Agent Skills to improve automation of common or complex tasks. + +Use the Agent Skills specification located at https://agentskills.io/specification.md as a reference for formatting and structuring the skills. + +Tasks: +1. Review the repository structure, code, and existing workflows. +2. Identify 1 to 3 areas where an Agent Skill could be beneficial (e.g., code review, automated testing, boilerplate generation, or specific formatting rules). +3. Create the corresponding Agent Skills configuration files (e.g., in a \`.jules/skills\` directory or similar, as per the specification). +4. Provide a brief explanation of what each skill does and why it is useful for this repository.`; + + core.info(`Prompt: ${prompt}`); + + // 3. Create a new Jules session + const session = await jules.session({ + prompt: prompt, + source: { + github: `${owner}/${repo}`, + baseBranch: baseBranch, + }, + // Automatically create a PR when the session is complete + autoPr: true, + }); + + core.info(`Session created successfully. ID: ${session.id}`); + + // 4. Monitor the progress + for await (const activity of session.stream()) { + switch (activity.type) { + case 'planGenerated': + core.info(`[Plan Generated] ${activity.plan.steps.length} steps.`); + break; + case 'progressUpdated': + core.info(`[Progress Updated] ${activity.title}`); + break; + case 'sessionCompleted': + core.info(`[Session Completed]`); + break; + } + } + + // 5. Wait for the final outcome + const outcome = await session.result(); + + if (outcome.state === 'failed') { + core.setFailed(`Session failed.`); + return; + } + + core.info(`Session finished successfully.`); + + if (outcome.pullRequest) { + core.info(`Pull Request created: ${outcome.pullRequest.url}`); + core.setOutput('pr-url', outcome.pullRequest.url); + } + } catch (error) { + if (error instanceof Error) { + core.setFailed(`Action failed with error: ${error.message}`); + } else { + core.setFailed(`Action failed with an unknown error.`); + } + } +} + +run(); diff --git a/packages/core/examples/github-action-agentskills/package.json b/packages/core/examples/github-action-agentskills/package.json new file mode 100644 index 0000000..395dea4 --- /dev/null +++ b/packages/core/examples/github-action-agentskills/package.json @@ -0,0 +1,18 @@ +{ + "name": "jules-github-action-agentskills-example", + "version": "1.0.0", + "description": "Analyze repository for Agent Skills using a scheduled GitHub Action cron job", + "main": "dist/index.js", + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@actions/core": "^1.10.1", + "@actions/github": "^6.0.0", + "@google/jules-sdk": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } +} diff --git a/packages/core/examples/github-action-agentskills/tsconfig.json b/packages/core/examples/github-action-agentskills/tsconfig.json new file mode 100644 index 0000000..487a581 --- /dev/null +++ b/packages/core/examples/github-action-agentskills/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist" + }, + "include": ["index.ts"] +} diff --git a/packages/core/examples/gitpatch-goals/README.md b/packages/core/examples/gitpatch-goals/README.md new file mode 100644 index 0000000..e156910 --- /dev/null +++ b/packages/core/examples/gitpatch-goals/README.md @@ -0,0 +1,39 @@ +# GitPatch Goals Review Example + +This example demonstrates how to use the Jules SDK to evaluate the code generated by an AI agent. It uses a "Repoless" session to simulate code generation, then extracts the GitPatch (the diff of the generated code) and uses a second session to review the patch against the original prompt's goals and generic coding standards. + +## 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. + +2. Export your Jules API key: + +```bash +export JULES_API_KEY="your-api-key-here" +``` + +## Running the Example + +Using `bun`: + +```bash +bun run index.ts +``` + +Using `npm` and `tsx` (or similar TypeScript runner): + +```bash +npx tsx index.ts +``` + +## What it does + +1. **Generation Session**: Creates a session asking the agent to write a simple Node.js HTTP server. +2. **Patch Extraction**: Waits for the session to complete and extracts the generated code as a GitPatch. +3. **Review Session**: Creates a second session, providing the original prompt, the generated GitPatch, and instructions to evaluate if the code meets the goals and adheres to best practices. +4. **Result**: Outputs the review findings from the second agent. diff --git a/packages/core/examples/gitpatch-goals/SKILL.md b/packages/core/examples/gitpatch-goals/SKILL.md new file mode 100644 index 0000000..30cba74 --- /dev/null +++ b/packages/core/examples/gitpatch-goals/SKILL.md @@ -0,0 +1,28 @@ +--- +name: gitpatch-goals-cli +description: Evaluates generated code via GitPatch against original goals and coding standards. +version: 1.0.0 +--- + +# GitPatch Goals CLI + +This CLI uses Jules sessions to simulate code generation, extracts the resulting code as a GitPatch, and feeds it to a second Jules session to evaluate if it successfully met the original prompt's goals and adheres to coding standards. + +## Usage Guidelines for AI Agents + +When invoking this CLI, adhere to the following best practices: + +1. **Schema Introspection**: You can introspect the required arguments and schema at runtime by passing `--describe`. + ```bash + bun run index.ts --describe + ``` + +2. **Context Window Discipline**: Use `--json` for predictable, deterministic, machine-readable output. Avoid parsing raw terminal stdout. + ```bash + bun run index.ts --prompt "Create an API" --json + ``` + +3. **Input Hardening**: Before executing mutations or relying on long-running APIs (like creating Jules Sessions), validate your payload using the `--dry-run` flag to ensure the CLI safely accepts your arguments without executing side effects. + ```bash + bun run index.ts --prompt "Create an API" --dry-run + ``` diff --git a/packages/core/examples/gitpatch-goals/generate.ts b/packages/core/examples/gitpatch-goals/generate.ts new file mode 100644 index 0000000..369b64d --- /dev/null +++ b/packages/core/examples/gitpatch-goals/generate.ts @@ -0,0 +1,36 @@ +import { jules, SessionClient } from '@google/jules-sdk'; + +/** + * Initiates the generation session and streams progress updates back + * to the caller until the session completes. + */ +export async function generateCode(prompt: string): Promise { + console.error('--- Step 1: Initiating Code Generation Session ---'); + + // Repoless sessions don't always create the resource instantly, + // we must await the outcome state for streaming to reliably start without 404ing activities + const session = await jules.session({ prompt }); + + console.error(`Generation Session created! ID: ${session.id}`); + console.error('Streaming agent progress...\n'); + + try { + for await (const activity of session.stream()) { + if (activity.type === 'progressUpdated') { + console.error(`[Generation] ${activity.title}`); + } else if (activity.type === 'agentMessaged') { + console.error(`[Generation Agent]: ${activity.message.substring(0, 100)}...`); + } else if (activity.type === 'sessionCompleted') { + console.error('[Generation] Session complete.'); + } else if (activity.type === 'sessionFailed') { + console.error('[Generation] Session failed.'); + } + } + } catch (err) { + // A 404 indicates the activities sub-collection might not be ready yet. + // The safest fallback is waiting for the result. + console.error('[Generation] Streaming not available yet. Waiting for completion...'); + } + + return session; +} diff --git a/packages/core/examples/gitpatch-goals/handler.ts b/packages/core/examples/gitpatch-goals/handler.ts new file mode 100644 index 0000000..eb0c3cd --- /dev/null +++ b/packages/core/examples/gitpatch-goals/handler.ts @@ -0,0 +1,102 @@ +import { ReviewInput, ReviewResult, ReviewSpec } from './spec.js'; +import { generateCode } from './generate.js'; +import { extractGitPatch } from './patch.js'; +import { reviewCode } from './review.js'; + +/** + * Orchestrator Handler that runs the generation and review workflow. + * Uses Typed Service Contract implementation to encapsulate errors. + */ +export class ReviewHandler implements ReviewSpec { + async execute(input: ReviewInput): Promise { + try { + if (input.dryRun) { + console.error('--- DRY RUN ENABLED: Simulating Code Generation & Review ---'); + return { + success: true, + data: { + reviewMessage: '[DRY RUN] Generated code successfully met the original goals.', + patchSize: 42, + }, + }; + } + + // 1. Generation phase + const genSession = await generateCode(input.prompt); + const genOutcome = await genSession.result(); + + if (genOutcome.state !== 'completed') { + return { + success: false, + error: { + code: 'GENERATION_FAILED', + message: `Generation session failed with state: ${genOutcome.state}`, + recoverable: false, + }, + }; + } + + // 2. Patch extraction phase + const gitPatch = extractGitPatch(genOutcome); + + if (!gitPatch) { + return { + success: false, + error: { + code: 'NO_PATCH_FOUND', + message: 'Failed to extract GitPatch from generation session.', + recoverable: false, + }, + }; + } + + // 3. Review phase + const reviewOutcome = await reviewCode(input.prompt, gitPatch); + + if (reviewOutcome.state !== 'completed') { + return { + success: false, + error: { + code: 'REVIEW_FAILED', + message: `Review session failed with state: ${reviewOutcome.state}`, + recoverable: false, + }, + }; + } + + // 4. Result Formatting + let reviewMessage = 'No final message provided by the review agent.'; + const activities = reviewOutcome.activities ?? []; + const agentMessages = activities.filter((a) => a.type === 'agentMessaged'); + + if (agentMessages.length > 0) { + reviewMessage = agentMessages[agentMessages.length - 1].message; + } else { + const files = reviewOutcome.generatedFiles(); + if (files.size > 0) { + reviewMessage = ''; + for (const [filename, content] of files.entries()) { + reviewMessage += `\nFile: ${filename}\n${content.content}\n`; + } + } + } + + return { + success: true, + data: { + reviewMessage, + patchSize: gitPatch.length, + }, + }; + } catch (error) { + return { + success: false, + error: { + code: 'UNKNOWN_ERROR', + message: error instanceof Error ? error.message : String(error), + recoverable: false, + }, + }; + } + } +} diff --git a/packages/core/examples/gitpatch-goals/index.ts b/packages/core/examples/gitpatch-goals/index.ts new file mode 100644 index 0000000..4417727 --- /dev/null +++ b/packages/core/examples/gitpatch-goals/index.ts @@ -0,0 +1,80 @@ +import { defineCommand, runMain } from 'citty'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { ReviewInputSchema } from './spec.js'; +import { ReviewHandler } from './handler.js'; + +// ============================================================================ +// CLI CONFIGURATION (citty) +// ============================================================================ + +const main = defineCommand({ + meta: { + name: 'gitpatch-goals', + description: 'Generates code and reviews it via GitPatch against original goals.', + }, + args: { + prompt: { + type: 'string', + description: 'The initial prompt/goal for code generation', + default: + 'Create a simple Node.js HTTP server that listens on port 8080 and serves "Hello, World!".', + alias: 'p', + }, + json: { + type: 'boolean', + description: 'Output the result as JSON', + default: false, + }, + 'dry-run': { + type: 'boolean', + description: 'Simulates the command without making API calls', + default: false, + }, + describe: { + type: 'boolean', + description: 'Prints the JSON schema for this command and exits', + default: false, + }, + }, + async run({ args }) { + if (args.describe) { + const schema = zodToJsonSchema(ReviewInputSchema, 'ReviewInput'); + console.log(JSON.stringify(schema, null, 2)); + process.exit(0); + } + + if (!process.env.JULES_API_KEY) { + console.error('Error: JULES_API_KEY environment variable is missing.'); + process.exit(1); + } + + const parseResult = ReviewInputSchema.safeParse({ + prompt: args.prompt, + dryRun: args['dry-run'], + }); + if (!parseResult.success) { + console.error('Invalid input:', parseResult.error.format()); + process.exit(1); + } + + const handler = new ReviewHandler(); + const result = await handler.execute(parseResult.data); + + if (args.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + if (result.success) { + console.log('\n======================================================'); + console.log('REVIEW RESULTS'); + console.log('======================================================\n'); + console.log(result.data.reviewMessage); + } else { + console.error('\nFAILED:', result.error.message); + } + } + + process.exit(result.success ? 0 : 1); + }, +}); + +runMain(main); diff --git a/packages/core/examples/gitpatch-goals/package.json b/packages/core/examples/gitpatch-goals/package.json new file mode 100644 index 0000000..3938fd9 --- /dev/null +++ b/packages/core/examples/gitpatch-goals/package.json @@ -0,0 +1,18 @@ +{ + "name": "gitpatch-goals", + "version": "1.0.0", + "description": "Example demonstrating how to evaluate generated code using a GitPatch.", + "main": "index.ts", + "scripts": { + "start": "bun run index.ts" + }, + "dependencies": { + "@google/jules-sdk": "workspace:*", + "citty": "^0.1.6", + "zod": "^3.25.76", + "zod-to-json-schema": "^3.24.1" + }, + "devDependencies": { + "bun-types": "^1.1.8" + } +} diff --git a/packages/core/examples/gitpatch-goals/patch.ts b/packages/core/examples/gitpatch-goals/patch.ts new file mode 100644 index 0000000..4fb781e --- /dev/null +++ b/packages/core/examples/gitpatch-goals/patch.ts @@ -0,0 +1,47 @@ +import { SessionOutcome } from '@google/jules-sdk'; + +/** + * Extracts a GitPatch diff from a completed session outcome. + * Prefers the native changeSet object but falls back to manually creating + * a diff from generated files if necessary. + */ +export function extractGitPatch(genOutcome: SessionOutcome): string | null { + console.error('\n--- Step 2: Extracting GitPatch ---'); + + let gitPatch = ''; + + // Let's first check if changeSet is available on the snapshot + if (typeof genOutcome.changeSet === 'function') { + const patch = genOutcome.changeSet(); + if (patch && typeof patch === 'string') { + gitPatch = patch; + } + } + + // If we didn't find one via `changeSet()`, let's check generated files + if (!gitPatch) { + console.error( + 'No direct changeSet found. Fallback to getting generated files.' + ); + const files = genOutcome.generatedFiles(); + if (files.size > 0) { + for (const [path, file] of files.entries()) { + const lineCount = file.content.split('\n').length; + gitPatch += `--- a/${path}\n+++ b/${path}\n@@ -0,0 +1,${lineCount} @@\n`; + gitPatch += + file.content + .split('\n') + .map((l: string) => '+' + l) + .join('\n') + '\n'; + } + } + } + + if (!gitPatch) { + console.error('No GitPatch data or generated files found in the generation session.'); + return null; + } + + console.error(gitPatch.substring(0, 500) + '...\n(truncated for brevity)'); + return gitPatch; +} diff --git a/packages/core/examples/gitpatch-goals/review.ts b/packages/core/examples/gitpatch-goals/review.ts new file mode 100644 index 0000000..02fc791 --- /dev/null +++ b/packages/core/examples/gitpatch-goals/review.ts @@ -0,0 +1,56 @@ +import { jules, SessionClient, SessionOutcome } from '@google/jules-sdk'; + +/** + * Initiates the review session with the extracted GitPatch and original prompt. + * Streams progress back until completion and returns the final session outcome. + */ +export async function reviewCode( + originalPrompt: string, + gitPatch: string +): Promise { + console.error('\n--- Step 3: Initiating Review Session ---'); + + const reviewPrompt = ` +You are an expert code reviewer. Review the following GitPatch generated by an AI agent. + +### Original Goals and Requirements +${originalPrompt} + +### GitPatch to Review +\`\`\`diff +${gitPatch} +\`\`\` + +### Task +1. Determine if the generated code successfully meets ALL the Original Goals and Requirements. +2. Determine if the code adheres to general Node.js coding standards and best practices. +3. Provide a clear, structured markdown response with the following sections: + - **Goal Satisfaction**: Yes/No and why. + - **Code Quality**: Feedback on best practices. + - **Final Verdict**: Pass or Fail. +`; + + const session = await jules.session({ prompt: reviewPrompt }); + + console.error(`Review Session created! ID: ${session.id}`); + console.error('Streaming agent progress...\n'); + + try { + for await (const activity of session.stream()) { + if (activity.type === 'progressUpdated') { + console.error(`[Review] ${activity.title}`); + } else if (activity.type === 'agentMessaged') { + console.error(`[Review Agent]: ${activity.message.substring(0, 100)}...`); + } else if (activity.type === 'sessionCompleted') { + console.error('[Review] Session complete.'); + } else if (activity.type === 'sessionFailed') { + console.error('[Review] Session failed.'); + } + } + } catch (err) { + console.error('[Review] Streaming not available yet. Waiting for completion...'); + } + + const reviewOutcome = await session.result(); + return reviewOutcome; +} diff --git a/packages/core/examples/gitpatch-goals/spec.ts b/packages/core/examples/gitpatch-goals/spec.ts new file mode 100644 index 0000000..73020a5 --- /dev/null +++ b/packages/core/examples/gitpatch-goals/spec.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +export const ReviewInputSchema = z.object({ + prompt: z.string().min(1, 'Prompt is required'), + dryRun: z.boolean().default(false).describe('Simulates the command without making API calls.'), +}); + +export type ReviewInput = z.infer; + +export const ReviewErrorCode = z.enum([ + 'GENERATION_FAILED', + 'REVIEW_FAILED', + 'NO_PATCH_FOUND', + 'UNKNOWN_ERROR', +]); + +export const ReviewSuccessSchema = z.object({ + success: z.literal(true), + data: z.object({ + reviewMessage: z.string(), + patchSize: z.number(), + }), +}); + +export const ReviewFailureSchema = z.object({ + success: z.literal(false), + error: z.object({ + code: ReviewErrorCode, + message: z.string(), + recoverable: z.boolean(), + }), +}); + +export type ReviewResult = + | z.infer + | z.infer; + +export interface ReviewSpec { + execute(input: ReviewInput): Promise; +} diff --git a/packages/core/examples/gitpatch-improve/README.md b/packages/core/examples/gitpatch-improve/README.md new file mode 100644 index 0000000..78ad1bc --- /dev/null +++ b/packages/core/examples/gitpatch-improve/README.md @@ -0,0 +1,28 @@ +# GitPatch Improve CLI Example + +This example demonstrates how to use the Jules SDK to build a CLI that analyzes code using GitPatch data to identify areas for improvement or automation in a repository. + +It follows the Typed Service Contract pattern (Spec & Handler) for robust error handling and input validation. + +## Setup + +1. Make sure you have installed the SDK dependencies in the project root. +2. Ensure you have your `JULES_API_KEY` set. + +```sh +export JULES_API_KEY="your-api-key-here" +``` + +## Running + +You can run the CLI script via `bun run start` or passing arguments directly: + +```sh +# Run with defaults (davideast/dataprompt) +bun run start + +# Run targeting a specific repo and querying the last 20 activities +bun run start --repo your-org/your-repo --limit 20 +``` + +This will find a recent local GitPatch activity, create a session targeting the specified repository, and prompt the agent to analyze the code using the GitPatch data to identify areas for improvement and print out the analysis. diff --git a/packages/core/examples/gitpatch-improve/cli.ts b/packages/core/examples/gitpatch-improve/cli.ts new file mode 100644 index 0000000..a176704 --- /dev/null +++ b/packages/core/examples/gitpatch-improve/cli.ts @@ -0,0 +1,58 @@ +import { defineCommand, runMain } from 'citty'; +import { AnalyzeGitPatchInputSchema } from './spec.js'; +import { AnalyzeGitPatchHandler } from './handler.js'; + +const main = defineCommand({ + meta: { + name: 'gitpatch-improve', + version: '1.0.0', + description: 'CLI to analyze GitPatch data for code improvements using Jules SDK.', + }, + args: { + repo: { + type: 'string', + description: 'The GitHub repository to analyze (e.g., davideast/dataprompt)', + default: 'davideast/dataprompt', + alias: 'r', + }, + limit: { + type: 'string', + description: 'Number of recent activities to search for GitPatch data', + default: '10', + alias: 'l', + }, + }, + async run({ args }) { + console.log(`Starting GitPatch Improve analysis...`); + + // Parse input + const input = AnalyzeGitPatchInputSchema.safeParse({ + sourceRepo: args.repo, + limit: parseInt(args.limit, 10), + }); + + if (!input.success) { + console.error('Invalid arguments provided:'); + console.error(input.error.errors); + process.exit(1); + } + + const handler = new AnalyzeGitPatchHandler(); + const result = await handler.execute(input.data); + + if (!result.success) { + console.error(`Error: [${result.error.code}] ${result.error.message}`); + if (result.error.suggestion) { + console.error(`Suggestion: ${result.error.suggestion}`); + } + process.exit(1); + } + + console.log('\n--- Analysis Report ---'); + console.log(`From Source Session ID: ${result.data.sourceSessionId}`); + console.log(result.data.analysis); + console.log('-----------------------\n'); + }, +}); + +runMain(main); diff --git a/packages/core/examples/gitpatch-improve/handler.ts b/packages/core/examples/gitpatch-improve/handler.ts new file mode 100644 index 0000000..d78b2b7 --- /dev/null +++ b/packages/core/examples/gitpatch-improve/handler.ts @@ -0,0 +1,115 @@ +import { jules, ChangeSetArtifact } from '@google/jules-sdk'; +import type { AnalyzeGitPatchSpec, AnalyzeGitPatchInput, AnalyzeGitPatchResult } from './spec.js'; + +export class AnalyzeGitPatchHandler implements AnalyzeGitPatchSpec { + async execute(input: AnalyzeGitPatchInput): Promise { + try { + console.log(`Searching recent ${input.limit} activities for a GitPatch...`); + + // 1. Search for a recent session with GitPatch data + const activitiesWithChanges = await jules.select({ + from: 'activities', + where: { artifactCount: { gt: 0 } }, + order: 'desc', + limit: input.limit, + }); + + let gitPatchData: string | null = null; + let sourceSessionId: string | null = null; + + for (const activity of activitiesWithChanges) { + if (activity.artifacts) { + for (const artifact of activity.artifacts) { + if (artifact.type === 'changeSet') { + const changeSet = artifact as ChangeSetArtifact; + if (changeSet.gitPatch?.unidiffPatch) { + gitPatchData = changeSet.gitPatch.unidiffPatch; + sourceSessionId = activity.session?.id || 'unknown'; + break; + } + } + } + } + if (gitPatchData) break; + } + + if (!gitPatchData) { + return { + success: false, + error: { + code: 'NO_GITPATCH_FOUND', + message: `Could not find any recent GitPatch data in the local cache.`, + suggestion: 'Run a session that modifies code and generates a changeset first.', + recoverable: true, + }, + }; + } + + console.log(`Found GitPatch data from session ${sourceSessionId}.`); + console.log(`Starting analysis session against ${input.sourceRepo}...`); + + // 2. Run a new session against the same source repository to analyze it + const session = await jules.session({ + prompt: `You are an expert code reviewer. Analyze the following GitPatch data against the repository context. + Identify any potential bugs, areas for optimization, or coding standard violations in the changes. + Explain how the automated code could be improved. + Write your analysis to a file named 'analysis.md'. + + ## GitPatch Data + \`\`\`diff + ${gitPatchData} + \`\`\` + `, + source: { github: input.sourceRepo }, + }); + + console.log(`Session created: ${session.id}`); + console.log('Waiting for the agent to complete the analysis...'); + + const outcome = await session.result(); + + if (outcome.state === 'failed') { + return { + success: false, + error: { + code: 'SESSION_FAILED', + message: `The analysis session failed.`, + recoverable: false, + }, + }; + } + + const files = outcome.generatedFiles(); + const analysisFile = files.get('analysis.md'); + + if (!analysisFile) { + return { + success: false, + error: { + code: 'SESSION_FAILED', + message: `The analysis file was not generated by the agent.`, + recoverable: true, + }, + }; + } + + return { + success: true, + data: { + analysis: analysisFile.content, + sourceSessionId: sourceSessionId || 'unknown', + }, + }; + + } catch (error) { + return { + success: false, + error: { + code: 'UNKNOWN_ERROR', + message: error instanceof Error ? error.message : String(error), + recoverable: false, + }, + }; + } + } +} diff --git a/packages/core/examples/gitpatch-improve/package.json b/packages/core/examples/gitpatch-improve/package.json new file mode 100644 index 0000000..d9274ec --- /dev/null +++ b/packages/core/examples/gitpatch-improve/package.json @@ -0,0 +1,19 @@ +{ + "name": "gitpatch-improve", + "version": "1.0.0", + "private": true, + "description": "Example CLI using Jules' GitPatch data to analyze how to improve or automate code in a repository.", + "type": "module", + "scripts": { + "start": "tsx cli.ts" + }, + "dependencies": { + "@google/jules-sdk": "workspace:*", + "citty": "^0.1.6", + "zod": "^3.25.0" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.7.3" + } +} diff --git a/packages/core/examples/gitpatch-improve/spec.ts b/packages/core/examples/gitpatch-improve/spec.ts new file mode 100644 index 0000000..d1aabbf --- /dev/null +++ b/packages/core/examples/gitpatch-improve/spec.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; +import type { GitPatch } from '@google/jules-sdk'; + +// 1. INPUT +export const AnalyzeGitPatchInputSchema = z.object({ + sourceRepo: z.string().describe('The GitHub repository in format owner/repo (e.g., davideast/dataprompt)'), + limit: z.number().int().positive().default(10).describe('Number of recent activities to search for GitPatch data'), +}); +export type AnalyzeGitPatchInput = z.infer; + +// 2. ERROR CODES +export const AnalyzeGitPatchErrorCode = z.enum([ + 'NO_GITPATCH_FOUND', + 'SESSION_FAILED', + 'UNKNOWN_ERROR', +]); + +// 3. RESULT +export const AnalyzeGitPatchSuccess = z.object({ + success: z.literal(true), + data: z.object({ + analysis: z.string(), + sourceSessionId: z.string(), + }), +}); + +export const AnalyzeGitPatchFailure = z.object({ + success: z.literal(false), + error: z.object({ + code: AnalyzeGitPatchErrorCode, + message: z.string(), + suggestion: z.string().optional(), + recoverable: z.boolean(), + }), +}); + +export type AnalyzeGitPatchResult = + | z.infer + | z.infer; + +// 4. INTERFACE +export interface AnalyzeGitPatchSpec { + execute(input: AnalyzeGitPatchInput): Promise; +} diff --git a/packages/core/examples/gitpatch-local/README.md b/packages/core/examples/gitpatch-local/README.md new file mode 100644 index 0000000..eed3742 --- /dev/null +++ b/packages/core/examples/gitpatch-local/README.md @@ -0,0 +1,55 @@ +# Gitpatch Local Example (CLI) + +This example demonstrates how to use the Jules SDK to retrieve a `changeSet` artifact's GitPatch from a specific session and apply the generated code modifications locally on your machine using Git. + +It is structured as a **CLI application** using `citty` and follows the **Typed Service Contract** pattern. It separates validation (`spec.ts`) and impure side effects (`handler.ts`), and provides agent-friendly `json` output flags to demonstrate CLI agent best practices. + +It specifically showcases how to: +- Pass a `sessionId` as a positional CLI argument. +- Use `session.snapshot()` to retrieve the generated changes directly without relying on local cache queries. +- Download the resulting `GitPatch` (`unidiffPatch`) and write it to a `.patch` file. +- Use safely executed `execFileSync` to spin up a local git branch, `git apply` to patch the code, and commit the changes. + +## Requirements + +- Node.js >= 18 or Bun +- A Jules API Key (`JULES_API_KEY` environment variable) +- `git` installed and available in your `PATH` +- Must be executed inside a git repository (so `git checkout -b` and `git apply` work) +- A valid Jules Session ID that contains a `changeSet` artifact. + +## Setup + +1. Make sure you have installed the SDK dependencies in the project root. + +2. Export your Jules API key: + +```bash +export JULES_API_KEY="your-api-key-here" +``` + +3. Ensure example dependencies are installed: +```bash +bun install +``` + +## Running the Example + +Using `bun`: + +```bash +bun run index.ts +``` + +**Options:** +- `--branch `: Provide a custom name for the local git branch to be created (default is `jules-patch-test-`). +- `--json`: Output the result of the operation as a strict JSON blob (ideal for AI Agent consumption). + +Example: +```bash +bun run index.ts jules:session:123456789 --branch test-patch-fix --json +``` + +## What it does + +The CLI validates the input session ID using Zod. It then queries the Jules API for that session's snapshot data. It searches the snapshot for a `changeSet` artifact. It extracts the `unidiffPatch` from the artifact's `gitPatch` property, writes it to a `.patch` file locally, and uses standard `git apply` to patch the local git repository in the specified branch. Finally, it commits the applied patch. All side effects are encapsulated within a handler that returns a structured Result object (Success/Failure) rather than throwing raw exceptions. diff --git a/packages/core/examples/gitpatch-local/SKILL.md b/packages/core/examples/gitpatch-local/SKILL.md new file mode 100644 index 0000000..74ebd08 --- /dev/null +++ b/packages/core/examples/gitpatch-local/SKILL.md @@ -0,0 +1,48 @@ +--- +name: jules-gitpatch-local +version: 1.0.0 +description: A CLI tool to securely download and apply a git patch from a Jules session. +--- + +# Jules GitPatch Local CLI + +This CLI is frequently invoked by AI/LLM agents. It is designed with safety rails, strict validation, and structured output to prevent errors caused by hallucinated parameters. + +Always assume your inputs can be adversarial or malformed. + +## Agent Usage Guidelines + +1. **Introspection First:** If you are unsure about the input or output schemas for this CLI, execute it with the `--describe` flag to dump the JSON schema. + ```bash + bun run index.ts "" --describe + ``` + +2. **Dry Run Safety Rails:** This tool performs mutating operations on the local file system and git repository (`git checkout`, `git apply`, `git commit`). When instructed to "test" or "verify" changes, always append the `--dry-run` flag to ensure the changes are safely fetched and simulated without modifying the host. + ```bash + bun run index.ts --dry-run + ``` + +3. **Structured Outputs:** By default, this CLI prints human-friendly logs. As an agent, you must ALWAYS use the `--json` flag when invoking the command to receive a deterministic, machine-readable `Result` object. + ```bash + bun run index.ts --json + ``` + +4. **Input Hardening:** + - The `` parameter must not contain query parameters (`?`) or hash fragments (`#`). + - The `--branch` parameter must not contain directory traversal characters (`..`) or control characters. + +## Result Schema +The `--json` output will always follow the `ApplyPatchResult` discriminated union pattern: +```typescript +{ + success: true, + data: { branchName: string, commitMessage?: string } +} +``` +or +```typescript +{ + success: false, + error: { code: string, message: string, recoverable: boolean } +} +``` diff --git a/packages/core/examples/gitpatch-local/handler.ts b/packages/core/examples/gitpatch-local/handler.ts new file mode 100644 index 0000000..6df80b8 --- /dev/null +++ b/packages/core/examples/gitpatch-local/handler.ts @@ -0,0 +1,132 @@ +import { jules } from '@google/jules-sdk'; +import { execFileSync } from 'child_process'; +import { writeFileSync, unlinkSync } from 'fs'; +import { join } from 'path'; +import { ApplyPatchSpec, ApplyPatchInput, ApplyPatchResult } from './spec.js'; + +export class ApplyPatchHandler implements ApplyPatchSpec { + async execute(input: ApplyPatchInput): Promise { + const branchName = input.targetBranch || `jules-patch-test-${Date.now()}`; + let patchPath: string | null = null; + let commitMessage: string | undefined; + + try { + // 1. Fetch Session Snapshot Data directly instead of querying cache + let snapshot; + try { + const session = jules.session(input.sessionId); + snapshot = await session.snapshot(); + } catch (err) { + return { + success: false, + error: { + code: 'SESSION_NOT_FOUND', + message: `Could not fetch session snapshot: ${input.sessionId}`, + recoverable: false, + }, + }; + } + + // 2. Extract the unidiff patch from the changeSet + const gitPatch = snapshot.changeSet()?.gitPatch; + if (!gitPatch || !gitPatch.unidiffPatch) { + return { + success: false, + error: { + code: 'NO_CHANGESET_FOUND', + message: `No ChangeSet artifact with gitPatch data found in session ${input.sessionId}.`, + recoverable: false, + }, + }; + } + + commitMessage = gitPatch.suggestedCommitMessage || 'Applied changes from Jules'; + + // 3. Handle Dry Run Safety Rails + if (input.dryRun) { + return { + success: true, + data: { + branchName: `[DRY RUN] ${branchName}`, + commitMessage: `[DRY RUN] ${commitMessage}`, + }, + }; + } + + // 4. Checkout a new branch to apply the changes + try { + execFileSync('git', ['checkout', '-b', branchName], { stdio: 'pipe' }); + } catch (e: any) { + return { + success: false, + error: { + code: 'UNABLE_TO_CHECKOUT_BRANCH', + message: `Failed to checkout branch ${branchName}. Ensure you are in a git repository. ${e.message}`, + recoverable: true, + }, + }; + } + + // 5. Save the patch to disk + patchPath = join(process.cwd(), 'jules_changes.patch'); + writeFileSync(patchPath, gitPatch.unidiffPatch); + + // 6. Apply the patch + try { + execFileSync('git', ['apply', patchPath], { stdio: 'pipe' }); + } catch (e: any) { + return { + success: false, + error: { + code: 'UNABLE_TO_APPLY_PATCH', + message: `Failed to apply patch. It may conflict with your current local state. ${e.message}`, + recoverable: true, + }, + }; + } + + // 7. Commit the applied changes + try { + execFileSync('git', ['add', '.'], { stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', commitMessage], { stdio: 'pipe' }); + } catch (e: any) { + return { + success: false, + error: { + code: 'UNABLE_TO_COMMIT', + message: `Failed to commit the changes. ${e.message}`, + recoverable: true, + }, + }; + } + + // 8. Success + return { + success: true, + data: { + branchName, + commitMessage, + }, + }; + } catch (error) { + // 8. Safety net for unknown exceptions + return { + success: false, + error: { + code: 'UNKNOWN_ERROR', + message: error instanceof Error ? error.message : String(error), + recoverable: false, + }, + }; + } finally { + // 9. Clean up the patch file if it exists + if (patchPath) { + try { + unlinkSync(patchPath); + } catch (e) { + // Ignore unlink errors + } + } + } + } +} diff --git a/packages/core/examples/gitpatch-local/index.ts b/packages/core/examples/gitpatch-local/index.ts new file mode 100644 index 0000000..0024ce8 --- /dev/null +++ b/packages/core/examples/gitpatch-local/index.ts @@ -0,0 +1,117 @@ +import { defineCommand, runMain } from 'citty'; +import { ApplyPatchInputSchema, ApplyPatchResultSchema } from './spec.js'; +import { ApplyPatchHandler } from './handler.js'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +/** + * Gitpatch Local Example + * + * Demonstrates how to use Jules' session GitPatch data to download + * and patch the code locally in a new branch on the user's machine. + */ +const main = defineCommand({ + meta: { + name: 'jules-gitpatch-local', + version: '1.0.0', + description: 'Applies a GitPatch from a Jules session to a local branch', + }, + args: { + sessionId: { + type: 'positional', + description: 'The ID of the Jules session to extract the patch from', + required: true, + }, + branch: { + type: 'string', + description: 'The name of the new local branch to apply changes to (optional)', + required: false, + }, + json: { + type: 'boolean', + description: 'Output the result as JSON for agent interoperability', + required: false, + default: false, + }, + 'dry-run': { + type: 'boolean', + description: 'Simulate the operation without mutating local files or git state', + required: false, + default: false, + }, + describe: { + type: 'boolean', + description: 'Output the JSON schemas for the input and output types for Agent introspection', + required: false, + default: false, + }, + }, + async run({ args }) { + // 0. Introspection (Agent Documentation) + if (args.describe) { + console.log( + JSON.stringify( + { + inputSchema: zodToJsonSchema(ApplyPatchInputSchema), + outputSchema: zodToJsonSchema(ApplyPatchResultSchema), + }, + null, + 2 + ) + ); + process.exit(0); + } + + if (!process.env.JULES_API_KEY) { + if (args.json) { + console.error(JSON.stringify({ error: 'JULES_API_KEY environment variable is not set.' })); + } else { + console.error('Error: JULES_API_KEY environment variable is not set.'); + console.error('Please set it using: export JULES_API_KEY="your-api-key"'); + } + process.exit(1); + } + + // 1. Validate Input (Parse, don't validate) + const inputResult = ApplyPatchInputSchema.safeParse({ + sessionId: args.sessionId, + targetBranch: args.branch, + dryRun: args['dry-run'], + }); + + if (!inputResult.success) { + if (args.json) { + console.error(JSON.stringify({ error: 'Invalid input', details: inputResult.error.issues })); + } else { + console.error('Validation Error:'); + inputResult.error.issues.forEach((i) => console.error(` - ${i.message}`)); + } + process.exit(1); + } + + // 2. Execute Handler + const handler = new ApplyPatchHandler(); + const result = await handler.execute(inputResult.data); + + // 3. Handle Output (Agent DX vs Human DX) + if (args.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + if (!result.success) { + console.error(`[Error] ${result.error.code}: ${result.error.message}`); + process.exit(1); + } else { + console.log(`Successfully checked out branch: ${result.data.branchName}`); + console.log(`Patch applied and committed!`); + } + } + + if (!result.success) { + process.exit(1); + } + }, +}); + +// Run the example CLI +if (import.meta.url === `file://${process.argv[1]}`) { + runMain(main); +} diff --git a/packages/core/examples/gitpatch-local/package.json b/packages/core/examples/gitpatch-local/package.json new file mode 100644 index 0000000..d9d9601 --- /dev/null +++ b/packages/core/examples/gitpatch-local/package.json @@ -0,0 +1,10 @@ +{ + "name": "gitpatch-local-example", + "private": true, + "type": "module", + "dependencies": { + "citty": "^0.2.1", + "zod": "^4.3.6", + "zod-to-json-schema": "^3.25.1" + } +} diff --git a/packages/core/examples/gitpatch-local/spec.ts b/packages/core/examples/gitpatch-local/spec.ts new file mode 100644 index 0000000..df9939d --- /dev/null +++ b/packages/core/examples/gitpatch-local/spec.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +// 1. VALIDATION HELPERS (Input Hardening against hallucinations) +export const SafeStringSchema = z.string() + .min(1, 'Cannot be empty') + .refine(s => !/[\x00-\x1F\x7F]/.test(s), "No control characters allowed") + .refine(s => !s.includes('..'), "No path traversal allowed") + .refine(s => !s.includes('?') && !s.includes('#'), "No query or hash parameters allowed"); + +export const ApplyPatchInputSchema = z.object({ + sessionId: SafeStringSchema, + targetBranch: SafeStringSchema.optional(), + dryRun: z.boolean().default(false), +}); +export type ApplyPatchInput = z.infer; + +export const ApplyPatchErrorCode = z.enum([ + 'SESSION_NOT_FOUND', + 'NO_CHANGESET_FOUND', + 'UNABLE_TO_CHECKOUT_BRANCH', + 'UNABLE_TO_APPLY_PATCH', + 'UNABLE_TO_COMMIT', + 'UNKNOWN_ERROR', +]); + +export const ApplyPatchSuccess = z.object({ + success: z.literal(true), + data: z.object({ + branchName: z.string(), + commitMessage: z.string().optional(), + }), +}); + +export const ApplyPatchFailure = z.object({ + success: z.literal(false), + error: z.object({ + code: ApplyPatchErrorCode, + message: z.string(), + recoverable: z.boolean(), + }), +}); + +export const ApplyPatchResultSchema = z.discriminatedUnion('success', [ + ApplyPatchSuccess, + ApplyPatchFailure, +]); + +export type ApplyPatchResult = z.infer; + +export interface ApplyPatchSpec { + execute(input: ApplyPatchInput): Promise; +} diff --git a/packages/core/examples/gitpatch-review/README.md b/packages/core/examples/gitpatch-review/README.md new file mode 100644 index 0000000..4364448 --- /dev/null +++ b/packages/core/examples/gitpatch-review/README.md @@ -0,0 +1,91 @@ +# GitPatch Review Example (CLI) + +This example demonstrates how to use Jules' session GitPatch to review and analyze code generated by a Jules coding agent against the context of a GitHub repository. + +It is structured as a CLI using [citty](https://github.com/unjs/citty) and follows the **Typed Service Contract** (Spec & Handler) pattern to clearly separate argument parsing, schema validation, and business logic execution. + +## Overview + +The CLI orchestrates two sequential sessions using the Jules SDK: + +1. **Generation Session**: Takes a user-provided prompt (e.g., instructing an agent to write poor code), producing a Git patch with changes. +2. **Review Session**: Takes the resulting Git patch generated from the first session and creates a *new* session, passing the patch string as context to instruct the agent to review and analyze the code against standard coding practices. + +This demonstrates common workflows such as: +- Structuring Agent CLIs for deterministic and predictable behavior. +- Using `session.stream()` to output real-time CLI feedback. +- Extracting raw `gitPatch` strings or parsed `ChangeSet` structures directly from the `session.snapshot()`. + +## Prerequisites + +- Node.js or Bun installed. +- A Jules API Key. Set it using: + ```bash + export JULES_API_KEY= + ``` + +## Running the Example + +You can run this example using `bun` (recommended) or via Node/TSX. + +### Basic Usage + +```bash +bun run index.ts -r "owner/repo" -b "main" -p "Write a badly formatted hello world function in Python" +``` + +### CLI Arguments + +| Argument | Alias | Required | Default | Description | +| :--- | :--- | :--- | :--- | :--- | +| `repository` | `-r` | **Yes** | | The target GitHub repository (e.g. `davideast/dataprompt`) | +| `prompt` | `-p` | **Yes** | | The prompt to generate the code change | +| `baseBranch` | `-b` | No | `main` | The base branch of the repository | +| `--json` | | No | `false` | Output the final result or error as structured JSON. Useful for agents or piping outputs. | + +### Example Output + +```text +Starting code generation session for davideast/dataprompt... +Code Generation Session ID: jules:session:12345 +[Code Gen] Planning changes +[Code Gen] Generated plan with 1 steps. +[Code Gen] Editing code +[Code Gen Agent]: I've created the requested bad code. + +--- Extracted Patch Content --- +--- a/bad_code.py ++++ b/bad_code.py +@@ -0,0 +1,2 @@ ++def hw(): ++ print("hello") +------------------------------- + +Starting review session... +Review Session ID: jules:session:67890 +[Review] Analyzing changes +[Review Agent]: This code lacks typing, has bad indentation, and uses a poor function name. + +======================================= + REVIEW COMPLETE +======================================= + +This code lacks typing, has bad indentation, and uses a poor function name. I recommend renaming it to 'hello_world' and fixing the indentation. +``` + +### JSON Output + +When run with the `--json` flag, all stdout/stderr progress logs are suppressed or piped differently, and the final output is a structured JSON response (following the Result pattern). + +```bash +bun run index.ts -r "owner/repo" -p "Write bad code" --json +``` + +```json +{ + "codeGenSessionId": "jules:session:12345", + "reviewSessionId": "jules:session:67890", + "gitPatchStr": "...", + "reviewMessage": "This code lacks typing..." +} +``` diff --git a/packages/core/examples/gitpatch-review/e2e-test.ts b/packages/core/examples/gitpatch-review/e2e-test.ts new file mode 100644 index 0000000..55647f7 --- /dev/null +++ b/packages/core/examples/gitpatch-review/e2e-test.ts @@ -0,0 +1,104 @@ +import { execa } from 'execa'; +import { z } from 'zod'; +import { ReviewSuccess } from './src/spec.js'; + +/** + * End-to-End Test for the GitPatch Review CLI + * + * This script invokes the CLI as a separate process to verify that: + * 1. The CLI can authenticate with the Jules API (using JULES_API_KEY). + * 2. It successfully starts and streams two consecutive sessions. + * 3. When the `--json` flag is provided, the final `stdout` is exclusively + * a valid JSON payload matching the `ReviewSuccess` schema. + * 4. Progress logs are successfully piped to `stderr` and don't corrupt the JSON. + */ +async function runE2E() { + const apiKey = process.env.JULES_API_KEY; + + if (!apiKey) { + console.error('āŒ E2E Test Failed: JULES_API_KEY environment variable is missing.'); + process.exit(1); + } + + console.log('šŸš€ Starting GitPatch Review CLI E2E Test...\n'); + + try { + // We use execa to easily spawn the CLI, capture stdout/stderr separately, + // and provide a timeout. The target repo here is arbitrary but must be valid. + const subprocess = execa('bun', [ + 'run', + 'index.ts', + '-r', + 'davideast/dataprompt', + '-b', + 'main', + '-p', + 'Write a Python function that adds two numbers, but name it very badly, use no types, and mess up the indentation.', + '--json', + ], { + env: { JULES_API_KEY: apiKey }, + timeout: 900000, // 15 minute timeout for two LLM sessions + cwd: import.meta.dir // Ensure we run relative to this e2e script + }); + + // Pipe stderr to our current console so we can watch the progress logs live + if (subprocess.stderr) { + subprocess.stderr.pipe(process.stderr); + } + + const { stdout, exitCode } = await subprocess; + + console.log('\n\nāœ… CLI process exited with code:', exitCode); + + if (exitCode !== 0) { + console.error('āŒ E2E Test Failed: CLI exited with a non-zero status code.'); + process.exit(1); + } + + console.log('--- Raw CLI JSON Output (stdout) ---'); + console.log(stdout); + console.log('------------------------------------\n'); + + // Parse and validate the stdout output against our expected Zod schema + const parsedJson = JSON.parse(stdout); + const validationResult = ReviewSuccess.safeParse({ success: true, data: parsedJson }); + + if (!validationResult.success) { + console.error('āŒ E2E Test Failed: The JSON output did not match the expected schema.'); + console.error(validationResult.error.format()); + process.exit(1); + } + + const { data } = validationResult.data; + + console.log('āœ… Validation Passed: Output is valid JSON.'); + console.log(`- Code Gen Session ID: ${data.codeGenSessionId}`); + console.log(`- Review Session ID: ${data.reviewSessionId}`); + + if (data.gitPatchStr && data.gitPatchStr.length > 0) { + console.log(`- Git Patch Extracted: YES (${data.gitPatchStr.split('\\n').length} lines)`); + } else { + console.error('āŒ E2E Test Failed: No Git Patch string was found in the output.'); + process.exit(1); + } + + if (data.reviewMessage && data.reviewMessage.length > 0) { + console.log(`- Review Message Generated: YES`); + } else { + console.error('āŒ E2E Test Failed: No Review Message was found in the output.'); + process.exit(1); + } + + console.log('\nšŸŽ‰ E2E Test Completed Successfully!'); + + } catch (error: any) { + console.error('\nāŒ E2E Test Failed with an exception:'); + if (error.shortMessage) { + console.error(error.shortMessage); // execa formatting + } + console.error(error.message); + process.exit(1); + } +} + +runE2E(); diff --git a/packages/core/examples/gitpatch-review/index.ts b/packages/core/examples/gitpatch-review/index.ts new file mode 100644 index 0000000..72a6645 --- /dev/null +++ b/packages/core/examples/gitpatch-review/index.ts @@ -0,0 +1,82 @@ +import { defineCommand, runMain } from 'citty'; +import { ReviewHandler } from './src/handler.js'; +import { ReviewInputSchema } from './src/spec.js'; + +const main = defineCommand({ + meta: { + name: 'jules-gitpatch-review', + version: '1.0.0', + description: 'Use Jules to review generated code patches against GitHub repo context.', + }, + args: { + repository: { + type: 'string', + description: 'The target GitHub repository (e.g. owner/repo)', + required: true, + alias: 'r', + }, + baseBranch: { + type: 'string', + description: 'The base branch of the repository', + default: 'main', + alias: 'b', + }, + prompt: { + type: 'string', + description: 'The prompt to generate the code change', + required: true, + alias: 'p', + }, + json: { + type: 'boolean', + description: 'Output the final result as JSON', + default: false, + }, + }, + async run({ args }) { + // 1. Validate Input (Parse, don't validate) + const inputResult = ReviewInputSchema.safeParse({ + repository: args.repository, + baseBranch: args.baseBranch, + prompt: args.prompt, + json: args.json, + }); + + if (!inputResult.success) { + console.error('Invalid arguments provided:'); + console.error(inputResult.error.format()); + process.exit(1); + } + + // 2. Instantiate the Handler + const handler = new ReviewHandler(); + + // 3. Execute Business Logic + const result = await handler.execute(inputResult.data); + + // 4. Handle Results Deterministically + if (!result.success) { + if (args.json) { + console.error(JSON.stringify(result.error, null, 2)); + } else { + console.error(`\n[ERROR] ${result.error.code}: ${result.error.message}`); + if (result.error.suggestion) { + console.error(`Suggestion: ${result.error.suggestion}`); + } + } + process.exit(1); + } + + // 5. Output Success State + if (args.json) { + console.log(JSON.stringify(result.data, null, 2)); + } else { + console.log('\n======================================='); + console.log(' REVIEW COMPLETE'); + console.log('=======================================\n'); + console.log(result.data.reviewMessage); + } + }, +}); + +runMain(main); diff --git a/packages/core/examples/gitpatch-review/package.json b/packages/core/examples/gitpatch-review/package.json new file mode 100644 index 0000000..b76a135 --- /dev/null +++ b/packages/core/examples/gitpatch-review/package.json @@ -0,0 +1,20 @@ +{ + "name": "jules-gitpatch-review-example", + "version": "1.0.0", + "description": "Example demonstrating how to use Jules' session GitPatch to review and analyze generated code", + "main": "index.ts", + "type": "module", + "scripts": { + "start": "bun run index.ts", + "test:e2e": "bun run e2e-test.ts" + }, + "dependencies": { + "@google/jules-sdk": "workspace:*", + "citty": "^0.1.6", + "zod": "^3.23.0" + }, + "devDependencies": { + "bun-types": "^1.1.8", + "execa": "^9.6.1" + } +} diff --git a/packages/core/examples/gitpatch-review/src/handler.ts b/packages/core/examples/gitpatch-review/src/handler.ts new file mode 100644 index 0000000..afaf84e --- /dev/null +++ b/packages/core/examples/gitpatch-review/src/handler.ts @@ -0,0 +1,211 @@ +import { jules, Session, Activity } from '@google/jules-sdk'; +import { ReviewSpec, ReviewInput, ReviewResult } from './spec.js'; + +export class ReviewHandler implements ReviewSpec { + async execute(input: ReviewInput): Promise { + try { + if (!process.env.JULES_API_KEY) { + return { + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'JULES_API_KEY environment variable is not set.', + suggestion: 'Export JULES_API_KEY="your-api-key" before running the CLI.', + recoverable: true, + }, + }; + } + + this.log(`Starting code generation session for ${input.repository}...`, input.json); + + const source = { github: input.repository, baseBranch: input.baseBranch }; + + // 1. Generate bad code + const codeGenSession = await jules.session({ + prompt: input.prompt, + source, + }); + + this.log(`Code Generation Session ID: ${codeGenSession.id}`, input.json); + + // If the session isn't immediately finished, stream it until it is + const genInfo = await codeGenSession.info(); + if (genInfo.state !== 'completed' && genInfo.state !== 'failed') { + try { + await this.streamSessionActivities(codeGenSession, 'Code Gen', input.json); + } catch(e) { + // Occasionally activities return 404 momentarily immediately after session creation + // in some environments. Ignore and fall through to wait on result(). + } + } + + const genOutcome = await codeGenSession.result(); + + if (genOutcome.state === 'failed') { + return { + success: false, + error: { + code: 'SESSION_FAILED', + message: `Code generation session failed: ${codeGenSession.id}`, + recoverable: false, + }, + }; + } + + // 2. Extract GitPatch + const snapshot = codeGenSession.snapshot(); + let changeSet; + + // Type guarding the `snapshot.changeSet` function because the underlying SDK + // abstraction may change or omit it. + if (typeof snapshot.changeSet === 'function') { + changeSet = snapshot.changeSet(); + } + + let gitPatchStr = ''; + + if (changeSet && changeSet.gitPatch && changeSet.gitPatch.unidiffPatch) { + // Prefer the raw unidiff patch from the GitPatch object if available + gitPatchStr = changeSet.gitPatch.unidiffPatch; + } else if (changeSet && typeof changeSet.parsed === 'function') { + // Fallback to rebuilding it from parsed diff + const parsed = changeSet.parsed(); + for(const file of parsed.files) { + gitPatchStr += `--- a/${file.path}\n+++ b/${file.path}\n`; + for(const chunk of file.chunks) { + gitPatchStr += `@@ -${chunk.oldStart},${chunk.oldLines} +${chunk.newStart},${chunk.newLines} @@\n`; + for(const change of chunk.changes) { + if(change.type === 'add') gitPatchStr += `+${change.content}\n`; + if(change.type === 'del') gitPatchStr += `-${change.content}\n`; + if(change.type === 'normal') gitPatchStr += ` ${change.content}\n`; + } + } + } + } else { + // Fallback to checking generated files if changeset isn't structured nicely + const files = genOutcome.generatedFiles(); + for (const [filename, content] of files.entries()) { + gitPatchStr += `File: ${filename}\n${content.content}\n`; + } + } + + if (!gitPatchStr || gitPatchStr.trim() === '') { + return { + success: false, + error: { + code: 'NO_CHANGES_GENERATED', + message: 'The agent did not generate any code changes.', + recoverable: false, + }, + }; + } + + this.log('\n--- Extracted Patch Content ---', input.json); + this.log(gitPatchStr, input.json); + this.log('-------------------------------\n', input.json); + + this.log('Starting review session...', input.json); + + // 3. Review the code + const reviewSession = await jules.session({ + prompt: `Review the following code change patch and determine if it adheres to clean coding standards. +Provide a short summary of the issues found and how they should be fixed. + +## Git Patch +\`\`\`diff +${gitPatchStr} +\`\`\` +`, + source, + }); + + this.log(`Review Session ID: ${reviewSession.id}`, input.json); + + let reviewMessage = ''; + + // Stream to get live updates and block until finished + const revInfo = await reviewSession.info(); + if (revInfo.state !== 'completed' && revInfo.state !== 'failed') { + try { + for await (const activity of reviewSession.stream()) { + this.logActivity(activity, 'Review', input.json); + if (activity.type === 'agentMessaged') { + reviewMessage = activity.message; + } + } + } catch(e) { + // Ignore stream fetch errors + } + } + + const reviewOutcome = await reviewSession.result(); + + if (reviewOutcome.state === 'failed') { + return { + success: false, + error: { + code: 'SESSION_FAILED', + message: `Review session failed: ${reviewSession.id}`, + recoverable: false, + }, + }; + } + + if (!reviewMessage) { + // Check files if no message + const files = reviewOutcome.generatedFiles(); + if (files.size > 0) { + for (const [filename, content] of files.entries()) { + reviewMessage += `\nFile: ${filename}\n${content.content}\n`; + } + } else { + reviewMessage = "The review completed but the agent provided no feedback."; + } + } + + return { + success: true, + data: { + codeGenSessionId: codeGenSession.id, + reviewSessionId: reviewSession.id, + gitPatchStr, + reviewMessage, + }, + }; + + } catch (error) { + return { + success: false, + error: { + code: 'UNKNOWN_ERROR', + message: error instanceof Error ? error.message : String(error), + recoverable: false, + }, + }; + } + } + + private async streamSessionActivities(session: Session, prefix: string, isJson: boolean) { + for await (const activity of session.stream()) { + this.logActivity(activity, prefix, isJson); + } + } + + private logActivity(activity: Activity, prefix: string, isJson: boolean) { + if (activity.type === 'progressUpdated') { + this.log(`[${prefix}] ${activity.title}`, isJson); + } else if (activity.type === 'agentMessaged') { + this.log(`[${prefix} Agent]: ${activity.message}`, isJson); + } else if (activity.type === 'planGenerated') { + this.log(`[${prefix}] Generated plan with ${activity.plan.steps.length} steps.`, isJson); + } + } + + private log(message: string, isJson: boolean) { + if (isJson) { + console.error(message); + } else { + console.log(message); + } + } +} diff --git a/packages/core/examples/gitpatch-review/src/spec.ts b/packages/core/examples/gitpatch-review/src/spec.ts new file mode 100644 index 0000000..0878041 --- /dev/null +++ b/packages/core/examples/gitpatch-review/src/spec.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +// 1. INPUT (The Command) - "Parse, don't validate" +export const ReviewInputSchema = z.object({ + repository: z.string().min(1, 'Repository must be provided (e.g., owner/repo)'), + baseBranch: z.string().min(1, 'Base branch must be provided (e.g., main)'), + prompt: z.string().min(1, 'A prompt to generate code must be provided'), + json: z.boolean().default(false), +}); + +export type ReviewInput = z.infer; + +// 2. ERROR CODES (Exhaustive) +export const ReviewErrorCode = z.enum([ + 'SESSION_FAILED', + 'NO_CHANGES_GENERATED', + 'UNKNOWN_ERROR', + 'UNAUTHORIZED', +]); + +// 3. RESULT (The Monad) +export const ReviewSuccess = z.object({ + success: z.literal(true), + data: z.object({ + reviewMessage: z.string(), + codeGenSessionId: z.string(), + reviewSessionId: z.string(), + gitPatchStr: z.string(), + }), +}); + +export const ReviewFailure = z.object({ + success: z.literal(false), + error: z.object({ + code: ReviewErrorCode, + message: z.string(), + suggestion: z.string().optional(), + recoverable: z.boolean(), + }), +}); + +export type ReviewResult = z.infer | z.infer; + +// 4. INTERFACE (The Capability) +export interface ReviewSpec { + execute(input: ReviewInput): Promise; +} diff --git a/packages/core/examples/google-sheets/README.md b/packages/core/examples/google-sheets/README.md new file mode 100644 index 0000000..7a442ac --- /dev/null +++ b/packages/core/examples/google-sheets/README.md @@ -0,0 +1,59 @@ +# Google Sheets Context Example CLI + +This example demonstrates how to extract tabular data from a Google Sheet using the `googleapis` library and pass it as context into an interactive Jules session prompt using a Command Line Interface (CLI). + +It implements the [Typed Service Contract](https://raw.githubusercontent.com/davideast/stitch-mcp/refs/heads/main/.gemini/skills/typed-service-contract/skill.md) pattern and follows [Agent CLI Best Practices](https://justin.poehnelt.com/posts/rewrite-your-cli-for-ai-agents.md) with a dedicated `--json` output format. + +## Requirements + +- Node.js >= 18 or Bun +- A Jules API Key (`JULES_API_KEY` environment variable) +- Google Cloud Service Account Credentials configured for application default (`GOOGLE_APPLICATION_CREDENTIALS` environment variable) +- Enable the Google Sheets API in your Google Cloud Project. + +## Setup + +1. Make sure you have installed the SDK dependencies in the project root by running `bun install`. + +2. Export your Jules API key: + ```bash + export JULES_API_KEY="your-api-key-here" + ``` + +3. Export your Google Cloud Credentials JSON file path: + ```bash + export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service-account-file.json" + ``` + +## Running the CLI + +Navigate to this directory and use `bun` to run the file. Use the `--help` flag to see available options: + +```bash +bun run index.ts --help +``` + +### Basic Usage + +Provide the spreadsheet ID, range, and your prompt for the AI agent: + +```bash +bun run index.ts \ + --spreadsheetId "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms" \ + --range "Class Data!A2:E" \ + --prompt "Analyze the following student data from a Google Sheet and provide a brief summary of the key demographics and trends." +``` + +### Agent / Machine-Readable Mode + +To output the result as a strictly formatted JSON object suitable for agents and downstream parsing, use the `--json` flag: + +```bash +bun run index.ts --spreadsheetId "..." --range "..." --prompt "..." --json +``` + +## What it does + +The script uses `citty` to parse arguments and `zod` to validate input schemas (Spec and Handler pattern). It authenticates with Google Cloud using Application Default Credentials to retrieve rows from the provided spreadsheet ID and range. + +It converts the rows into comma-separated text, appends it to your prompt, and starts a `jules.session()`. It waits for the agent to complete the task and displays the final analysis response or output files, returning structured errors on failure without throwing unhandled exceptions. diff --git a/packages/core/examples/google-sheets/handler.ts b/packages/core/examples/google-sheets/handler.ts new file mode 100644 index 0000000..9bb7a8e --- /dev/null +++ b/packages/core/examples/google-sheets/handler.ts @@ -0,0 +1,130 @@ +import { jules } from '@google/jules-sdk'; +import { google } from 'googleapis'; +import { RunSessionSpec, RunSessionInput, RunSessionResult } from './spec.js'; + +export class GoogleSheetsSessionHandler implements RunSessionSpec { + async execute(input: RunSessionInput): Promise { + try { + if (!process.env.JULES_API_KEY) { + return { + success: false, + error: { + code: 'MISSING_CREDENTIALS', + message: 'JULES_API_KEY environment variable is not set.', + recoverable: true, + }, + }; + } + + const auth = new google.auth.GoogleAuth({ + scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'], + }); + + const sheets = google.sheets({ version: 'v4', auth }); + + let response; + try { + response = await sheets.spreadsheets.values.get({ + spreadsheetId: input.spreadsheetId, + range: input.range, + }); + } catch (authErr: any) { + if (authErr.message && authErr.message.includes('Could not load the default credentials')) { + return { + success: false, + error: { + code: 'MISSING_CREDENTIALS', + message: 'Google Application Default Credentials not found. Please set GOOGLE_APPLICATION_CREDENTIALS.', + recoverable: true, + }, + }; + } + return { + success: false, + error: { + code: 'API_ERROR', + message: `Google Sheets API Error: ${authErr.message || String(authErr)}`, + recoverable: false, + }, + }; + } + + const rows = response.data.values; + if (!rows || rows.length === 0) { + return { + success: false, + error: { + code: 'SHEET_NOT_FOUND_OR_EMPTY', + message: 'No data found in the specified spreadsheet range.', + recoverable: true, + }, + }; + } + + const sheetContext = rows.map((row) => row.join(', ')).join('\n'); + const finalPrompt = `${input.prompt}\n\n## Source Data\n${sheetContext}`; + + let session; + try { + session = await jules.session({ prompt: finalPrompt }); + } catch (julesErr: any) { + return { + success: false, + error: { + code: 'JULES_ERROR', + message: `Jules Session Creation Error: ${julesErr.message || String(julesErr)}`, + recoverable: false, + }, + }; + } + + const outcome = await session.result(); + + let agentMessage = undefined; + let generatedFiles: Record = {}; + + if (outcome.state === 'completed') { + try { + const activities = await jules.select({ + from: 'activities', + where: { type: 'agentMessaged', 'session.id': session.id }, + order: 'desc', + limit: 1, + }); + + if (activities.length > 0) { + agentMessage = activities[0].message; + } else { + const files = outcome.generatedFiles(); + if (files.size > 0) { + for (const [filename, content] of files.entries()) { + generatedFiles[filename] = content.content; + } + } + } + } catch (queryErr) { + console.error('Failed to query local cache for agent messages:', queryErr); + } + } + + return { + success: true, + data: { + sessionId: session.id, + state: outcome.state, + agentMessage, + files: Object.keys(generatedFiles).length > 0 ? generatedFiles : undefined, + }, + }; + } catch (error) { + return { + success: false, + error: { + code: 'UNKNOWN_ERROR', + message: error instanceof Error ? error.message : String(error), + recoverable: false, + }, + }; + } + } +} diff --git a/packages/core/examples/google-sheets/index.ts b/packages/core/examples/google-sheets/index.ts new file mode 100644 index 0000000..093ee17 --- /dev/null +++ b/packages/core/examples/google-sheets/index.ts @@ -0,0 +1,87 @@ +import { defineCommand, runMain } from 'citty'; +import { RunSessionInputSchema } from './spec.js'; +import { GoogleSheetsSessionHandler } from './handler.js'; + +const mainCommand = defineCommand({ + meta: { + name: 'google-sheets-context', + version: '1.0.0', + description: 'A CLI tool that reads from a Google Sheet and uses the data to start a Jules session.', + }, + args: { + spreadsheetId: { + type: 'string', + description: 'The ID of the Google Spreadsheet to read from', + required: true, + alias: 's', + }, + range: { + type: 'string', + description: 'The A1 notation of the values to retrieve (e.g. "Class Data!A2:E")', + required: true, + alias: 'r', + }, + prompt: { + type: 'string', + description: 'The initial prompt to give to the Jules session, instructing it on what to do with the data', + required: true, + alias: 'p', + }, + json: { + type: 'boolean', + description: 'Output response as JSON', + default: false, + }, + }, + async run({ args }) { + // Input validation + const inputResult = RunSessionInputSchema.safeParse(args); + + if (!inputResult.success) { + if (args.json) { + console.error(JSON.stringify({ error: inputResult.error.errors })); + } else { + console.error('Validation Error:', inputResult.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')); + } + process.exit(1); + } + + if (!args.json) { + console.log('Fetching Google Sheet data and initializing session...'); + } + + const handler = new GoogleSheetsSessionHandler(); + const result = await handler.execute(inputResult.data); + + if (!result.success) { + if (args.json) { + console.error(JSON.stringify(result.error)); + } else { + console.error(`Error (${result.error.code}): ${result.error.message}`); + if (result.error.recoverable) { + console.log('Suggestion: Check your credentials and input values.'); + } + } + process.exit(1); + } + + if (args.json) { + console.log(JSON.stringify(result.data, null, 2)); + } else { + console.log(`\nSession Completed!`); + console.log(`Session ID: ${result.data.sessionId}`); + console.log(`State: ${result.data.state}`); + + if (result.data.agentMessage) { + console.log(`\nAgent Analysis:\n${result.data.agentMessage}`); + } else if (result.data.files) { + console.log('\nGenerated Files:'); + for (const [filename, content] of Object.entries(result.data.files)) { + console.log(`\nFile: ${filename}\n${content}`); + } + } + } + }, +}); + +runMain(mainCommand); diff --git a/packages/core/examples/google-sheets/package.json b/packages/core/examples/google-sheets/package.json new file mode 100644 index 0000000..f118ea0 --- /dev/null +++ b/packages/core/examples/google-sheets/package.json @@ -0,0 +1,13 @@ +{ + "name": "google-sheets-context-example", + "type": "module", + "scripts": { + "start": "bun run index.ts" + }, + "dependencies": { + "@google/jules-sdk": "workspace:*", + "citty": "^0.2.1", + "googleapis": "^171.4.0", + "zod": "^4.3.6" + } +} diff --git a/packages/core/examples/google-sheets/spec.ts b/packages/core/examples/google-sheets/spec.ts new file mode 100644 index 0000000..a8d2490 --- /dev/null +++ b/packages/core/examples/google-sheets/spec.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; + +// 1. INPUT (The Command) +export const RunSessionInputSchema = z.object({ + spreadsheetId: z.string().min(1, 'Spreadsheet ID is required'), + range: z.string().min(1, 'Range is required'), + prompt: z.string().min(1, 'Prompt is required'), + json: z.boolean().default(false).optional(), +}); +export type RunSessionInput = z.infer; + +// 2. ERROR CODES +export const RunSessionErrorCode = z.enum([ + 'MISSING_CREDENTIALS', + 'SHEET_NOT_FOUND_OR_EMPTY', + 'API_ERROR', + 'JULES_ERROR', + 'UNKNOWN_ERROR' +]); + +// 3. RESULT +export const RunSessionSuccess = z.object({ + success: z.literal(true), + data: z.object({ + sessionId: z.string(), + state: z.string(), + agentMessage: z.string().optional(), + files: z.record(z.string(), z.string()).optional(), + }), +}); + +export const RunSessionFailure = z.object({ + success: z.literal(false), + error: z.object({ + code: RunSessionErrorCode, + message: z.string(), + recoverable: z.boolean(), + }) +}); + +export type RunSessionResult = + | z.infer + | z.infer; + +// 4. INTERFACE +export interface RunSessionSpec { + execute(input: RunSessionInput): Promise; +} diff --git a/packages/core/examples/nextjs/README.md b/packages/core/examples/nextjs/README.md new file mode 100644 index 0000000..c207d95 --- /dev/null +++ b/packages/core/examples/nextjs/README.md @@ -0,0 +1,69 @@ +# Next.js Integration Example + +This example demonstrates how to integrate the Jules SDK into a Next.js application using the App Router. + +It covers two common patterns for starting Jules sessions from a Next.js application: +1. **Server Actions:** Ideal for form submissions and simple mutations. +2. **API Route Handlers:** Ideal for webhooks, external client integrations, or more complex APIs. + +## Prerequisites + +- A Jules API Key (`JULES_API_KEY` environment variable). + +## How to use in your Next.js app + +### 1. Server Actions + +You can use the `triggerJulesTask` pattern from `index.ts` directly in your Next.js application. + +Create a file like `app/actions.ts` and add `'use server'` at the top: + +```typescript +'use server' + +import { jules } from '@google/jules-sdk'; + +export async function triggerJulesTask(prompt: string) { + const session = await jules.session({ prompt }); + return { success: true, sessionId: session.id }; +} +``` + +Then, import and call this action from your Client Components or Server Components. + +### 2. API Route Handlers + +You can copy the `POST` function pattern to a file like `app/api/jules/route.ts` to create a REST endpoint for your Next.js application. + +```typescript +import { jules } from '@google/jules-sdk'; + +export async function POST(req: Request) { + const body = await req.json(); + const session = await jules.session({ + prompt: body.taskDescription, + source: { github: body.githubUrl }, + }); + + return new Response(JSON.stringify({ sessionId: session.id }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} +``` + +## Running the Example Locally + +The `index.ts` file includes a runnable test script to verify that both the Server Action function and the API Route function work as expected. + +Ensure you have your API key set: + +```bash +export JULES_API_KEY="your-api-key-here" +``` + +Then, you can run the file using `bun` (or another runner like `tsx`): + +```bash +bun run index.ts +``` diff --git a/packages/core/examples/nextjs/index.ts b/packages/core/examples/nextjs/index.ts new file mode 100644 index 0000000..b54f192 --- /dev/null +++ b/packages/core/examples/nextjs/index.ts @@ -0,0 +1,142 @@ +import { jules } from '@google/jules-sdk'; + +/** + * Next.js Integration Example + * + * This file demonstrates how to integrate the Jules SDK into a Next.js application. + * It provides two common patterns: + * 1. A Server Action (App Router) + * 2. An API Route Handler (App Router) + */ + +// ============================================================================ +// Pattern 1: Next.js Server Action +// ============================================================================ +// Typically placed in a file like `app/actions.ts` or inline with components. +// +// 'use server' // Uncomment this in a real Next.js Server Action file + +/** + * A server action to trigger a Jules session from a form submission or button click. + */ +export async function triggerJulesTask(prompt: string) { + try { + // Check if the API key is configured (Next.js automatically loads .env files) + if (!process.env.JULES_API_KEY) { + throw new Error('JULES_API_KEY is not configured'); + } + + // Example: Create a repoless session for a simple task + const session = await jules.session({ + prompt: `Review the following text and provide a summary: \n\n${prompt}`, + }); + + console.log(`[Server Action] Session created: ${session.id}`); + + // In a real application, you might save the session.id to your database here + // so the client can poll or subscribe to updates. + + return { success: true, sessionId: session.id }; + } catch (error) { + console.error('[Server Action] Failed to start Jules task:', error); + return { success: false, error: 'Failed to start task' }; + } +} + +// ============================================================================ +// Pattern 2: Next.js API Route Handler +// ============================================================================ +// Typically placed in a file like `app/api/jules/route.ts` + +/** + * A Next.js App Router API Handler (POST method) + * + * @param req NextRequest + */ +export async function POST(req: Request) { + try { + if (!process.env.JULES_API_KEY) { + return new Response(JSON.stringify({ error: 'JULES_API_KEY missing' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const body = await req.json(); + const { githubUrl, taskDescription } = body; + + if (!githubUrl || !taskDescription) { + return new Response(JSON.stringify({ error: 'Missing parameters' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Example: Create a session linked to a GitHub repository + const session = await jules.session({ + prompt: taskDescription, + source: { github: githubUrl }, + }); + + console.log(`[API Route] Session created: ${session.id}`); + + // Return the session ID to the client + return new Response(JSON.stringify({ sessionId: session.id }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + console.error('[API Route] Error:', error); + return new Response(JSON.stringify({ error: 'Internal Server Error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} + +// ============================================================================ +// Local Test Runner +// ============================================================================ +// This allows you to run this example file directly to verify it works. + +async function main() { + if (!process.env.JULES_API_KEY) { + console.error('Error: JULES_API_KEY environment variable is not set.'); + console.error('Please set it using: export JULES_API_KEY="your-api-key"'); + process.exit(1); + } + + console.log('Testing Server Action example...'); + const result = await triggerJulesTask('Next.js Server Actions are great for mutations.'); + + if (result.success) { + console.log(`Server Action successfully created session: ${result.sessionId}`); + } else { + console.error('Server Action failed:', result.error); + } + + console.log('\\nTesting API Route example...'); + // Simulate a Next.js Request object + const mockRequest = new Request('http://localhost:3000/api/jules', { + method: 'POST', + body: JSON.stringify({ + githubUrl: 'google-labs-code/jules-sdk', + taskDescription: 'Look for any console.log statements and remove them.' + }), + headers: { 'Content-Type': 'application/json' } + }); + + const response = await POST(mockRequest); + const responseData = await response.json(); + + if (response.ok) { + console.log(`API Route successfully created session: ${responseData.sessionId}`); + } else { + console.error('API Route failed:', responseData.error); + } +} + +// Run the main function if executed directly (e.g. via `bun run index.ts`) +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch(console.error); +} diff --git a/packages/core/examples/nextjs/package.json b/packages/core/examples/nextjs/package.json new file mode 100644 index 0000000..50d36f7 --- /dev/null +++ b/packages/core/examples/nextjs/package.json @@ -0,0 +1,22 @@ +{ + "name": "jules-nextjs-example", + "version": "1.0.0", + "description": "An example of integrating the Jules SDK in a Next.js application", + "type": "module", + "scripts": { + "build": "tsc --noEmit", + "start": "bun run index.ts" + }, + "dependencies": { + "@google/jules-sdk": "workspace:*", + "next": "^14.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "typescript": "^5.0.0" + } +}