From c1df63d7229f05b57abba4af11e74a8d2bc6dcd9 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Mon, 24 Nov 2025 08:38:50 -0700 Subject: [PATCH 1/7] feat: upgrade ai-sdk-provider-claude-code to v2.2.0 for native structured outputs (#1436) --- .changeset/native-structured-outputs.md | 5 + docs/claude-code-integration.md | 62 +++ package-lock.json | 145 ++----- package.json | 2 +- .../claude-code-structured-output.test.js | 366 ++++++++++++++++++ 5 files changed, 474 insertions(+), 106 deletions(-) create mode 100644 .changeset/native-structured-outputs.md create mode 100644 tests/unit/ai-providers/claude-code-structured-output.test.js diff --git a/.changeset/native-structured-outputs.md b/.changeset/native-structured-outputs.md new file mode 100644 index 00000000..568933ee --- /dev/null +++ b/.changeset/native-structured-outputs.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Upgrade ai-sdk-provider-claude-code to v2.2.0 for native structured outputs support. diff --git a/docs/claude-code-integration.md b/docs/claude-code-integration.md index dba1c5b9..62b6a0f3 100644 --- a/docs/claude-code-integration.md +++ b/docs/claude-code-integration.md @@ -194,6 +194,68 @@ Task Master's Claude Code integration uses the official `ai-sdk-provider-claude- - **Full AI SDK Compatibility**: Works with generateText, streamText, and other AI SDK functions - **Automatic Error Handling**: Graceful degradation when Claude Code is unavailable - **Type Safety**: Full TypeScript support with proper type definitions +- **Native Structured Outputs (v2.2.0+)**: Guaranteed schema-compliant JSON responses + +## Native Structured Outputs (v2.2.0+) + +As of `ai-sdk-provider-claude-code` v2.2.0, Claude Code supports **native structured outputs** via the Claude Agent SDK's `outputFormat` option. This provides significant benefits for Task Master's structured data generation: + +### Key Benefits + +- **Guaranteed Schema Compliance**: The SDK uses constrained decoding to ensure responses always match your Zod schema +- **No JSON Parsing Errors**: Schema validation is handled internally by the SDK +- **No Prompt Engineering Required**: No need for "output valid JSON" instructions - the SDK enforces schema natively +- **Better Performance**: No retry/extraction logic needed for valid JSON output + +### How It Works + +When Task Master calls `generateObject()` or `streamObject()` with a Zod schema, the Claude Code provider: + +1. Sets `mode: 'json'` (via `needsExplicitJsonSchema = true`) +2. Passes the schema to the SDK +3. The SDK converts the Zod schema to JSON Schema and uses `outputFormat: { type: 'json_schema', schema: ... }` +4. Claude Agent SDK returns `structured_output` with guaranteed schema compliance + +### Example + +```javascript +import { z } from 'zod'; +import { generateObjectService } from './scripts/modules/ai-services-unified.js'; + +// Define your schema +const taskSchema = z.object({ + title: z.string().min(1), + description: z.string().min(1), + priority: z.enum(['high', 'medium', 'low']), + dependencies: z.array(z.number().int()) +}); + +// Generate structured output - guaranteed to match schema +const result = await generateObjectService({ + role: 'main', // Uses Claude Code if configured as main provider + schema: taskSchema, + objectName: 'task', + prompt: 'Create a task for implementing user authentication', + systemPrompt: 'You are a task planning assistant.', + commandName: 'add-task', + outputType: 'cli' +}); + +// result.mainResult is guaranteed to match taskSchema +console.log(result.mainResult); +// { title: "...", description: "...", priority: "high", dependencies: [] } +``` + +### Supported Commands + +All Task Master commands that generate structured data benefit from native schema support: + +- `parse-prd` - Parsing PRD documents into tasks +- `add-task` - Creating new tasks +- `expand-task` - Expanding tasks into subtasks +- `update-tasks` - Batch updating tasks +- `update-task-by-id` - Updating individual tasks +- `analyze-complexity` - Analyzing task complexity ### Example AI SDK Usage diff --git a/package-lock.json b/package-lock.json index 1111c788..2eb31b33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "@streamparser/json": "^0.0.22", "@supabase/supabase-js": "^2.57.4", "ai": "^5.0.51", - "ai-sdk-provider-claude-code": "^2.1.0", + "ai-sdk-provider-claude-code": "^2.2.0", "ai-sdk-provider-codex-cli": "^0.3.0", "ai-sdk-provider-gemini-cli": "^1.1.1", "ajv": "^8.17.1", @@ -1563,6 +1563,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2797,6 +2798,7 @@ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -6031,6 +6033,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6162,7 +6165,6 @@ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -6205,21 +6207,6 @@ "node": ">=14.0.0" } }, - "node_modules/@mintlify/common/node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/@mintlify/link-rot": { "version": "3.0.753", "resolved": "https://registry.npmjs.org/@mintlify/link-rot/-/link-rot-3.0.753.tgz", @@ -6883,7 +6870,6 @@ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -7341,6 +7327,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -7374,6 +7361,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -8338,6 +8326,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -8687,8 +8676,7 @@ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", @@ -9277,7 +9265,6 @@ "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" @@ -9303,7 +9290,6 @@ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, @@ -12318,6 +12304,7 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -12328,6 +12315,7 @@ "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -12593,30 +12581,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/ui": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.12.tgz", - "integrity": "sha512-RCqeApCnbwd5IFvxk6OeKMXTvzHU/cVqY8HAW0gWk0yAO6wXwQJMKhDfDtk2ss7JCy9u7RNC3kyazwiaDhBA/g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vitest/utils": "4.0.12", - "fflate": "^0.8.2", - "flatted": "^3.3.3", - "pathe": "^2.0.3", - "sirv": "^3.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "4.0.12" - } - }, "node_modules/@vitest/utils": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.12.tgz", @@ -12999,6 +12963,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -13076,6 +13041,7 @@ "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.97.tgz", "integrity": "sha512-8zBx0b/owis4eJI2tAlV8a1Rv0BANmLxontcAelkLNwEHhgfgXeKpDkhNB6OgV+BJSwboIUDkgd9312DdJnCOQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@ai-sdk/gateway": "2.0.12", "@ai-sdk/provider": "2.0.0", @@ -13090,15 +13056,14 @@ } }, "node_modules/ai-sdk-provider-claude-code": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ai-sdk-provider-claude-code/-/ai-sdk-provider-claude-code-2.1.0.tgz", - "integrity": "sha512-g+Nb+TKQs7RMP8PEn7NBjEHwVyIqHthXKSUqADO37oGPwOeMATuWCcuYAFmbeSdpIirV6iKRXRhdFipv2yTzqQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ai-sdk-provider-claude-code/-/ai-sdk-provider-claude-code-2.2.0.tgz", + "integrity": "sha512-MN/l+GZ76tICIN+92Mhdi/XMiimPqcnx8HBARxfph2nnvt630ZPEBHAnqfa/BKGYbGtfxfnoiLLahfKfaYdiEg==", "license": "MIT", "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.9", - "@anthropic-ai/claude-agent-sdk": "^0.1.20", - "jsonc-parser": "^3.3.1" + "@anthropic-ai/claude-agent-sdk": "^0.1.50" }, "engines": { "node": ">=18" @@ -13125,9 +13090,9 @@ } }, "node_modules/ai-sdk-provider-claude-code/node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.1.47", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.47.tgz", - "integrity": "sha512-0LAXuqp2AsvJcvrpVrJKANbmkqp3ZMpEfm03vRL6DrLc8JIQ5n25aCagIBDMwIVJscDwjd9cn4uAHvdI2PMLvw==", + "version": "0.1.50", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.50.tgz", + "integrity": "sha512-vHOLohUeiVadWl4eTAbw12ACIG1wZ/NN4ScLe8P/yrsldT1QkYwn6ndkoilaFBB2gIHECEx7wRAtSfCLefge4Q==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" @@ -13303,6 +13268,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14509,6 +14475,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -16735,7 +16702,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dezalgo": { "version": "1.0.4", @@ -17450,6 +17418,7 @@ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -18663,8 +18632,7 @@ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/figlet": { "version": "1.9.4", @@ -18844,8 +18812,7 @@ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/follow-redirects": { "version": "1.15.11", @@ -20573,6 +20540,7 @@ "integrity": "sha512-wF3j/DmkM8q5E+OtfdQhCRw8/0ahkc8CUTgEddxZzpEWPslu7YPL3t64MWRoI9m6upVGpfAg4ms2BBvxCdKRLQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.2.0", @@ -21801,6 +21769,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -23672,6 +23641,7 @@ "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.16.0" } @@ -24081,7 +24051,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24103,7 +24072,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24125,7 +24093,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24147,7 +24114,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24169,7 +24135,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24191,7 +24156,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24213,7 +24177,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24235,7 +24198,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24257,7 +24219,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24279,7 +24240,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24301,7 +24261,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24580,7 +24539,6 @@ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -24593,8 +24551,7 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lowercase-keys": { "version": "3.0.0", @@ -24741,6 +24698,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -26474,7 +26432,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=10" } @@ -27907,6 +27864,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -28639,6 +28597,7 @@ "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -28649,6 +28608,7 @@ "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -29648,6 +29608,7 @@ "integrity": "sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.95.0", "@rolldown/pluginutils": "1.0.0-beta.45" @@ -30467,7 +30428,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", @@ -31825,7 +31785,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=6" } @@ -32683,20 +32642,6 @@ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "license": "MIT" }, - "node_modules/tsup/node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/tsx": { "version": "4.20.6", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", @@ -32995,6 +32940,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -33153,6 +33099,7 @@ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -33667,6 +33614,7 @@ "integrity": "sha512-pmW4GCKQ8t5Ko1jYjC3SqOr7TUKN7uHOHB/XGsAIb69eYu6d1ionGSsb5H9chmPf+WeXt0VE7jTXsB1IvWoNbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.12", "@vitest/mocker": "4.0.12", @@ -33783,6 +33731,7 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -33852,21 +33801,6 @@ } } }, - "node_modules/vitest/node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -34615,6 +34549,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index cf3ef5bc..f6d759a8 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "@streamparser/json": "^0.0.22", "@supabase/supabase-js": "^2.57.4", "ai": "^5.0.51", - "ai-sdk-provider-claude-code": "^2.1.0", + "ai-sdk-provider-claude-code": "^2.2.0", "ai-sdk-provider-codex-cli": "^0.3.0", "ai-sdk-provider-gemini-cli": "^1.1.1", "ajv": "^8.17.1", diff --git a/tests/unit/ai-providers/claude-code-structured-output.test.js b/tests/unit/ai-providers/claude-code-structured-output.test.js new file mode 100644 index 00000000..bc734bd7 --- /dev/null +++ b/tests/unit/ai-providers/claude-code-structured-output.test.js @@ -0,0 +1,366 @@ +import { jest } from '@jest/globals'; +import { z } from 'zod'; + +/** + * Tests for Claude Code native structured output support (v2.2.0+) + * + * ai-sdk-provider-claude-code v2.2.0 introduced native structured outputs via + * the Claude Agent SDK's outputFormat option. When using generateObject() with + * a schema, the SDK now guarantees schema-compliant JSON through constrained decoding. + * + * Key behaviors tested: + * 1. Schema is passed correctly to the SDK + * 2. mode: 'json' is used (which enables native outputFormat in the SDK) + * 3. SDK error handling for schema validation failures + */ + +// Mock generateObject from 'ai' SDK +const mockGenerateObject = jest.fn(); + +jest.unstable_mockModule('ai', () => ({ + generateObject: mockGenerateObject, + generateText: jest.fn(), + streamText: jest.fn(), + streamObject: jest.fn(), + zodSchema: jest.fn((schema) => schema), + JSONParseError: class JSONParseError extends Error { + constructor(message, text) { + super(message); + this.text = text; + } + }, + NoObjectGeneratedError: class NoObjectGeneratedError extends Error { + static isInstance(error) { + return error instanceof NoObjectGeneratedError; + } + } +})); + +// Mock jsonrepair +jest.unstable_mockModule('jsonrepair', () => ({ + jsonrepair: jest.fn((text) => text) +})); + +// Mock the ai-sdk-provider-claude-code package +jest.unstable_mockModule('ai-sdk-provider-claude-code', () => ({ + createClaudeCode: jest.fn(() => { + const provider = (modelId) => ({ + id: modelId, + specificationVersion: 'v1', + provider: 'claude-code', + modelId + }); + provider.languageModel = provider; + provider.chat = provider; + return provider; + }) +})); + +// Mock config getters +jest.unstable_mockModule('../../../scripts/modules/config-manager.js', () => ({ + getClaudeCodeSettingsForCommand: jest.fn(() => ({})), + getSupportedModelsForProvider: jest.fn(() => ['opus', 'sonnet', 'haiku']), + getDebugFlag: jest.fn(() => false), + getLogLevel: jest.fn(() => 'info'), + isProxyEnabled: jest.fn(() => false) +})); + +// Mock utils +jest.unstable_mockModule('../../../scripts/modules/utils.js', () => ({ + log: jest.fn(), + findProjectRoot: jest.fn(() => '/test/project') +})); + +// Import after mocking +const { ClaudeCodeProvider } = await import( + '../../../src/ai-providers/claude-code.js' +); + +describe('ClaudeCodeProvider structured outputs (v2.2.0+)', () => { + let provider; + + beforeEach(() => { + provider = new ClaudeCodeProvider(); + jest.clearAllMocks(); + }); + + describe('needsExplicitJsonSchema flag', () => { + it('should have needsExplicitJsonSchema set to true', () => { + // This flag triggers mode: 'json' in base-provider.js generateObject() + // which in turn enables the SDK's native outputFormat with constrained decoding + expect(provider.needsExplicitJsonSchema).toBe(true); + }); + + it('should not support temperature parameter', () => { + // Claude Code SDK doesn't support temperature + expect(provider.supportsTemperature).toBe(false); + }); + }); + + describe('generateObject with schema', () => { + const testSchema = z.object({ + name: z.string(), + age: z.number(), + email: z.string().email() + }); + + const testMessages = [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Generate a user profile' } + ]; + + beforeEach(() => { + // Mock successful generateObject response + mockGenerateObject.mockResolvedValue({ + object: { name: 'Test User', age: 25, email: 'test@example.com' }, + usage: { + promptTokens: 100, + completionTokens: 50, + totalTokens: 150 + } + }); + }); + + it('should pass schema to generateObject call', async () => { + await provider.generateObject({ + apiKey: 'test-key', + modelId: 'sonnet', + messages: testMessages, + schema: testSchema, + objectName: 'user_profile' + }); + + expect(mockGenerateObject).toHaveBeenCalledWith( + expect.objectContaining({ + schema: testSchema + }) + ); + }); + + it('should use json mode for Claude Code (enables native outputFormat)', async () => { + await provider.generateObject({ + apiKey: 'test-key', + modelId: 'sonnet', + messages: testMessages, + schema: testSchema, + objectName: 'user_profile' + }); + + // mode: 'json' is set when needsExplicitJsonSchema is true + // This triggers the SDK to use outputFormat: { type: 'json_schema', schema: ... } + expect(mockGenerateObject).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'json' + }) + ); + }); + + it('should pass schemaName for better SDK context', async () => { + await provider.generateObject({ + apiKey: 'test-key', + modelId: 'sonnet', + messages: testMessages, + schema: testSchema, + objectName: 'user_profile' + }); + + expect(mockGenerateObject).toHaveBeenCalledWith( + expect.objectContaining({ + schemaName: 'user_profile' + }) + ); + }); + + it('should return structured object from SDK', async () => { + const result = await provider.generateObject({ + apiKey: 'test-key', + modelId: 'sonnet', + messages: testMessages, + schema: testSchema, + objectName: 'user_profile' + }); + + expect(result.object).toEqual({ + name: 'Test User', + age: 25, + email: 'test@example.com' + }); + }); + + it('should return usage information', async () => { + const result = await provider.generateObject({ + apiKey: 'test-key', + modelId: 'sonnet', + messages: testMessages, + schema: testSchema, + objectName: 'user_profile' + }); + + expect(result.usage).toEqual({ + inputTokens: 100, + outputTokens: 50, + totalTokens: 150 + }); + }); + }); + + describe('complex schemas', () => { + it('should handle nested object schemas', async () => { + const complexSchema = z.object({ + tasks: z.array( + z.object({ + id: z.number(), + title: z.string(), + subtasks: z.array( + z.object({ + id: z.number(), + title: z.string() + }) + ) + }) + ) + }); + + mockGenerateObject.mockResolvedValue({ + object: { + tasks: [ + { + id: 1, + title: 'Main Task', + subtasks: [{ id: 1, title: 'Subtask 1' }] + } + ] + }, + usage: { promptTokens: 50, completionTokens: 30, totalTokens: 80 } + }); + + const result = await provider.generateObject({ + apiKey: 'test-key', + modelId: 'sonnet', + messages: [{ role: 'user', content: 'Generate tasks' }], + schema: complexSchema, + objectName: 'task_list' + }); + + expect(result.object.tasks).toHaveLength(1); + expect(result.object.tasks[0].subtasks).toHaveLength(1); + }); + + it('should handle enum schemas (like task priority)', async () => { + const prioritySchema = z.object({ + priority: z.enum(['high', 'medium', 'low']), + title: z.string() + }); + + mockGenerateObject.mockResolvedValue({ + object: { priority: 'high', title: 'Important Task' }, + usage: { promptTokens: 30, completionTokens: 20, totalTokens: 50 } + }); + + const result = await provider.generateObject({ + apiKey: 'test-key', + modelId: 'sonnet', + messages: [{ role: 'user', content: 'Create a task' }], + schema: prioritySchema, + objectName: 'task' + }); + + expect(result.object.priority).toBe('high'); + }); + }); + + describe('error handling', () => { + it('should throw error when schema is missing', async () => { + await expect( + provider.generateObject({ + apiKey: 'test-key', + modelId: 'sonnet', + messages: [{ role: 'user', content: 'test' }], + objectName: 'test' + // schema is missing + }) + ).rejects.toThrow('Schema is required'); + }); + + it('should throw error when objectName is missing', async () => { + await expect( + provider.generateObject({ + apiKey: 'test-key', + modelId: 'sonnet', + messages: [{ role: 'user', content: 'test' }], + schema: z.object({ name: z.string() }) + // objectName is missing + }) + ).rejects.toThrow('Object name is required'); + }); + + it('should handle SDK errors gracefully', async () => { + mockGenerateObject.mockRejectedValue( + new Error('SDK error: Failed to generate') + ); + + await expect( + provider.generateObject({ + apiKey: 'test-key', + modelId: 'sonnet', + messages: [{ role: 'user', content: 'test' }], + schema: z.object({ name: z.string() }), + objectName: 'test' + }) + ).rejects.toThrow(); + }); + }); + + describe('v2.2.0 native structured output benefits', () => { + /** + * These tests document the expected behavior with v2.2.0's native schema support. + * The SDK now handles schema validation internally through constrained decoding, + * so the jsonrepair fallback in base-provider.js should rarely be triggered + * for Claude Code operations. + */ + + it('should work with Task Master command schemas', async () => { + // This simulates the expand-task schema pattern + const expandTaskSchema = z.object({ + subtasks: z.array( + z.object({ + id: z.number().int().positive(), + title: z.string().min(1), + description: z.string().min(1), + dependencies: z.array(z.number().int()), + details: z.string(), + testStrategy: z.string() + }) + ) + }); + + mockGenerateObject.mockResolvedValue({ + object: { + subtasks: [ + { + id: 1, + title: 'Implement feature X', + description: 'Description for feature X', + dependencies: [], + details: 'Implementation details', + testStrategy: 'Unit tests for feature X' + } + ] + }, + usage: { promptTokens: 200, completionTokens: 100, totalTokens: 300 } + }); + + const result = await provider.generateObject({ + apiKey: 'test-key', + modelId: 'sonnet', + messages: [{ role: 'user', content: 'Expand task into subtasks' }], + schema: expandTaskSchema, + objectName: 'subtasks' + }); + + expect(result.object.subtasks).toHaveLength(1); + expect(result.object.subtasks[0].id).toBe(1); + expect(result.object.subtasks[0].title).toBe('Implement feature X'); + }); + }); +}); From 783398ecdf71432bd2b97f400756acbcfd60fbef Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Mon, 24 Nov 2025 10:56:18 -0700 Subject: [PATCH 2/7] feat(gemini-cli): upgrade to native structured output support (#1437) --- .../gemini-cli-native-structured-output.md | 9 + CONTRIBUTING.md | 2 +- apps/cli/package.json | 2 +- apps/docs/getting-started/contribute.mdx | 2 +- apps/extension/README.md | 4 +- apps/mcp/package.json | 2 +- docs/configuration.md | 2 +- docs/providers/codex-cli.md | 2 +- docs/providers/gemini-cli.md | 45 +- package-lock.json | 413 +++++------- package.json | 4 +- .../ai-sdk-provider-grok-cli/package.json | 2 +- .../commands/install-taskmaster.md | 6 +- packages/tm-core/README.md | 2 +- src/ai-providers/gemini-cli.js | 602 +----------------- .../gemini-cli-structured-output.test.js | 339 ++++++++++ tests/unit/ai-providers/gemini-cli.test.js | 579 +++-------------- 17 files changed, 688 insertions(+), 1329 deletions(-) create mode 100644 .changeset/gemini-cli-native-structured-output.md create mode 100644 tests/unit/ai-providers/gemini-cli-structured-output.test.js diff --git a/.changeset/gemini-cli-native-structured-output.md b/.changeset/gemini-cli-native-structured-output.md new file mode 100644 index 00000000..939f274b --- /dev/null +++ b/.changeset/gemini-cli-native-structured-output.md @@ -0,0 +1,9 @@ +--- +"task-master-ai": minor +--- + +Upgrade gemini-cli provider to native structured output support + +- Upgrade `ai-sdk-provider-gemini-cli` from v1.1.1 to v1.4.0 with native `responseJsonSchema` support +- Simplify provider implementation by removing JSON extraction workarounds (652 lines → 95 lines) +- Enable native structured output via Gemini API's schema enforcement diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 02b0ec2f..def662ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -170,7 +170,7 @@ Example: ### Prerequisites -- Node.js 18+ +- Node.js 20+ - npm or yarn ### Environment Setup diff --git a/apps/cli/package.json b/apps/cli/package.json index 63a789fd..86dbb717 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -41,7 +41,7 @@ "vitest": "^4.0.10" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "keywords": ["task-master", "cli", "task-management", "productivity"], "author": "", diff --git a/apps/docs/getting-started/contribute.mdx b/apps/docs/getting-started/contribute.mdx index 79b9fc55..f02f7a65 100644 --- a/apps/docs/getting-started/contribute.mdx +++ b/apps/docs/getting-started/contribute.mdx @@ -170,7 +170,7 @@ Example: ### Prerequisites -- Node.js 18+ +- Node.js 20+ - npm or yarn ### Environment Setup diff --git a/apps/extension/README.md b/apps/extension/README.md index 16d2c875..abaf83c7 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -40,7 +40,7 @@ Taskmaster AI is an intelligent task management system designed for AI-assisted ### Prerequisites 1. **VS Code** 1.90.0 or higher -2. **Node.js** 18.0 or higher (for Taskmaster MCP server) +2. **Node.js** 20.0 or higher (for Taskmaster MCP server) ### Install the Extension @@ -125,7 +125,7 @@ Access settings via **File → Preferences → Settings** and search for "Taskma ## šŸ”§ Troubleshooting ### **Extension Not Loading** -1. Ensure Node.js 18+ is installed +1. Ensure Node.js 20+ is installed 2. Check workspace contains `.taskmaster` folder 3. Restart VS Code 4. Check Output panel (View → Output → Taskmaster Kanban) diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 88f42f61..2b746be2 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -34,7 +34,7 @@ "vitest": "^4.0.10" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "keywords": [ "task-master", diff --git a/docs/configuration.md b/docs/configuration.md index 2d538eb1..585ce4e6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -505,7 +505,7 @@ Azure OpenAI provides enterprise-grade OpenAI models through Microsoft's Azure c The Codex CLI provider integrates Task Master with OpenAI's Codex CLI, allowing you to use ChatGPT subscription models via OAuth authentication. 1. **Prerequisites**: - - Node.js >= 18 + - Node.js >= 20 - Codex CLI >= 0.42.0 (>= 0.44.0 recommended) - ChatGPT subscription: Plus, Pro, Business, Edu, or Enterprise (for OAuth access to GPT-5 models) diff --git a/docs/providers/codex-cli.md b/docs/providers/codex-cli.md index d9d3af0d..d9a45f03 100644 --- a/docs/providers/codex-cli.md +++ b/docs/providers/codex-cli.md @@ -29,7 +29,7 @@ task-master models --set-main gpt-5-codex --codex-cli ## Requirements -- **Node.js**: >= 18.0.0 +- **Node.js**: >= 20.0.0 - **Codex CLI**: >= 0.42.0 (>= 0.44.0 recommended) - **ChatGPT Subscription**: Required for OAuth access (Plus, Pro, Business, Edu, or Enterprise) - **Task Master**: >= 0.27.3 (version with Codex CLI support) diff --git a/docs/providers/gemini-cli.md b/docs/providers/gemini-cli.md index c4e9f7a8..6b0f0bb6 100644 --- a/docs/providers/gemini-cli.md +++ b/docs/providers/gemini-cli.md @@ -107,7 +107,8 @@ You can also manually edit your `.taskmaster/config.json`: ### Available Models -The gemini-cli provider supports only two models: +The gemini-cli provider supports the following models: +- `gemini-3-pro-preview` - Latest preview model with best performance - `gemini-2.5-pro` - High performance model (1M token context window, 65,536 max output tokens) - `gemini-2.5-flash` - Fast, efficient model (1M token context window, 65,536 max output tokens) @@ -134,7 +135,8 @@ If you get an authentication error: ### "Model not found" Error -The gemini-cli provider only supports two models: +The gemini-cli provider supports the following models: +- `gemini-3-pro-preview` - `gemini-2.5-pro` - `gemini-2.5-flash` @@ -152,10 +154,45 @@ npm install -g @google/gemini-cli gemini --version ``` +## Native Structured Outputs (v1.4.0+) + +As of `ai-sdk-provider-gemini-cli` v1.4.0, the Gemini CLI provider now supports **native structured output** via Gemini's `responseJsonSchema` parameter. This provides several benefits: + +### Key Benefits + +- **Guaranteed Schema Compliance**: JSON output is constrained at the API level to match your schema +- **No JSON Parsing Errors**: Eliminates issues with malformed JSON or conversational preamble +- **Improved Reliability**: Native schema enforcement means consistent, predictable output +- **Better Performance**: No need for post-processing or JSON extraction from text + +### How It Works + +When you use Task Master commands that require structured output (like `parse-prd`, `expand`, `add-task`, `update-task`, or `analyze-complexity`), the provider: + +1. Passes the Zod schema directly to Gemini's API via `responseJsonSchema` +2. Sets `responseMimeType: 'application/json'` for clean JSON output +3. Returns validated, schema-compliant JSON without any text extraction needed + +### Supported Commands + +All commands that use structured output benefit from native schema enforcement: + +- `task-master parse-prd` - Parse PRD and generate tasks +- `task-master expand` - Expand tasks into subtasks +- `task-master add-task` - Add new tasks with AI assistance +- `task-master update-task` - Update existing tasks +- `task-master analyze-complexity` - Analyze task complexity + +### Requirements + +- **Node.js 20+**: The v1.4.0 SDK requires Node.js 20 or later +- **ai-sdk-provider-gemini-cli >= 1.4.0**: Included with Task Master automatically + ## Important Notes - **OAuth vs API Key**: This provider is specifically designed for users who want to use OAuth authentication via gemini-cli. If you prefer using API keys, consider using the standard `google` provider instead. -- **Limited Model Support**: Only `gemini-2.5-pro` and `gemini-2.5-flash` are available through gemini-cli. +- **Limited Model Support**: Only `gemini-3-pro-preview`, `gemini-2.5-pro`, and `gemini-2.5-flash` are available through gemini-cli. - **Subscription Benefits**: Using OAuth authentication allows you to leverage any subscription benefits associated with your Google account. +- **Node.js Requirement**: Requires Node.js 20+ due to native structured output support. - The provider uses the `ai-sdk-provider-gemini-cli` npm package internally. -- Supports all standard Task Master features: text generation, streaming, and structured object generation. \ No newline at end of file +- Supports all standard Task Master features: text generation, streaming, and structured object generation with native schema enforcement. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2eb31b33..c048930a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "ai": "^5.0.51", "ai-sdk-provider-claude-code": "^2.2.0", "ai-sdk-provider-codex-cli": "^0.3.0", - "ai-sdk-provider-gemini-cli": "^1.1.1", + "ai-sdk-provider-gemini-cli": "^1.4.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "boxen": "^8.0.1", @@ -102,7 +102,7 @@ "typescript": "^5.9.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "optionalDependencies": { "@anthropic-ai/claude-code": "^1.0.88", @@ -132,7 +132,7 @@ "vitest": "^4.0.10" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "apps/cli/node_modules/@types/node": { @@ -221,7 +221,7 @@ "vitest": "^4.0.10" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "apps/mcp/node_modules/@types/node": { @@ -1563,7 +1563,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2798,7 +2797,6 @@ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -3496,9 +3494,9 @@ } }, "node_modules/@google/gemini-cli-core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@google/gemini-cli-core/-/gemini-cli-core-0.16.0.tgz", - "integrity": "sha512-EYzcAUcIcfkLJQGHabS96Y47A9ofEapzgJwLtbzpUwYFBuAegQcnl3xhbdxfj6kCygVHq2rPoa/udEVfqryOjQ==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@google/gemini-cli-core/-/gemini-cli-core-0.17.1.tgz", + "integrity": "sha512-XL6cw7iksZGG36Na2dwI7rRtdpI+cHRbr2I3mcel5ApXkeIhcSzH6ojbk69bK/f2DUSB/OrJJDlonh5+sPTzOg==", "dependencies": { "@google-cloud/logging": "^11.2.1", "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", @@ -3529,7 +3527,7 @@ "fast-uri": "^3.0.6", "fdir": "^6.4.6", "fzf": "^0.5.2", - "glob": "^10.4.5", + "glob": "^12.0.0", "google-auth-library": "^9.11.0", "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", @@ -3562,27 +3560,6 @@ "node-pty": "^1.0.0" } }, - "node_modules/@google/gemini-cli-core/node_modules/@google/genai": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.16.0.tgz", - "integrity": "sha512-hdTYu39QgDFxv+FB6BK2zi4UIJGWhx2iPc0pHQ0C5Q/RCi+m+4gsryIzTGO+riqWcUA8/WGYp6hpqckdOBNysw==", - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^9.14.2", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.11.4" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, "node_modules/@google/gemini-cli-core/node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -3595,6 +3572,53 @@ "url": "https://dotenvx.com" } }, + "node_modules/@google/gemini-cli-core/node_modules/glob": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz", + "integrity": "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@google/gemini-cli-core/node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@google/gemini-cli-core/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@google/gemini-cli-core/node_modules/mime": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz", @@ -3610,6 +3634,37 @@ "node": ">=16" } }, + "node_modules/@google/gemini-cli-core/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@google/gemini-cli-core/node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@google/gemini-cli-core/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -3620,9 +3675,9 @@ } }, "node_modules/@google/genai": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.14.0.tgz", - "integrity": "sha512-jirYprAAJU1svjwSDVCzyVq+FrJpJd5CSxR/g2Ga/gZ0ZYZpcWjMS75KJl9y71K1mDN+tcx6s21CzCbB2R840g==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.16.0.tgz", + "integrity": "sha512-hdTYu39QgDFxv+FB6BK2zi4UIJGWhx2iPc0pHQ0C5Q/RCi+m+4gsryIzTGO+riqWcUA8/WGYp6hpqckdOBNysw==", "license": "Apache-2.0", "dependencies": { "google-auth-library": "^9.14.2", @@ -3632,7 +3687,7 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.11.0" + "@modelcontextprotocol/sdk": "^1.11.4" }, "peerDependenciesMeta": { "@modelcontextprotocol/sdk": { @@ -4380,6 +4435,27 @@ } } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -6033,7 +6109,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6165,6 +6240,7 @@ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -6870,6 +6946,7 @@ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -7327,7 +7404,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -7361,7 +7437,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -8326,7 +8401,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -8619,6 +8693,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -8670,14 +8745,6 @@ "node": ">=12" } }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -9265,6 +9332,7 @@ "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" @@ -9290,6 +9358,7 @@ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, @@ -12304,7 +12373,6 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -12315,7 +12383,6 @@ "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -12963,7 +13030,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -13041,7 +13107,6 @@ "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.97.tgz", "integrity": "sha512-8zBx0b/owis4eJI2tAlV8a1Rv0BANmLxontcAelkLNwEHhgfgXeKpDkhNB6OgV+BJSwboIUDkgd9312DdJnCOQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@ai-sdk/gateway": "2.0.12", "@ai-sdk/provider": "2.0.0", @@ -13148,17 +13213,17 @@ } }, "node_modules/ai-sdk-provider-gemini-cli": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ai-sdk-provider-gemini-cli/-/ai-sdk-provider-gemini-cli-1.3.0.tgz", - "integrity": "sha512-I9QNtTiQxpiF9QnSStKaKLDj1z9Ei3vDNKX9huiCJUwAJXl0kinH/MZLxU/CaUFhADlWq1qFmff85jqwXamqCw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ai-sdk-provider-gemini-cli/-/ai-sdk-provider-gemini-cli-1.4.0.tgz", + "integrity": "sha512-k5nWGbqUO28Bi8yQErBP1m5MQZQL1oCVFpCytSepYMAwfNP01QPcYyYR2bt8HrGGlEMS7Bl1CbuRjPFcJdccMg==", "license": "MIT", "dependencies": { "@ai-sdk/provider": "2.0.0", - "@ai-sdk/provider-utils": "3.0.3", - "@google/gemini-cli-core": "0.16.0", - "@google/genai": "1.14.0", - "google-auth-library": "10.2.1", - "zod-to-json-schema": "3.24.6" + "@ai-sdk/provider-utils": "3.0.17", + "@google/gemini-cli-core": "0.17.1", + "@google/genai": "1.16.0", + "google-auth-library": "^9.11.0", + "zod-to-json-schema": "3.25.0" }, "engines": { "node": ">=20" @@ -13167,108 +13232,11 @@ "zod": "^3.0.0 || ^4.0.0" } }, - "node_modules/ai-sdk-provider-gemini-cli/node_modules/@ai-sdk/provider-utils": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.3.tgz", - "integrity": "sha512-kAxIw1nYmFW1g5TvE54ZB3eNtgZna0RnLjPUp1ltz1+t9xkXJIuDT4atrwfau9IbS0BOef38wqrI8CjFfQrxhw==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "2.0.0", - "@standard-schema/spec": "^1.0.0", - "eventsource-parser": "^3.0.3", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4" - } - }, - "node_modules/ai-sdk-provider-gemini-cli/node_modules/gaxios": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", - "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2", - "rimraf": "^5.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/ai-sdk-provider-gemini-cli/node_modules/gcp-metadata": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.1.tgz", - "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/ai-sdk-provider-gemini-cli/node_modules/google-auth-library": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.2.1.tgz", - "integrity": "sha512-HMxFl2NfeHYnaL1HoRIN1XgorKS+6CDaM+z9LSSN+i/nKDDL4KFFEWogMXu7jV4HZQy2MsxpY+wA5XIf3w410A==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.0.0", - "gcp-metadata": "^7.0.0", - "google-logging-utils": "^1.0.0", - "gtoken": "^8.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/ai-sdk-provider-gemini-cli/node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/ai-sdk-provider-gemini-cli/node_modules/gtoken": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", - "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", - "license": "MIT", - "dependencies": { - "gaxios": "^7.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/ai-sdk-provider-gemini-cli/node_modules/zod-to-json-schema": { - "version": "3.24.6", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", - "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.24.1" - } - }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14019,6 +13987,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/bare-events": { @@ -14430,6 +14399,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -14475,7 +14445,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -16702,8 +16671,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -17418,7 +17386,6 @@ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -18626,14 +18593,6 @@ } } }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/figlet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.9.4.tgz", @@ -18806,14 +18765,6 @@ "flat": "cli.js" } }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -19320,6 +19271,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -19533,9 +19485,9 @@ } }, "node_modules/got": { - "version": "14.6.4", - "resolved": "https://registry.npmjs.org/got/-/got-14.6.4.tgz", - "integrity": "sha512-DjsLab39NUMf5iYlK9asVCkHMhaA2hEhrlmf+qXRhjEivuuBHWYbjmty9DA3OORUwZgENTB+6vSmY2ZW8gFHVw==", + "version": "14.6.5", + "resolved": "https://registry.npmjs.org/got/-/got-14.6.5.tgz", + "integrity": "sha512-Su87c0NNeg97de1sO02gy9I8EmE7DCJ1gzcFLcgGpYeq2PnLg4xz73MWrp6HjqbSsjb6Glf4UBDW6JNyZA6uSg==", "license": "MIT", "dependencies": { "@sindresorhus/is": "^7.0.1", @@ -20540,7 +20492,6 @@ "integrity": "sha512-wF3j/DmkM8q5E+OtfdQhCRw8/0ahkc8CUTgEddxZzpEWPslu7YPL3t64MWRoI9m6upVGpfAg4ms2BBvxCdKRLQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.2.0", @@ -21752,6 +21703,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -21769,7 +21721,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -23641,7 +23592,6 @@ "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.16.0" } @@ -24051,6 +24001,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24072,6 +24023,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24093,6 +24045,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24114,6 +24067,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24135,6 +24089,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24156,6 +24111,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24177,6 +24133,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24198,6 +24155,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24219,6 +24177,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24240,6 +24199,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24261,6 +24221,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -24539,6 +24500,7 @@ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -24551,7 +24513,8 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lowercase-keys": { "version": "3.0.0", @@ -24698,7 +24661,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -26078,6 +26040,7 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -26425,17 +26388,6 @@ "node": ">=4" } }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -27692,6 +27644,7 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -27864,7 +27817,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -28597,7 +28549,6 @@ "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -28608,7 +28559,6 @@ "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -29587,28 +29537,12 @@ "node": ">=0.10.0" } }, - "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rolldown": { "version": "1.0.0-beta.45", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.45.tgz", "integrity": "sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.95.0", "@rolldown/pluginutils": "1.0.0-beta.45" @@ -30421,22 +30355,6 @@ "dev": true, "license": "MIT" }, - "node_modules/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -31778,17 +31696,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/tr46": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", @@ -32940,7 +32847,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -33099,7 +33005,6 @@ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -33614,7 +33519,6 @@ "integrity": "sha512-pmW4GCKQ8t5Ko1jYjC3SqOr7TUKN7uHOHB/XGsAIb69eYu6d1ionGSsb5H9chmPf+WeXt0VE7jTXsB1IvWoNbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.12", "@vitest/mocker": "4.0.12", @@ -33731,7 +33635,6 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -33801,6 +33704,21 @@ } } }, + "node_modules/vitest/node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -34377,7 +34295,7 @@ "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">= 6" @@ -34549,7 +34467,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -34587,7 +34504,7 @@ "vitest": "^4.0.10" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "packages/ai-sdk-provider-grok-cli/node_modules/@types/node": { diff --git a/package.json b/package.json index f6d759a8..af24c825 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "ai": "^5.0.51", "ai-sdk-provider-claude-code": "^2.2.0", "ai-sdk-provider-codex-cli": "^0.3.0", - "ai-sdk-provider-gemini-cli": "^1.1.1", + "ai-sdk-provider-gemini-cli": "^1.4.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "boxen": "^8.0.1", @@ -115,7 +115,7 @@ "@biomejs/cli-linux-x64": "^1.9.4" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "packageManager": "npm@10.9.2", "repository": { diff --git a/packages/ai-sdk-provider-grok-cli/package.json b/packages/ai-sdk-provider-grok-cli/package.json index 7476816d..e568eba9 100644 --- a/packages/ai-sdk-provider-grok-cli/package.json +++ b/packages/ai-sdk-provider-grok-cli/package.json @@ -25,7 +25,7 @@ "vitest": "^4.0.10" }, "engines": { - "node": ">=18" + "node": ">=20" }, "keywords": ["ai", "grok", "x.ai", "cli", "language-model", "provider"], "files": ["dist/**/*", "README.md"], diff --git a/packages/claude-code-plugin/commands/install-taskmaster.md b/packages/claude-code-plugin/commands/install-taskmaster.md index 73116074..b2d6e0bf 100644 --- a/packages/claude-code-plugin/commands/install-taskmaster.md +++ b/packages/claude-code-plugin/commands/install-taskmaster.md @@ -91,10 +91,10 @@ npm install -g task-master-ai --registry https://registry.npmjs.org/ **Node Version Issues:** ```bash -# Install Node 18+ via nvm +# Install Node 20+ via nvm curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash -nvm install 18 -nvm use 18 +nvm install 20 +nvm use 20 ``` ## Success Confirmation diff --git a/packages/tm-core/README.md b/packages/tm-core/README.md index ee0fccc3..20a505c9 100644 --- a/packages/tm-core/README.md +++ b/packages/tm-core/README.md @@ -81,7 +81,7 @@ The library is organized into several key modules: ### Prerequisites -- Node.js >= 18.0.0 +- Node.js >= 20.0.0 - npm or yarn ### Setup diff --git a/src/ai-providers/gemini-cli.js b/src/ai-providers/gemini-cli.js index 4466ff2c..3689ff53 100644 --- a/src/ai-providers/gemini-cli.js +++ b/src/ai-providers/gemini-cli.js @@ -3,35 +3,38 @@ * * Implementation for interacting with Gemini models via Gemini CLI * using the ai-sdk-provider-gemini-cli package. + * + * As of v1.4.0, the SDK provides native structured output support via: + * - supportsStructuredOutputs = true + * - defaultObjectGenerationMode = 'json' + * - responseJsonSchema passed directly to Gemini API + * + * This eliminates the need for JSON extraction workarounds. + * System messages are automatically handled by the SDK's mapPromptToGeminiFormat + * which extracts them to Gemini's systemInstruction field. */ -import { generateObject, generateText, streamText } from 'ai'; -import { parse } from 'jsonc-parser'; -import { BaseAIProvider } from './base-provider.js'; -import { log } from '../../scripts/modules/utils.js'; import { createGeminiProvider } from 'ai-sdk-provider-gemini-cli'; +import { BaseAIProvider } from './base-provider.js'; export class GeminiCliProvider extends BaseAIProvider { constructor() { super(); this.name = 'Gemini CLI'; - // Gemini CLI requires explicit JSON schema mode - this.needsExplicitJsonSchema = true; // Gemini CLI does not support temperature parameter this.supportsTemperature = false; } /** - * Override validateAuth to handle Gemini CLI authentication options + * Override validateAuth to handle Gemini CLI authentication options. + * Gemini CLI is designed to use pre-configured OAuth authentication. + * Users choose gemini-cli specifically to leverage their existing + * gemini auth login credentials, not to use API keys. * @param {object} params - Parameters to validate */ validateAuth(params) { - // Gemini CLI is designed to use pre-configured OAuth authentication - // Users choose gemini-cli specifically to leverage their existing - // gemini auth login credentials, not to use API keys. - // We support API keys for compatibility, but the expected usage - // is through CLI authentication (no API key required). // No validation needed - the SDK will handle auth internally + // via OAuth (primary) or API key (optional fallback) } /** @@ -44,7 +47,7 @@ export class GeminiCliProvider extends BaseAIProvider { */ async getClient(params) { try { - // Primary use case: Use existing gemini CLI authentication + // Primary use case: Use existing gemini CLI authentication via OAuth // Secondary use case: Direct API key (for compatibility) let authOptions = {}; @@ -74,577 +77,18 @@ export class GeminiCliProvider extends BaseAIProvider { } /** - * Extracts system messages from the messages array and returns them separately. - * This is needed because ai-sdk-provider-gemini-cli expects system prompts as a separate parameter. - * @param {Array} messages - Array of message objects - * @param {Object} options - Options for system prompt enhancement - * @param {boolean} options.enforceJsonOutput - Whether to add JSON enforcement to system prompt - * @returns {Object} - {systemPrompt: string|undefined, messages: Array} + * Returns the name of the API key environment variable. + * @returns {string} API key environment variable name */ - _extractSystemMessage(messages, options = {}) { - if (!messages || !Array.isArray(messages)) { - return { systemPrompt: undefined, messages: messages || [] }; - } - - const systemMessages = messages.filter((msg) => msg.role === 'system'); - const nonSystemMessages = messages.filter((msg) => msg.role !== 'system'); - - // Combine multiple system messages if present - let systemPrompt = - systemMessages.length > 0 - ? systemMessages.map((msg) => msg.content).join('\n\n') - : undefined; - - // Add Gemini CLI specific JSON enforcement if requested - if (options.enforceJsonOutput) { - const jsonEnforcement = this._getJsonEnforcementPrompt(); - systemPrompt = systemPrompt - ? `${systemPrompt}\n\n${jsonEnforcement}` - : jsonEnforcement; - } - - return { systemPrompt, messages: nonSystemMessages }; - } - - /** - * Gets a Gemini CLI specific system prompt to enforce strict JSON output - * @returns {string} JSON enforcement system prompt - */ - _getJsonEnforcementPrompt() { - return `CRITICAL: You MUST respond with ONLY valid JSON. Do not include any explanatory text, markdown formatting, code block markers, or conversational phrases like "Here is" or "Of course". Your entire response must be parseable JSON that starts with { or [ and ends with } or ]. No exceptions.`; - } - - /** - * Checks if a string is valid JSON - * @param {string} text - Text to validate - * @returns {boolean} True if valid JSON - */ - _isValidJson(text) { - if (!text || typeof text !== 'string') { - return false; - } - - try { - JSON.parse(text.trim()); - return true; - } catch { - return false; - } - } - - /** - * Detects if the user prompt is requesting JSON output - * @param {Array} messages - Array of message objects - * @returns {boolean} True if JSON output is likely expected - */ - _detectJsonRequest(messages) { - const userMessages = messages.filter((msg) => msg.role === 'user'); - const combinedText = userMessages - .map((msg) => msg.content) - .join(' ') - .toLowerCase(); - - // Look for indicators that JSON output is expected - const jsonIndicators = [ - 'json', - 'respond only with', - 'return only', - 'output only', - 'format:', - 'structure:', - 'schema:', - '{"', - '[{', - 'subtasks', - 'array', - 'object' - ]; - - return jsonIndicators.some((indicator) => combinedText.includes(indicator)); - } - - /** - * Simplifies complex prompts for gemini-cli to improve JSON output compliance - * @param {Array} messages - Array of message objects - * @returns {Array} Simplified messages array - */ - _simplifyJsonPrompts(messages) { - // First, check if this is an expand-task operation by looking at the system message - const systemMsg = messages.find((m) => m.role === 'system'); - const isExpandTask = - systemMsg && - systemMsg.content.includes( - 'You are an AI assistant helping with task breakdown. Generate exactly' - ); - - if (!isExpandTask) { - return messages; // Not an expand task, return unchanged - } - - // Extract subtask count from system message - const subtaskCountMatch = systemMsg.content.match( - /Generate exactly (\d+) subtasks/ - ); - const subtaskCount = subtaskCountMatch ? subtaskCountMatch[1] : '10'; - - log( - 'debug', - `${this.name} detected expand-task operation, simplifying for ${subtaskCount} subtasks` - ); - - return messages.map((msg) => { - if (msg.role !== 'user') { - return msg; - } - - // For expand-task user messages, create a much simpler, more direct prompt - // that doesn't depend on specific task content - const simplifiedPrompt = `Generate exactly ${subtaskCount} subtasks in the following JSON format. - -CRITICAL INSTRUCTION: You must respond with ONLY valid JSON. No explanatory text, no "Here is", no "Of course", no markdown - just the JSON object. - -Required JSON structure: -{ - "subtasks": [ - { - "id": 1, - "title": "Specific actionable task title", - "description": "Clear task description", - "dependencies": [], - "details": "Implementation details and guidance", - "testStrategy": "Testing approach" - } - ] -} - -Generate ${subtaskCount} subtasks based on the original task context. Return ONLY the JSON object.`; - - log( - 'debug', - `${this.name} simplified user prompt for better JSON compliance` - ); - return { ...msg, content: simplifiedPrompt }; - }); - } - - /** - * Extract JSON from Gemini's response using a tolerant parser. - * - * Optimized approach that progressively tries different parsing strategies: - * 1. Direct parsing after cleanup - * 2. Smart boundary detection with single-pass analysis - * 3. Limited character-by-character fallback for edge cases - * - * @param {string} text - Raw text which may contain JSON - * @returns {string} A valid JSON string if extraction succeeds, otherwise the original text - */ - extractJson(text) { - if (!text || typeof text !== 'string') { - return text; - } - - let content = text.trim(); - - // Early exit for very short content - if (content.length < 2) { - return text; - } - - // Strip common wrappers in a single pass - content = content - // Remove markdown fences - .replace(/^.*?```(?:json)?\s*([\s\S]*?)\s*```.*$/i, '$1') - // Remove variable declarations - .replace(/^\s*(?:const|let|var)\s+\w+\s*=\s*([\s\S]*?)(?:;|\s*)$/i, '$1') - // Remove common prefixes - .replace(/^(?:Here's|The)\s+(?:the\s+)?JSON.*?[:]\s*/i, '') - .trim(); - - // Find the first JSON-like structure - const firstObj = content.indexOf('{'); - const firstArr = content.indexOf('['); - - if (firstObj === -1 && firstArr === -1) { - return text; - } - - const start = - firstArr === -1 - ? firstObj - : firstObj === -1 - ? firstArr - : Math.min(firstObj, firstArr); - content = content.slice(start); - - // Optimized parsing function with error collection - const tryParse = (value) => { - if (!value || value.length < 2) return undefined; - - const errors = []; - try { - const result = parse(value, errors, { - allowTrailingComma: true, - allowEmptyContent: false - }); - if (errors.length === 0 && result !== undefined) { - return JSON.stringify(result, null, 2); - } - } catch { - // Parsing failed completely - } - return undefined; - }; - - // Try parsing the full content first - const fullParse = tryParse(content); - if (fullParse !== undefined) { - return fullParse; - } - - // Smart boundary detection - single pass with optimizations - const openChar = content[0]; - const closeChar = openChar === '{' ? '}' : ']'; - - let depth = 0; - let inString = false; - let escapeNext = false; - let lastValidEnd = -1; - - // Single-pass boundary detection with early termination - for (let i = 0; i < content.length && i < 10000; i++) { - // Limit scan for performance - const char = content[i]; - - if (escapeNext) { - escapeNext = false; - continue; - } - - if (char === '\\') { - escapeNext = true; - continue; - } - - if (char === '"') { - inString = !inString; - continue; - } - - if (inString) continue; - - if (char === openChar) { - depth++; - } else if (char === closeChar) { - depth--; - if (depth === 0) { - lastValidEnd = i + 1; - // Try parsing immediately on first valid boundary - const candidate = content.slice(0, lastValidEnd); - const parsed = tryParse(candidate); - if (parsed !== undefined) { - return parsed; - } - } - } - } - - // If we found valid boundaries but parsing failed, try limited fallback - if (lastValidEnd > 0) { - const maxAttempts = Math.min(5, Math.floor(lastValidEnd / 100)); // Limit attempts - for (let i = 0; i < maxAttempts; i++) { - const testEnd = Math.max( - lastValidEnd - i * 50, - Math.floor(lastValidEnd * 0.8) - ); - const candidate = content.slice(0, testEnd); - const parsed = tryParse(candidate); - if (parsed !== undefined) { - return parsed; - } - } - } - - return text; - } - - /** - * Generates text using Gemini CLI model - * Overrides base implementation to properly handle system messages and enforce JSON output when needed - */ - async generateText(params) { - try { - this.validateParams(params); - this.validateMessages(params.messages); - - log( - 'debug', - `Generating ${this.name} text with model: ${params.modelId}` - ); - - // Detect if JSON output is expected and enforce it for better gemini-cli compatibility - const enforceJsonOutput = this._detectJsonRequest(params.messages); - - // Debug logging to understand what's happening - log('debug', `${this.name} JSON detection analysis:`, { - enforceJsonOutput, - messageCount: params.messages.length, - messages: params.messages.map((msg) => ({ - role: msg.role, - contentPreview: msg.content - ? msg.content.substring(0, 200) + '...' - : 'empty' - })) - }); - - if (enforceJsonOutput) { - log( - 'debug', - `${this.name} detected JSON request - applying strict JSON enforcement system prompt` - ); - } - - // For gemini-cli, simplify complex prompts before processing - let processedMessages = params.messages; - if (enforceJsonOutput) { - processedMessages = this._simplifyJsonPrompts(params.messages); - } - - // Extract system messages for separate handling with optional JSON enforcement - const { systemPrompt, messages } = this._extractSystemMessage( - processedMessages, - { enforceJsonOutput } - ); - - // Debug the final system prompt being sent - log('debug', `${this.name} final system prompt:`, { - systemPromptLength: systemPrompt ? systemPrompt.length : 0, - systemPromptPreview: systemPrompt - ? systemPrompt.substring(0, 300) + '...' - : 'none', - finalMessageCount: messages.length - }); - - const client = await this.getClient(params); - const result = await generateText({ - model: client(params.modelId), - system: systemPrompt, - messages: messages, - maxOutputTokens: params.maxTokens, - temperature: params.temperature - }); - - // If we detected a JSON request and gemini-cli returned conversational text, - // attempt to extract JSON from the response - let finalText = result.text; - if (enforceJsonOutput && result.text && !this._isValidJson(result.text)) { - log( - 'debug', - `${this.name} response appears conversational, attempting JSON extraction` - ); - - // Log first 1000 chars of the response to see what Gemini actually returned - log('debug', `${this.name} raw response preview:`, { - responseLength: result.text.length, - responseStart: result.text.substring(0, 1000) - }); - - const extractedJson = this.extractJson(result.text); - if (this._isValidJson(extractedJson)) { - log( - 'debug', - `${this.name} successfully extracted JSON from conversational response` - ); - finalText = extractedJson; - } else { - log( - 'debug', - `${this.name} JSON extraction failed, returning original response` - ); - - // Log what extraction returned to debug why it failed - log('debug', `${this.name} extraction result preview:`, { - extractedLength: extractedJson ? extractedJson.length : 0, - extractedStart: extractedJson - ? extractedJson.substring(0, 500) - : 'null' - }); - } - } - - log( - 'debug', - `${this.name} generateText completed successfully for model: ${params.modelId}` - ); - - return { - text: finalText, - usage: { - inputTokens: result.usage?.promptTokens, - outputTokens: result.usage?.completionTokens, - totalTokens: result.usage?.totalTokens - } - }; - } catch (error) { - this.handleError('text generation', error); - } - } - - /** - * Streams text using Gemini CLI model - * Overrides base implementation to properly handle system messages and enforce JSON output when needed - */ - async streamText(params) { - try { - this.validateParams(params); - this.validateMessages(params.messages); - - log('debug', `Streaming ${this.name} text with model: ${params.modelId}`); - - // Detect if JSON output is expected and enforce it for better gemini-cli compatibility - const enforceJsonOutput = this._detectJsonRequest(params.messages); - - // Debug logging to understand what's happening - log('debug', `${this.name} JSON detection analysis:`, { - enforceJsonOutput, - messageCount: params.messages.length, - messages: params.messages.map((msg) => ({ - role: msg.role, - contentPreview: msg.content - ? msg.content.substring(0, 200) + '...' - : 'empty' - })) - }); - - if (enforceJsonOutput) { - log( - 'debug', - `${this.name} detected JSON request - applying strict JSON enforcement system prompt` - ); - } - - // Extract system messages for separate handling with optional JSON enforcement - const { systemPrompt, messages } = this._extractSystemMessage( - params.messages, - { enforceJsonOutput } - ); - - const client = await this.getClient(params); - const stream = await streamText({ - model: client(params.modelId), - system: systemPrompt, - messages: messages, - maxOutputTokens: params.maxTokens, - temperature: params.temperature - }); - - log( - 'debug', - `${this.name} streamText initiated successfully for model: ${params.modelId}` - ); - - // Note: For streaming, we can't intercept and modify the response in real-time - // The JSON extraction would need to happen on the consuming side - return stream; - } catch (error) { - this.handleError('text streaming', error); - } - } - - /** - * Generates a structured object using Gemini CLI model - * Overrides base implementation to handle Gemini-specific JSON formatting issues and system messages - */ - async generateObject(params) { - try { - // First try the standard generateObject from base class - return await super.generateObject(params); - } catch (error) { - // If it's a JSON parsing error, try to extract and parse JSON manually - if (error.message?.includes('JSON') || error.message?.includes('parse')) { - log( - 'debug', - `Gemini CLI generateObject failed with parsing error, attempting manual extraction` - ); - - try { - // Validate params first - this.validateParams(params); - this.validateMessages(params.messages); - - if (!params.schema) { - throw new Error('Schema is required for object generation'); - } - if (!params.objectName) { - throw new Error('Object name is required for object generation'); - } - - // Extract system messages for separate handling with JSON enforcement - const { systemPrompt, messages } = this._extractSystemMessage( - params.messages, - { enforceJsonOutput: true } - ); - - // Call generateObject directly with our client - const client = await this.getClient(params); - const result = await generateObject({ - model: client(params.modelId), - system: systemPrompt, - messages: messages, - schema: params.schema, - mode: this.needsExplicitJsonSchema ? 'json' : 'auto', - maxOutputTokens: params.maxTokens, - temperature: params.temperature - }); - - // If we get rawResponse text, try to extract JSON from it - if (result.rawResponse?.text && !result.object) { - const extractedJson = this.extractJson(result.rawResponse.text); - try { - result.object = JSON.parse(extractedJson); - } catch (parseError) { - log( - 'error', - `Failed to parse extracted JSON: ${parseError.message}` - ); - log( - 'debug', - `Extracted JSON: ${extractedJson.substring(0, 500)}...` - ); - throw new Error( - `Gemini CLI returned invalid JSON that could not be parsed: ${parseError.message}` - ); - } - } - - return { - object: result.object, - usage: { - inputTokens: result.usage?.promptTokens, - outputTokens: result.usage?.completionTokens, - totalTokens: result.usage?.totalTokens - } - }; - } catch (retryError) { - log( - 'error', - `Gemini CLI manual JSON extraction failed: ${retryError.message}` - ); - // Re-throw the original error with more context - throw new Error( - `${this.name} failed to generate valid JSON object: ${error.message}` - ); - } - } - - // For non-parsing errors, just re-throw - throw error; - } - } - getRequiredApiKeyName() { return 'GEMINI_API_KEY'; } + /** + * Indicates whether an API key is required. + * Gemini CLI primarily uses OAuth, so API key is optional. + * @returns {boolean} False - API key is not required + */ isRequiredApiKey() { return false; } diff --git a/tests/unit/ai-providers/gemini-cli-structured-output.test.js b/tests/unit/ai-providers/gemini-cli-structured-output.test.js new file mode 100644 index 00000000..8be6fb30 --- /dev/null +++ b/tests/unit/ai-providers/gemini-cli-structured-output.test.js @@ -0,0 +1,339 @@ +import { jest } from '@jest/globals'; +import { z } from 'zod'; + +// Mock the AI SDK module +const mockGenerateObject = jest.fn(); +const mockStreamObject = jest.fn(); +const mockZodSchema = jest.fn((schema) => ({ _zodSchema: schema })); + +jest.unstable_mockModule('ai', () => ({ + generateObject: mockGenerateObject, + streamObject: mockStreamObject, + zodSchema: mockZodSchema, + generateText: jest.fn(), + streamText: jest.fn(), + JSONParseError: class JSONParseError extends Error {}, + NoObjectGeneratedError: class NoObjectGeneratedError extends Error { + static isInstance(error) { + return error instanceof NoObjectGeneratedError; + } + } +})); + +// Mock the gemini-cli SDK module +const mockModel = jest.fn((modelId) => ({ modelId, type: 'gemini-cli-model' })); +mockModel.languageModel = mockModel; +mockModel.chat = mockModel; + +jest.unstable_mockModule('ai-sdk-provider-gemini-cli', () => ({ + createGeminiProvider: jest.fn(() => mockModel) +})); + +// Mock utilities +jest.unstable_mockModule('../../../scripts/modules/utils.js', () => ({ + log: jest.fn(), + findProjectRoot: jest.fn(() => '/mock/project') +})); + +jest.unstable_mockModule('../../../scripts/modules/config-manager.js', () => ({ + isProxyEnabled: jest.fn(() => false) +})); + +// Import after mocking +const { GeminiCliProvider } = await import( + '../../../src/ai-providers/gemini-cli.js' +); + +describe('GeminiCliProvider Structured Output Integration', () => { + let provider; + + // Sample Zod schema for testing + const testSchema = z.object({ + title: z.string(), + description: z.string(), + priority: z.enum(['low', 'medium', 'high']) + }); + + beforeEach(() => { + provider = new GeminiCliProvider(); + jest.clearAllMocks(); + + // Reset mock implementations + mockGenerateObject.mockReset(); + mockStreamObject.mockReset(); + }); + + describe('generateObject', () => { + it('should forward schema to AI SDK generateObject', async () => { + // Setup mock response + mockGenerateObject.mockResolvedValue({ + object: { + title: 'Test Task', + description: 'A test task description', + priority: 'high' + }, + usage: { + promptTokens: 100, + completionTokens: 50, + totalTokens: 150 + } + }); + + const params = { + modelId: 'gemini-2.5-pro', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Create a task for testing.' } + ], + schema: testSchema, + objectName: 'task', + maxTokens: 1000 + }; + + const result = await provider.generateObject(params); + + // Verify generateObject was called + expect(mockGenerateObject).toHaveBeenCalledTimes(1); + + // Verify schema was passed through + const callArgs = mockGenerateObject.mock.calls[0][0]; + expect(callArgs.schema).toBe(testSchema); + + // Verify result is returned correctly + expect(result.object).toEqual({ + title: 'Test Task', + description: 'A test task description', + priority: 'high' + }); + }); + + it('should use mode "auto" since needsExplicitJsonSchema is false', async () => { + mockGenerateObject.mockResolvedValue({ + object: { title: 'Test', description: 'Test', priority: 'low' }, + usage: { promptTokens: 10, completionTokens: 10, totalTokens: 20 } + }); + + await provider.generateObject({ + modelId: 'gemini-2.5-flash', + messages: [{ role: 'user', content: 'Test' }], + schema: testSchema, + objectName: 'task', + maxTokens: 500 + }); + + const callArgs = mockGenerateObject.mock.calls[0][0]; + + // Mode should be 'auto' because needsExplicitJsonSchema is false + expect(callArgs.mode).toBe('auto'); + + // Verify the provider flag is correctly set + expect(provider.needsExplicitJsonSchema).toBe(false); + }); + + it('should pass schemaName and schemaDescription', async () => { + mockGenerateObject.mockResolvedValue({ + object: { title: 'Test', description: 'Test', priority: 'medium' }, + usage: { promptTokens: 10, completionTokens: 10, totalTokens: 20 } + }); + + await provider.generateObject({ + modelId: 'gemini-2.5-pro', + messages: [{ role: 'user', content: 'Test' }], + schema: testSchema, + objectName: 'myCustomObject', + maxTokens: 500 + }); + + const callArgs = mockGenerateObject.mock.calls[0][0]; + expect(callArgs.schemaName).toBe('myCustomObject'); + expect(callArgs.schemaDescription).toBe( + 'Generate a valid JSON object for myCustomObject' + ); + }); + + it('should return usage statistics from SDK response', async () => { + mockGenerateObject.mockResolvedValue({ + object: { title: 'Test', description: 'Test', priority: 'high' }, + usage: { + promptTokens: 250, + completionTokens: 100, + totalTokens: 350 + } + }); + + const result = await provider.generateObject({ + modelId: 'gemini-2.5-pro', + messages: [{ role: 'user', content: 'Test' }], + schema: testSchema, + objectName: 'task', + maxTokens: 1000 + }); + + expect(result.usage).toEqual({ + inputTokens: 250, + outputTokens: 100, + totalTokens: 350 + }); + }); + + it('should not include temperature when supportsTemperature is false', async () => { + mockGenerateObject.mockResolvedValue({ + object: { title: 'Test', description: 'Test', priority: 'low' }, + usage: { promptTokens: 10, completionTokens: 10, totalTokens: 20 } + }); + + // GeminiCliProvider sets supportsTemperature = false + expect(provider.supportsTemperature).toBe(false); + + await provider.generateObject({ + modelId: 'gemini-2.5-pro', + messages: [{ role: 'user', content: 'Test' }], + schema: testSchema, + objectName: 'task', + maxTokens: 500, + temperature: 0.7 // This should be ignored + }); + + const callArgs = mockGenerateObject.mock.calls[0][0]; + expect(callArgs.temperature).toBeUndefined(); + }); + }); + + describe('streamObject', () => { + it('should forward schema to AI SDK streamObject with zodSchema wrapper', async () => { + const mockStreamResult = { + partialObjectStream: { + [Symbol.asyncIterator]: async function* () { + yield { title: 'Test' }; + yield { title: 'Test', description: 'Description' }; + yield { + title: 'Test', + description: 'Description', + priority: 'high' + }; + } + } + }; + mockStreamObject.mockResolvedValue(mockStreamResult); + + const params = { + modelId: 'gemini-2.5-pro', + messages: [{ role: 'user', content: 'Stream a task' }], + schema: testSchema, + maxTokens: 1000 + }; + + const result = await provider.streamObject(params); + + // Verify streamObject was called + expect(mockStreamObject).toHaveBeenCalledTimes(1); + + // Verify zodSchema wrapper was used + expect(mockZodSchema).toHaveBeenCalledWith(testSchema); + + // Verify the wrapped schema was passed + const callArgs = mockStreamObject.mock.calls[0][0]; + expect(callArgs.schema).toEqual({ _zodSchema: testSchema }); + + // Verify stream result is returned + expect(result).toBe(mockStreamResult); + }); + + it('should use default mode "auto" for streamObject', async () => { + mockStreamObject.mockResolvedValue({ partialObjectStream: {} }); + + await provider.streamObject({ + modelId: 'gemini-2.5-flash', + messages: [{ role: 'user', content: 'Test' }], + schema: testSchema, + maxTokens: 500 + }); + + const callArgs = mockStreamObject.mock.calls[0][0]; + expect(callArgs.mode).toBe('auto'); + }); + + it('should pass maxOutputTokens to streamObject', async () => { + mockStreamObject.mockResolvedValue({ partialObjectStream: {} }); + + await provider.streamObject({ + modelId: 'gemini-2.5-pro', + messages: [{ role: 'user', content: 'Test' }], + schema: testSchema, + maxTokens: 2000 + }); + + const callArgs = mockStreamObject.mock.calls[0][0]; + expect(callArgs.maxOutputTokens).toBe(2000); + }); + }); + + describe('SDK integration', () => { + it('should create model with correct modelId', async () => { + mockGenerateObject.mockResolvedValue({ + object: { title: 'Test', description: 'Test', priority: 'low' }, + usage: { totalTokens: 10 } + }); + + await provider.generateObject({ + modelId: 'gemini-2.5-pro', + messages: [{ role: 'user', content: 'Test' }], + schema: testSchema, + objectName: 'task' + }); + + const callArgs = mockGenerateObject.mock.calls[0][0]; + // The model should be the result of calling mockModel with the modelId + expect(callArgs.model).toEqual({ + modelId: 'gemini-2.5-pro', + type: 'gemini-cli-model' + }); + }); + + it('should work with gemini-2.5-flash model', async () => { + mockGenerateObject.mockResolvedValue({ + object: { + title: 'Fast', + description: 'Quick response', + priority: 'medium' + }, + usage: { totalTokens: 20 } + }); + + const result = await provider.generateObject({ + modelId: 'gemini-2.5-flash', + messages: [{ role: 'user', content: 'Quick task' }], + schema: testSchema, + objectName: 'task' + }); + + expect(result.object.title).toBe('Fast'); + + const callArgs = mockGenerateObject.mock.calls[0][0]; + expect(callArgs.model.modelId).toBe('gemini-2.5-flash'); + }); + + it('should work with gemini-3-pro-preview model', async () => { + mockGenerateObject.mockResolvedValue({ + object: { + title: 'Preview', + description: 'Latest model', + priority: 'high' + }, + usage: { totalTokens: 30 } + }); + + const result = await provider.generateObject({ + modelId: 'gemini-3-pro-preview', + messages: [{ role: 'user', content: 'Test preview' }], + schema: testSchema, + objectName: 'task' + }); + + expect(result.object.title).toBe('Preview'); + + const callArgs = mockGenerateObject.mock.calls[0][0]; + expect(callArgs.model.modelId).toBe('gemini-3-pro-preview'); + }); + }); +}); diff --git a/tests/unit/ai-providers/gemini-cli.test.js b/tests/unit/ai-providers/gemini-cli.test.js index 3f52f128..e541d223 100644 --- a/tests/unit/ai-providers/gemini-cli.test.js +++ b/tests/unit/ai-providers/gemini-cli.test.js @@ -1,12 +1,5 @@ import { jest } from '@jest/globals'; -// Mock the ai module -jest.unstable_mockModule('ai', () => ({ - generateObject: jest.fn(), - generateText: jest.fn(), - streamText: jest.fn() -})); - // Mock the gemini-cli SDK module jest.unstable_mockModule('ai-sdk-provider-gemini-cli', () => ({ createGeminiProvider: jest.fn((options) => { @@ -27,12 +20,13 @@ jest.unstable_mockModule('../../../src/ai-providers/base-provider.js', () => ({ BaseAIProvider: class { constructor() { this.name = 'Base Provider'; + this.needsExplicitJsonSchema = false; + this.supportsTemperature = true; } handleError(context, error) { throw error; } validateParams(params) { - // Basic validation if (!params.modelId) { throw new Error('Model ID is required'); } @@ -42,60 +36,54 @@ jest.unstable_mockModule('../../../src/ai-providers/base-provider.js', () => ({ throw new Error('Invalid messages array'); } } - async generateObject(params) { - // Mock implementation that can be overridden - throw new Error('Mock base generateObject error'); - } } })); -// Mock the log module -jest.unstable_mockModule('../../../scripts/modules/utils.js', () => ({ - log: jest.fn() -})); - // Import after mocking const { GeminiCliProvider } = await import( '../../../src/ai-providers/gemini-cli.js' ); const { createGeminiProvider } = await import('ai-sdk-provider-gemini-cli'); -const { generateObject, generateText, streamText } = await import('ai'); -const { log } = await import('../../../scripts/modules/utils.js'); describe('GeminiCliProvider', () => { let provider; - let consoleLogSpy; beforeEach(() => { provider = new GeminiCliProvider(); jest.clearAllMocks(); - consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); - }); - - afterEach(() => { - consoleLogSpy.mockRestore(); }); describe('constructor', () => { it('should set the provider name to Gemini CLI', () => { expect(provider.name).toBe('Gemini CLI'); }); + + it('should set supportsTemperature to false', () => { + expect(provider.supportsTemperature).toBe(false); + }); + + it('should not set needsExplicitJsonSchema (rely on SDK defaults)', () => { + // The SDK has defaultObjectGenerationMode = 'json' and supportsStructuredOutputs = true + // so we don't need to override this in the provider + expect(provider.needsExplicitJsonSchema).toBe(false); + }); }); describe('validateAuth', () => { it('should not throw an error when API key is provided', () => { expect(() => provider.validateAuth({ apiKey: 'test-key' })).not.toThrow(); - expect(consoleLogSpy).not.toHaveBeenCalled(); }); - it('should not require API key and should not log messages', () => { + it('should not throw an error when no API key is provided', () => { expect(() => provider.validateAuth({})).not.toThrow(); - expect(consoleLogSpy).not.toHaveBeenCalled(); }); - it('should not require any parameters', () => { + it('should not throw an error when called with no parameters', () => { expect(() => provider.validateAuth()).not.toThrow(); - expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + + it('should not throw an error when called with undefined', () => { + expect(() => provider.validateAuth(undefined)).not.toThrow(); }); }); @@ -121,7 +109,18 @@ describe('GeminiCliProvider', () => { }); }); - it('should include baseURL when provided', async () => { + it('should use OAuth when apiKey is the special no-key-required value', async () => { + const client = await provider.getClient({ + apiKey: 'gemini-cli-no-key-required' + }); + + expect(client).toBeDefined(); + expect(createGeminiProvider).toHaveBeenCalledWith({ + authType: 'oauth-personal' + }); + }); + + it('should include baseURL when provided with API key', async () => { const client = await provider.getClient({ apiKey: 'test-key', baseURL: 'https://custom-endpoint.com' @@ -135,493 +134,107 @@ describe('GeminiCliProvider', () => { }); }); + it('should include baseURL when provided with OAuth', async () => { + const client = await provider.getClient({ + baseURL: 'https://custom-endpoint.com' + }); + + expect(client).toBeDefined(); + expect(createGeminiProvider).toHaveBeenCalledWith({ + authType: 'oauth-personal', + baseURL: 'https://custom-endpoint.com' + }); + }); + it('should have languageModel and chat methods', async () => { const client = await provider.getClient({ apiKey: 'test-key' }); expect(client.languageModel).toBeDefined(); expect(client.chat).toBeDefined(); expect(client.chat).toBe(client.languageModel); }); - }); - describe('_extractSystemMessage', () => { - it('should extract single system message', () => { - const messages = [ - { role: 'system', content: 'You are a helpful assistant' }, - { role: 'user', content: 'Hello' } - ]; - const result = provider._extractSystemMessage(messages); - expect(result.systemPrompt).toBe('You are a helpful assistant'); - expect(result.messages).toEqual([{ role: 'user', content: 'Hello' }]); - }); + it('should use OAuth with empty string API key', async () => { + await provider.getClient({ apiKey: '' }); - it('should combine multiple system messages', () => { - const messages = [ - { role: 'system', content: 'You are helpful' }, - { role: 'system', content: 'Be concise' }, - { role: 'user', content: 'Hello' } - ]; - const result = provider._extractSystemMessage(messages); - expect(result.systemPrompt).toBe('You are helpful\n\nBe concise'); - expect(result.messages).toEqual([{ role: 'user', content: 'Hello' }]); - }); - - it('should handle messages without system prompts', () => { - const messages = [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi there' } - ]; - const result = provider._extractSystemMessage(messages); - expect(result.systemPrompt).toBeUndefined(); - expect(result.messages).toEqual(messages); - }); - - it('should handle empty or invalid input', () => { - expect(provider._extractSystemMessage([])).toEqual({ - systemPrompt: undefined, - messages: [] - }); - expect(provider._extractSystemMessage(null)).toEqual({ - systemPrompt: undefined, - messages: [] - }); - expect(provider._extractSystemMessage(undefined)).toEqual({ - systemPrompt: undefined, - messages: [] + expect(createGeminiProvider).toHaveBeenCalledWith({ + authType: 'oauth-personal' }); }); - it('should add JSON enforcement when enforceJsonOutput is true', () => { - const messages = [ - { role: 'system', content: 'You are a helpful assistant' }, - { role: 'user', content: 'Hello' } - ]; - const result = provider._extractSystemMessage(messages, { - enforceJsonOutput: true + it('should throw error when createGeminiProvider fails', async () => { + createGeminiProvider.mockImplementationOnce(() => { + throw new Error('Auth initialization failed'); }); - expect(result.systemPrompt).toContain('You are a helpful assistant'); - expect(result.systemPrompt).toContain( - 'CRITICAL: You MUST respond with ONLY valid JSON' + + await expect(provider.getClient({})).rejects.toThrow( + 'Auth initialization failed' ); - expect(result.messages).toEqual([{ role: 'user', content: 'Hello' }]); - }); - - it('should add JSON enforcement with no existing system message', () => { - const messages = [{ role: 'user', content: 'Return JSON format' }]; - const result = provider._extractSystemMessage(messages, { - enforceJsonOutput: true - }); - expect(result.systemPrompt).toBe( - 'CRITICAL: You MUST respond with ONLY valid JSON. Do not include any explanatory text, markdown formatting, code block markers, or conversational phrases like "Here is" or "Of course". Your entire response must be parseable JSON that starts with { or [ and ends with } or ]. No exceptions.' - ); - expect(result.messages).toEqual([ - { role: 'user', content: 'Return JSON format' } - ]); }); }); - describe('_detectJsonRequest', () => { - it('should detect JSON requests from user messages', () => { - const messages = [ - { - role: 'user', - content: 'Please return JSON format with subtasks array' - } - ]; - expect(provider._detectJsonRequest(messages)).toBe(true); - }); - - it('should detect various JSON indicators', () => { - const testCases = [ - 'respond only with valid JSON', - 'return JSON format', - 'output schema: {"test": true}', - 'format: [{"id": 1}]', - 'Please return subtasks in array format', - 'Return an object with properties' - ]; - - testCases.forEach((content) => { - const messages = [{ role: 'user', content }]; - expect(provider._detectJsonRequest(messages)).toBe(true); - }); - }); - - it('should not detect JSON requests for regular conversation', () => { - const messages = [{ role: 'user', content: 'Hello, how are you today?' }]; - expect(provider._detectJsonRequest(messages)).toBe(false); - }); - - it('should handle multiple user messages', () => { - const messages = [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi there' }, - { role: 'user', content: 'Now please return JSON format' } - ]; - expect(provider._detectJsonRequest(messages)).toBe(true); + describe('getRequiredApiKeyName', () => { + it('should return GEMINI_API_KEY', () => { + expect(provider.getRequiredApiKeyName()).toBe('GEMINI_API_KEY'); }); }); - describe('_getJsonEnforcementPrompt', () => { - it('should return strict JSON enforcement prompt', () => { - const prompt = provider._getJsonEnforcementPrompt(); - expect(prompt).toContain('CRITICAL'); - expect(prompt).toContain('ONLY valid JSON'); - expect(prompt).toContain('No exceptions'); + describe('isRequiredApiKey', () => { + it('should return false (API key is optional for gemini-cli)', () => { + expect(provider.isRequiredApiKey()).toBe(false); }); }); - describe('_isValidJson', () => { - it('should return true for valid JSON objects', () => { - expect(provider._isValidJson('{"test": true}')).toBe(true); - expect(provider._isValidJson('{"subtasks": [{"id": 1}]}')).toBe(true); + describe('base class delegation', () => { + it('should not override generateText (uses base class)', () => { + // Verify that generateText is not defined on the provider prototype + // (it inherits from base class) + expect( + Object.prototype.hasOwnProperty.call( + GeminiCliProvider.prototype, + 'generateText' + ) + ).toBe(false); }); - it('should return true for valid JSON arrays', () => { - expect(provider._isValidJson('[1, 2, 3]')).toBe(true); - expect(provider._isValidJson('[{"id": 1}, {"id": 2}]')).toBe(true); + it('should not override streamText (uses base class)', () => { + expect( + Object.prototype.hasOwnProperty.call( + GeminiCliProvider.prototype, + 'streamText' + ) + ).toBe(false); }); - it('should return false for invalid JSON', () => { - expect(provider._isValidJson('Of course. Here is...')).toBe(false); - expect(provider._isValidJson('{"invalid": json}')).toBe(false); - expect(provider._isValidJson('not json at all')).toBe(false); - }); - - it('should handle edge cases', () => { - expect(provider._isValidJson('')).toBe(false); - expect(provider._isValidJson(null)).toBe(false); - expect(provider._isValidJson(undefined)).toBe(false); - expect(provider._isValidJson(' {"test": true} ')).toBe(true); // with whitespace - }); - }); - - describe('extractJson', () => { - it('should extract JSON from markdown code blocks', () => { - const input = '```json\n{"subtasks": [{"id": 1}]}\n```'; - const result = provider.extractJson(input); - const parsed = JSON.parse(result); - expect(parsed).toEqual({ subtasks: [{ id: 1 }] }); - }); - - it('should extract JSON with explanatory text', () => { - const input = 'Here\'s the JSON response:\n{"subtasks": [{"id": 1}]}'; - const result = provider.extractJson(input); - const parsed = JSON.parse(result); - expect(parsed).toEqual({ subtasks: [{ id: 1 }] }); - }); - - it('should handle variable declarations', () => { - const input = 'const result = {"subtasks": [{"id": 1}]};'; - const result = provider.extractJson(input); - const parsed = JSON.parse(result); - expect(parsed).toEqual({ subtasks: [{ id: 1 }] }); - }); - - it('should handle trailing commas with jsonc-parser', () => { - const input = '{"subtasks": [{"id": 1,}],}'; - const result = provider.extractJson(input); - const parsed = JSON.parse(result); - expect(parsed).toEqual({ subtasks: [{ id: 1 }] }); - }); - - it('should handle arrays', () => { - const input = 'The result is: [1, 2, 3]'; - const result = provider.extractJson(input); - const parsed = JSON.parse(result); - expect(parsed).toEqual([1, 2, 3]); - }); - - it('should handle nested objects with proper bracket matching', () => { - const input = - 'Response: {"outer": {"inner": {"value": "test"}}} extra text'; - const result = provider.extractJson(input); - const parsed = JSON.parse(result); - expect(parsed).toEqual({ outer: { inner: { value: 'test' } } }); - }); - - it('should handle escaped quotes in strings', () => { - const input = '{"message": "He said \\"hello\\" to me"}'; - const result = provider.extractJson(input); - const parsed = JSON.parse(result); - expect(parsed).toEqual({ message: 'He said "hello" to me' }); - }); - - it('should return original text if no JSON found', () => { - const input = 'No JSON here'; - expect(provider.extractJson(input)).toBe(input); - }); - - it('should handle null or non-string input', () => { - expect(provider.extractJson(null)).toBe(null); - expect(provider.extractJson(undefined)).toBe(undefined); - expect(provider.extractJson(123)).toBe(123); - }); - - it('should handle partial JSON by finding valid boundaries', () => { - const input = '{"valid": true, "partial": "incomplete'; - // Should return original text since no valid JSON can be extracted - expect(provider.extractJson(input)).toBe(input); - }); - - it('should handle performance edge cases with large text', () => { - // Test with large text that has JSON at the end - const largePrefix = 'This is a very long explanation. '.repeat(1000); - const json = '{"result": "success"}'; - const input = largePrefix + json; - - const result = provider.extractJson(input); - const parsed = JSON.parse(result); - expect(parsed).toEqual({ result: 'success' }); - }); - - it('should handle early termination for very large invalid content', () => { - // Test that it doesn't hang on very large content without JSON - const largeText = 'No JSON here. '.repeat(2000); - const result = provider.extractJson(largeText); - expect(result).toBe(largeText); - }); - }); - - describe('generateObject', () => { - const mockParams = { - modelId: 'gemini-2.0-flash-exp', - apiKey: 'test-key', - messages: [{ role: 'user', content: 'Test message' }], - schema: { type: 'object', properties: {} }, - objectName: 'testObject' - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should handle JSON parsing errors by attempting manual extraction', async () => { - // Mock the parent generateObject to throw a JSON parsing error - jest - .spyOn( - Object.getPrototypeOf(Object.getPrototypeOf(provider)), + it('should not override generateObject (uses base class)', () => { + expect( + Object.prototype.hasOwnProperty.call( + GeminiCliProvider.prototype, 'generateObject' ) - .mockRejectedValueOnce(new Error('Failed to parse JSON response')); - - // Mock generateObject from ai module to return text with JSON - generateObject.mockResolvedValueOnce({ - rawResponse: { - text: 'Here is the JSON:\n```json\n{"subtasks": [{"id": 1}]}\n```' - }, - object: null, - usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 } - }); - - const result = await provider.generateObject(mockParams); - - expect(log).toHaveBeenCalledWith( - 'debug', - expect.stringContaining('attempting manual extraction') - ); - expect(generateObject).toHaveBeenCalledWith({ - model: expect.objectContaining({ - id: 'gemini-2.0-flash-exp', - authOptions: expect.objectContaining({ - authType: 'api-key', - apiKey: 'test-key' - }) - }), - messages: mockParams.messages, - schema: mockParams.schema, - mode: 'json', // Should use json mode for Gemini - system: expect.stringContaining( - 'CRITICAL: You MUST respond with ONLY valid JSON' - ), - maxTokens: undefined, - temperature: undefined - }); - expect(result.object).toEqual({ subtasks: [{ id: 1 }] }); + ).toBe(false); }); - it('should throw error if manual extraction also fails', async () => { - // Mock parent to throw JSON error - jest - .spyOn( - Object.getPrototypeOf(Object.getPrototypeOf(provider)), - 'generateObject' + it('should not override streamObject (uses base class)', () => { + expect( + Object.prototype.hasOwnProperty.call( + GeminiCliProvider.prototype, + 'streamObject' ) - .mockRejectedValueOnce(new Error('Failed to parse JSON')); - - // Mock generateObject to return unparseable text - generateObject.mockResolvedValueOnce({ - rawResponse: { text: 'Not valid JSON at all' }, - object: null - }); - - await expect(provider.generateObject(mockParams)).rejects.toThrow( - 'Gemini CLI failed to generate valid JSON object: Failed to parse JSON' - ); + ).toBe(false); }); - it('should pass through non-JSON errors unchanged', async () => { - const otherError = new Error('Network error'); - jest - .spyOn( - Object.getPrototypeOf(Object.getPrototypeOf(provider)), - 'generateObject' - ) - .mockRejectedValueOnce(otherError); - - await expect(provider.generateObject(mockParams)).rejects.toThrow( - 'Network error' - ); - expect(generateObject).not.toHaveBeenCalled(); - }); - - it('should handle successful response from parent', async () => { - const mockResult = { - object: { test: 'data' }, - usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 } - }; - jest - .spyOn( - Object.getPrototypeOf(Object.getPrototypeOf(provider)), - 'generateObject' - ) - .mockResolvedValueOnce(mockResult); - - const result = await provider.generateObject(mockParams); - expect(result).toEqual(mockResult); - expect(generateObject).not.toHaveBeenCalled(); + it('should not have JSON extraction methods (removed)', () => { + expect(provider._extractSystemMessage).toBeUndefined(); + expect(provider._detectJsonRequest).toBeUndefined(); + expect(provider._getJsonEnforcementPrompt).toBeUndefined(); + expect(provider._isValidJson).toBeUndefined(); + expect(provider.extractJson).toBeUndefined(); + expect(provider._simplifyJsonPrompts).toBeUndefined(); }); }); - describe('system message support', () => { - const mockParams = { - modelId: 'gemini-2.0-flash-exp', - apiKey: 'test-key', - messages: [ - { role: 'system', content: 'You are a helpful assistant' }, - { role: 'user', content: 'Hello' } - ], - maxTokens: 100, - temperature: 0.7 - }; - - describe('generateText with system messages', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should pass system prompt separately to AI SDK', async () => { - const { generateText } = await import('ai'); - generateText.mockResolvedValueOnce({ - text: 'Hello! How can I help you?', - usage: { promptTokens: 10, completionTokens: 8, totalTokens: 18 } - }); - - const result = await provider.generateText(mockParams); - - expect(generateText).toHaveBeenCalledWith({ - model: expect.objectContaining({ - id: 'gemini-2.0-flash-exp' - }), - system: 'You are a helpful assistant', - messages: [{ role: 'user', content: 'Hello' }], - maxOutputTokens: 100, - temperature: 0.7 - }); - expect(result.text).toBe('Hello! How can I help you?'); - }); - - it('should handle messages without system prompt', async () => { - const { generateText } = await import('ai'); - const paramsNoSystem = { - ...mockParams, - messages: [{ role: 'user', content: 'Hello' }] - }; - - generateText.mockResolvedValueOnce({ - text: 'Hi there!', - usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 } - }); - - await provider.generateText(paramsNoSystem); - - expect(generateText).toHaveBeenCalledWith({ - model: expect.objectContaining({ - id: 'gemini-2.0-flash-exp' - }), - system: undefined, - messages: [{ role: 'user', content: 'Hello' }], - maxOutputTokens: 100, - temperature: 0.7 - }); - }); - }); - - describe('streamText with system messages', () => { - it('should pass system prompt separately to AI SDK', async () => { - const { streamText } = await import('ai'); - const mockStream = { stream: 'mock-stream' }; - streamText.mockResolvedValueOnce(mockStream); - - const result = await provider.streamText(mockParams); - - expect(streamText).toHaveBeenCalledWith({ - model: expect.objectContaining({ - id: 'gemini-2.0-flash-exp' - }), - system: 'You are a helpful assistant', - messages: [{ role: 'user', content: 'Hello' }], - maxOutputTokens: 100, - temperature: 0.7 - }); - expect(result).toBe(mockStream); - }); - }); - - describe('generateObject with system messages', () => { - const mockObjectParams = { - ...mockParams, - schema: { type: 'object', properties: {} }, - objectName: 'testObject' - }; - - it('should include system prompt in fallback generateObject call', async () => { - // Mock parent to throw JSON error - jest - .spyOn( - Object.getPrototypeOf(Object.getPrototypeOf(provider)), - 'generateObject' - ) - .mockRejectedValueOnce(new Error('Failed to parse JSON')); - - // Mock direct generateObject call - generateObject.mockResolvedValueOnce({ - object: { result: 'success' }, - usage: { promptTokens: 15, completionTokens: 10, totalTokens: 25 } - }); - - const result = await provider.generateObject(mockObjectParams); - - expect(generateObject).toHaveBeenCalledWith({ - model: expect.objectContaining({ - id: 'gemini-2.0-flash-exp' - }), - system: expect.stringContaining('You are a helpful assistant'), - messages: [{ role: 'user', content: 'Hello' }], - schema: mockObjectParams.schema, - mode: 'json', - maxOutputTokens: 100, - temperature: 0.7 - }); - expect(result.object).toEqual({ result: 'success' }); - }); - }); - }); - - // Note: Error handling for module loading is tested in integration tests - // since dynamic imports are difficult to mock properly in unit tests - describe('authentication scenarios', () => { - it('should use api-key auth type with API key', async () => { + it('should use api-key auth type with valid API key', async () => { await provider.getClient({ apiKey: 'gemini-test-key' }); expect(createGeminiProvider).toHaveBeenCalledWith({ @@ -638,8 +251,8 @@ describe('GeminiCliProvider', () => { }); }); - it('should handle empty string API key as no API key', async () => { - await provider.getClient({ apiKey: '' }); + it('should prioritize OAuth over special marker API key', async () => { + await provider.getClient({ apiKey: 'gemini-cli-no-key-required' }); expect(createGeminiProvider).toHaveBeenCalledWith({ authType: 'oauth-personal' From af36d171c78359df927ef2883970efd168ecbc1e Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Mon, 24 Nov 2025 20:51:47 +0100 Subject: [PATCH 3/7] feat: implement MFA in taskmaster (#1438) --- CLAUDE.md | 28 + apps/cli/src/commands/auth.command.ts | 127 +++- packages/tm-bridge/src/bridge-types.ts | 4 +- .../tm-core/src/modules/auth/auth-domain.ts | 11 + packages/tm-core/src/modules/auth/index.ts | 3 +- .../auth/managers/auth-manager.spec.ts | 316 ++++++++-- .../src/modules/auth/managers/auth-manager.ts | 316 ++++------ .../auth/services/session-manager.spec.ts | 500 ++++++++++++++++ .../modules/auth/services/session-manager.ts | 390 +++++++++++++ packages/tm-core/src/modules/auth/types.ts | 36 +- .../clients/supabase-client.spec.ts | 546 ++++++++++++++++++ .../integration/clients/supabase-client.ts | 157 ++++- 12 files changed, 2148 insertions(+), 286 deletions(-) create mode 100644 packages/tm-core/src/modules/auth/services/session-manager.spec.ts create mode 100644 packages/tm-core/src/modules/auth/services/session-manager.ts create mode 100644 packages/tm-core/src/modules/integration/clients/supabase-client.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 38fd00bd..ec4c6dd3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,33 @@ function getTasks() { 4. Verify test now passes 5. Commit both fix and test together +### Testing Guidelines + +**Principles**: FIRST (Fast, Independent, Repeatable, Self-validating, Timely) +**Structure**: AAA (Arrange, Act, Assert) +**Coverage**: Right-BICEP (Right results, Boundary, Inverse, Cross-check, Error conditions, Performance) + +#### What to Mock + +**Unit tests** (`.spec.ts` - test single unit in isolation): +- **@tm/core**: Mock only external I/O (Supabase, APIs, filesystem). Use real internal services. +- **apps/cli**: Mock tm-core responses. Use real Commander/chalk/inquirer/other npm packages (test display logic). +- **apps/mcp**: Mock tm-core responses. Use real MCP framework (test response formatting). + +**Integration tests** (`tests/integration/` - test multiple units together): +- **All packages**: Use real tm-core, mock only external boundaries (APIs, DB, filesystem). + +**Never mock**: +- Internal utilities/helpers in the same package +- Standard frameworks (Commander, Express) - let them run +- Standard library + +**Rule of thumb**: Mock what you're NOT testing. CLI unit tests test display → mock tm-core. Core unit tests test logic → mock I/O. Integration tests test full flow → mock only external APIs. + +**Red flag**: Mocking 3+ dependencies in a unit test means code is doing too much or is in the wrong layer. + +**Anti-pattern**: Heavily mocked tests don't verify real behavior—they verify that you wired up mocks correctly. You end up writing orchestration code to satisfy tests, rather than tests that validate your actual implementation. If testing is hard, move the logic to where it's naturally testable. + ## Architecture Guidelines ### Business Logic Separation @@ -149,3 +176,4 @@ Apply standard software engineering principles: - **Add a changeset for code changes** - Run `npx changeset` after making code changes (not needed for docs-only PRs) - When creating changesets, remember that it's user-facing, meaning we don't have to get into the specifics of the code, but rather mention what the end-user is getting or fixing from this changeset +- Run `npm run turbo:typecheck` before pushing to ensure TypeScript type checks pass diff --git a/apps/cli/src/commands/auth.command.ts b/apps/cli/src/commands/auth.command.ts index be934a1f..d2293fc1 100644 --- a/apps/cli/src/commands/auth.command.ts +++ b/apps/cli/src/commands/auth.command.ts @@ -74,6 +74,7 @@ Examples: $ tm auth login # Browser-based OAuth flow (interactive) $ tm auth login # Token-based authentication $ tm auth login -y # Non-interactive token auth (for scripts) + # Note: MFA prompts cannot be skipped if enabled ` ) .action(async (token?: string, options?: { yes?: boolean }) => { @@ -114,6 +115,19 @@ Examples: }); } + /** + * Handle authentication errors with proper type safety + */ + private handleAuthError(error: unknown): void { + if (error instanceof Error) { + displayError(error); + } else { + displayError( + new Error(String(error ?? 'An unknown authentication error occurred')) + ); + } + } + /** * Execute login command */ @@ -133,8 +147,8 @@ Examples: setTimeout(() => { process.exit(0); }, 100); - } catch (error: any) { - displayError(error); + } catch (error) { + this.handleAuthError(error); } } @@ -149,8 +163,8 @@ Examples: if (!result.success) { process.exit(1); } - } catch (error: any) { - displayError(error); + } catch (error) { + this.handleAuthError(error); } } @@ -161,8 +175,8 @@ Examples: try { const result = await this.displayStatus(); this.setLastResult(result); - } catch (error: any) { - displayError(error); + } catch (error) { + this.handleAuthError(error); } } @@ -177,8 +191,8 @@ Examples: if (!result.success) { process.exit(1); } - } catch (error: any) { - displayError(error); + } catch (error) { + this.handleAuthError(error); } } @@ -512,11 +526,108 @@ Examples: spinner.succeed('Successfully authenticated!'); return credentials; } catch (error) { + // Check if MFA is required BEFORE showing failure message + if ( + error instanceof AuthenticationError && + error.code === 'MFA_REQUIRED' + ) { + // Stop spinner without showing failure - MFA is required, not a failure + spinner.stop(); + + // MFA is required - prompt the user for their MFA code + return this.handleMFAVerification(error); + } + + // Only show "Authentication failed" for actual failures spinner.fail('Authentication failed'); throw error; } } + /** + * Handle MFA verification flow + * Thin wrapper around @tm/core's verifyMFAWithRetry + */ + private async handleMFAVerification( + mfaError: AuthenticationError + ): Promise { + if (!mfaError.mfaChallenge) { + throw new AuthenticationError( + 'MFA challenge information missing', + 'MFA_VERIFICATION_FAILED' + ); + } + + console.log( + chalk.yellow( + '\nāš ļø Multi-factor authentication is enabled on your account' + ) + ); + console.log( + chalk.white( + ' Please enter the 6-digit code from your authenticator app\n' + ) + ); + + // Use @tm/core's retry logic - presentation layer just handles UI + const result = await this.authManager.verifyMFAWithRetry( + mfaError.mfaChallenge.factorId, + async () => { + // Prompt for MFA code + try { + const response = await inquirer.prompt([ + { + type: 'input', + name: 'mfaCode', + message: 'Enter your 6-digit MFA code:', + validate: (input: string) => { + const trimmed = (input || '').trim(); + + if (trimmed.length === 0) { + return 'MFA code cannot be empty'; + } + + if (!/^\d{6}$/.test(trimmed)) { + return 'MFA code must be exactly 6 digits (0-9)'; + } + + return true; + } + } + ]); + + return response.mfaCode.trim(); + } catch (error: any) { + // Handle user cancellation (Ctrl+C) + if ( + error.name === 'ExitPromptError' || + error.message?.includes('force closed') + ) { + ui.displayWarning('\nMFA verification cancelled by user'); + throw new AuthenticationError( + 'MFA verification cancelled', + 'MFA_VERIFICATION_FAILED' + ); + } + throw error; + } + }, + 3 // Max attempts + ); + + // Handle result from core + if (result.success && result.credentials) { + console.log(chalk.green('\nāœ“ MFA verification successful!')); + return result.credentials; + } + + // Show error with attempt count + throw new AuthenticationError( + `MFA verification failed after ${result.attemptsUsed} attempts`, + 'MFA_VERIFICATION_FAILED' + ); + } + /** * Perform token-based authentication flow */ diff --git a/packages/tm-bridge/src/bridge-types.ts b/packages/tm-bridge/src/bridge-types.ts index f3f719b5..cf02e273 100644 --- a/packages/tm-bridge/src/bridge-types.ts +++ b/packages/tm-bridge/src/bridge-types.ts @@ -2,6 +2,8 @@ * Shared types and interfaces for bridge functions */ +import type { TmCore } from '@tm/core'; + /** * Log levels used by bridge report functions */ @@ -40,7 +42,7 @@ export interface StorageCheckResult { /** Whether API storage is being used */ isApiStorage: boolean; /** TmCore instance if initialization succeeded */ - tmCore?: import('@tm/core').TmCore; + tmCore?: TmCore; /** Error message if initialization failed */ error?: string; } diff --git a/packages/tm-core/src/modules/auth/auth-domain.ts b/packages/tm-core/src/modules/auth/auth-domain.ts index f2d81e08..c0f137f0 100644 --- a/packages/tm-core/src/modules/auth/auth-domain.ts +++ b/packages/tm-core/src/modules/auth/auth-domain.ts @@ -96,6 +96,17 @@ export class AuthDomain { return this.authManager.authenticateWithCode(token); } + /** + * Verify MFA code and complete authentication + * Call this after authenticateWithCode() throws MFA_REQUIRED error + * + * @param factorId - MFA factor ID from the MFA_REQUIRED error + * @param code - The TOTP code from the user's authenticator app + */ + async verifyMFA(factorId: string, code: string): Promise { + return this.authManager.verifyMFA(factorId, code); + } + /** * Get OAuth authorization URL */ diff --git a/packages/tm-core/src/modules/auth/index.ts b/packages/tm-core/src/modules/auth/index.ts index 99cd5ae8..9c208f88 100644 --- a/packages/tm-core/src/modules/auth/index.ts +++ b/packages/tm-core/src/modules/auth/index.ts @@ -17,7 +17,8 @@ export type { OAuthFlowOptions, AuthConfig, CliData, - UserContext + UserContext, + MFAVerificationResult } from './types.js'; export { AuthenticationError } from './types.js'; diff --git a/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts b/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts index 16bfe7a4..c69b5928 100644 --- a/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts +++ b/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts @@ -1,52 +1,35 @@ /** * Tests for AuthManager singleton behavior + * + * Mocking strategy (per @tm/core guidelines): + * - Mock external I/O: SupabaseAuthClient (API), SessionManager (filesystem), OAuthService (OAuth APIs) + * - Mock side effects: logger (acceptable for unit tests) + * - Mock internal services: ContextStore (TODO: evaluate if real instance can be used) + * + * Note: Mocking 5 dependencies is a code smell suggesting AuthManager may have too many responsibilities. + * Consider refactoring to reduce coupling in the future. */ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock the logger to verify warnings (must be hoisted before SUT import) -const mockLogger = { - warn: vi.fn(), - info: vi.fn(), - debug: vi.fn(), - error: vi.fn() -}; - -vi.mock('../logger/index.js', () => ({ - getLogger: () => mockLogger +vi.mock('../../../common/logger/index.js', () => ({ + getLogger: () => ({ + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + error: vi.fn() + }) })); -// Spy on CredentialStore constructor to verify config propagation -const CredentialStoreSpy = vi.fn(); -vi.mock('./credential-store.js', () => { - return { - CredentialStore: class { - static getInstance(config?: any) { - return new (this as any)(config); - } - static resetInstance() { - // Mock reset instance method - } - constructor(config: any) { - CredentialStoreSpy(config); - } - getCredentials(_options?: any) { - return null; - } - saveCredentials() {} - clearCredentials() {} - hasCredentials() { - return false; - } - } - }; -}); - -// Mock OAuthService to avoid side effects -vi.mock('./oauth-service.js', () => { +// Spy on OAuthService constructor to verify config propagation +const OAuthServiceSpy = vi.fn(); +vi.mock('../services/oauth-service.js', () => { return { OAuthService: class { - constructor() {} + constructor(_contextStore: any, _supabaseClient: any, config?: any) { + OAuthServiceSpy(config); + } authenticate() { return Promise.resolve({}); } @@ -57,8 +40,38 @@ vi.mock('./oauth-service.js', () => { }; }); +// Mock ContextStore +vi.mock('../services/context-store.js', () => { + return { + ContextStore: class { + static getInstance() { + return new (this as any)(); + } + static resetInstance() {} + getUserContext() { + return null; + } + getContext() { + return null; + } + } + }; +}); + +// Mock SessionManager +vi.mock('../services/session-manager.js', () => { + return { + SessionManager: class { + constructor() {} + async getAuthCredentials() { + return null; + } + } + }; +}); + // Mock SupabaseAuthClient to avoid side effects -vi.mock('../clients/supabase-client.js', () => { +vi.mock('../../integration/clients/supabase-client.js', () => { return { SupabaseAuthClient: class { constructor() {} @@ -74,13 +87,14 @@ vi.mock('../clients/supabase-client.js', () => { // Import SUT after mocks import { AuthManager } from './auth-manager.js'; +import { AuthenticationError } from '../types.js'; describe('AuthManager Singleton', () => { beforeEach(() => { // Reset singleton before each test AuthManager.resetInstance(); vi.clearAllMocks(); - CredentialStoreSpy.mockClear(); + OAuthServiceSpy.mockClear(); }); it('should return the same instance on multiple calls', () => { @@ -100,44 +114,42 @@ describe('AuthManager Singleton', () => { const instance = AuthManager.getInstance(config); expect(instance).toBeDefined(); - // Assert that CredentialStore was constructed with the provided config - expect(CredentialStoreSpy).toHaveBeenCalledTimes(1); - expect(CredentialStoreSpy).toHaveBeenCalledWith(config); + // Assert that OAuthService was constructed with the provided config + expect(OAuthServiceSpy).toHaveBeenCalledTimes(1); + expect(OAuthServiceSpy).toHaveBeenCalledWith(config); // Verify the config is passed to internal components through observable behavior - // getCredentials would look in the configured file path - const credentials = await instance.getCredentials(); - expect(credentials).toBeNull(); // File doesn't exist, but config was propagated correctly + // getAuthCredentials would use the configured session + const credentials = await instance.getAuthCredentials(); + expect(credentials).toBeNull(); // No session, but config was propagated correctly }); it('should warn when config is provided after initialization', () => { - // Clear previous calls - mockLogger.warn.mockClear(); - // First call with config AuthManager.getInstance({ baseUrl: 'https://first.auth.com' }); - // Second call with different config + // Reset the spy to track only the second call + OAuthServiceSpy.mockClear(); + + // Second call with different config (should trigger warning) AuthManager.getInstance({ baseUrl: 'https://second.auth.com' }); - // Verify warning was logged - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringMatching(/config.*after initialization.*ignored/i) - ); + // Verify OAuthService was not constructed again (singleton behavior) + expect(OAuthServiceSpy).not.toHaveBeenCalled(); }); - it('should not warn when no config is provided after initialization', () => { - // Clear previous calls - mockLogger.warn.mockClear(); - + it('should not call OAuthService again when no config is provided after initialization', () => { // First call with config AuthManager.getInstance({ configDir: '/test/config' }); + // Reset the spy + OAuthServiceSpy.mockClear(); + // Second call without config AuthManager.getInstance(); - // Verify no warning was logged - expect(mockLogger.warn).not.toHaveBeenCalled(); + // Verify OAuthService was not constructed again + expect(OAuthServiceSpy).not.toHaveBeenCalled(); }); it('should allow resetting the instance', () => { @@ -153,3 +165,187 @@ describe('AuthManager Singleton', () => { expect(instance1).not.toBe(instance2); }); }); + +describe('AuthManager - MFA Retry Logic', () => { + beforeEach(() => { + AuthManager.resetInstance(); + vi.clearAllMocks(); + }); + + describe('verifyMFAWithRetry', () => { + it('should succeed on first attempt with valid code', async () => { + const authManager = AuthManager.getInstance(); + let callCount = 0; + + // Mock code provider + const codeProvider = vi.fn(async () => { + callCount++; + return '123456'; + }); + + // Mock successful verification + vi.spyOn(authManager, 'verifyMFA').mockResolvedValue({ + token: 'test-token', + userId: 'test-user', + email: 'test@example.com', + tokenType: 'standard', + savedAt: new Date().toISOString() + }); + + const result = await authManager.verifyMFAWithRetry( + 'factor-123', + codeProvider, + 3 + ); + + expect(result.success).toBe(true); + expect(result.attemptsUsed).toBe(1); + expect(result.credentials).toBeDefined(); + expect(result.credentials?.token).toBe('test-token'); + expect(codeProvider).toHaveBeenCalledTimes(1); + }); + + it('should retry on INVALID_MFA_CODE and succeed on second attempt', async () => { + const authManager = AuthManager.getInstance(); + let attemptCount = 0; + + // Mock code provider + const codeProvider = vi.fn(async () => { + attemptCount++; + return `code-${attemptCount}`; + }); + + // Mock verification: fail once, then succeed + const verifyMFASpy = vi + .spyOn(authManager, 'verifyMFA') + .mockRejectedValueOnce( + new AuthenticationError('Invalid MFA code', 'INVALID_MFA_CODE') + ) + .mockResolvedValueOnce({ + token: 'test-token', + userId: 'test-user', + email: 'test@example.com', + tokenType: 'standard', + savedAt: new Date().toISOString() + }); + + const result = await authManager.verifyMFAWithRetry( + 'factor-123', + codeProvider, + 3 + ); + + expect(result.success).toBe(true); + expect(result.attemptsUsed).toBe(2); + expect(result.credentials).toBeDefined(); + expect(codeProvider).toHaveBeenCalledTimes(2); + expect(verifyMFASpy).toHaveBeenCalledTimes(2); + }); + + it('should fail after max attempts with INVALID_MFA_CODE', async () => { + const authManager = AuthManager.getInstance(); + const codeProvider = vi.fn(async () => '000000'); + + // Mock verification to always fail + vi.spyOn(authManager, 'verifyMFA').mockRejectedValue( + new AuthenticationError('Invalid MFA code', 'INVALID_MFA_CODE') + ); + + const result = await authManager.verifyMFAWithRetry( + 'factor-123', + codeProvider, + 3 + ); + + expect(result.success).toBe(false); + expect(result.attemptsUsed).toBe(3); + expect(result.credentials).toBeUndefined(); + expect(result.errorCode).toBe('INVALID_MFA_CODE'); + expect(codeProvider).toHaveBeenCalledTimes(3); + }); + + it('should throw immediately on non-INVALID_MFA_CODE errors', async () => { + const authManager = AuthManager.getInstance(); + const codeProvider = vi.fn(async () => '123456'); + + // Mock verification to throw different error + const networkError = new AuthenticationError( + 'Network error', + 'NETWORK_ERROR' + ); + vi.spyOn(authManager, 'verifyMFA').mockRejectedValue(networkError); + + await expect( + authManager.verifyMFAWithRetry('factor-123', codeProvider, 3) + ).rejects.toThrow('Network error'); + + // Should not retry on non-INVALID_MFA_CODE errors + expect(codeProvider).toHaveBeenCalledTimes(1); + }); + + it('should respect custom maxAttempts parameter', async () => { + const authManager = AuthManager.getInstance(); + const codeProvider = vi.fn(async () => '000000'); + + // Mock verification to always fail + vi.spyOn(authManager, 'verifyMFA').mockRejectedValue( + new AuthenticationError('Invalid MFA code', 'INVALID_MFA_CODE') + ); + + const result = await authManager.verifyMFAWithRetry( + 'factor-123', + codeProvider, + 5 // Custom max attempts + ); + + expect(result.success).toBe(false); + expect(result.attemptsUsed).toBe(5); + expect(codeProvider).toHaveBeenCalledTimes(5); + }); + + it('should use default maxAttempts of 3', async () => { + const authManager = AuthManager.getInstance(); + const codeProvider = vi.fn(async () => '000000'); + + vi.spyOn(authManager, 'verifyMFA').mockRejectedValue( + new AuthenticationError('Invalid MFA code', 'INVALID_MFA_CODE') + ); + + // Don't pass maxAttempts - should default to 3 + const result = await authManager.verifyMFAWithRetry( + 'factor-123', + codeProvider + ); + + expect(result.success).toBe(false); + expect(result.attemptsUsed).toBe(3); + expect(codeProvider).toHaveBeenCalledTimes(3); + }); + + it('should throw TypeError on invalid maxAttempts (0 or negative)', async () => { + const authManager = AuthManager.getInstance(); + const codeProvider = vi.fn(async () => '123456'); + + // Test with 0 + await expect( + authManager.verifyMFAWithRetry('factor-123', codeProvider, 0) + ).rejects.toThrow(TypeError); + + await expect( + authManager.verifyMFAWithRetry('factor-123', codeProvider, 0) + ).rejects.toThrow('Invalid maxAttempts value: 0. Must be at least 1.'); + + // Test with negative + await expect( + authManager.verifyMFAWithRetry('factor-123', codeProvider, -1) + ).rejects.toThrow(TypeError); + + await expect( + authManager.verifyMFAWithRetry('factor-123', codeProvider, -1) + ).rejects.toThrow('Invalid maxAttempts value: -1. Must be at least 1.'); + + // Verify code provider was never called + expect(codeProvider).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/tm-core/src/modules/auth/managers/auth-manager.ts b/packages/tm-core/src/modules/auth/managers/auth-manager.ts index 29f739f7..7b3af187 100644 --- a/packages/tm-core/src/modules/auth/managers/auth-manager.ts +++ b/packages/tm-core/src/modules/auth/managers/auth-manager.ts @@ -1,10 +1,8 @@ /** * Authentication manager for Task Master CLI + * Lightweight coordinator that delegates to focused services */ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; import { ERROR_CODES, TaskMasterError @@ -19,91 +17,45 @@ import { OrganizationService, type RemoteTask } from '../services/organization.service.js'; +import { SessionManager } from '../services/session-manager.js'; import { - AuthConfig, - AuthCredentials, + type AuthConfig, + type AuthCredentials, AuthenticationError, - OAuthFlowOptions, - UserContext, - UserContextWithBrief + type MFAVerificationResult, + type OAuthFlowOptions, + type UserContext, + type UserContextWithBrief } from '../types.js'; /** - * Authentication manager class + * Authentication manager class - coordinates auth services */ export class AuthManager { private static instance: AuthManager | null = null; private static readonly staticLogger = getLogger('AuthManager'); private contextStore: ContextStore; private oauthService: OAuthService; + private sessionManager: SessionManager; public supabaseClient: SupabaseAuthClient; private organizationService?: OrganizationService; - private readonly logger = getLogger('AuthManager'); - private readonly LEGACY_AUTH_FILE = path.join( - os.homedir(), - '.taskmaster', - 'auth.json' - ); private constructor(config?: Partial) { this.contextStore = ContextStore.getInstance(); this.supabaseClient = new SupabaseAuthClient(); + + // Initialize session manager (handles session lifecycle) + this.sessionManager = new SessionManager( + this.supabaseClient, + this.contextStore + ); + // Pass the supabase client to OAuthService so they share the same instance this.oauthService = new OAuthService( this.contextStore, this.supabaseClient, config ); - - // Initialize Supabase client with session restoration - // Fire-and-forget with catch handler to prevent unhandled rejections - this.initializeSupabaseSession().catch(() => { - // Errors are already logged in initializeSupabaseSession - }); - - // Migrate legacy auth.json if it exists - // Fire-and-forget with catch handler - this.migrateLegacyAuth().catch(() => { - // Errors are already logged in migrateLegacyAuth - }); - } - - /** - * Initialize Supabase session from stored credentials - */ - private async initializeSupabaseSession(): Promise { - try { - await this.supabaseClient.initialize(); - } catch (error) { - // Log but don't throw - session might not exist yet - this.logger.debug('No existing session to restore'); - } - } - - /** - * Migrate legacy auth.json to Supabase session - * Called once during AuthManager initialization - */ - private async migrateLegacyAuth(): Promise { - if (!fs.existsSync(this.LEGACY_AUTH_FILE)) { - return; - } - - try { - // If we have a valid Supabase session, delete legacy file - const hasSession = await this.hasValidSession(); - if (hasSession) { - fs.unlinkSync(this.LEGACY_AUTH_FILE); - this.logger.info('Migrated to Supabase auth, removed legacy auth.json'); - return; - } - - // Otherwise, user needs to re-authenticate - this.logger.warn('Legacy auth.json found but no valid Supabase session.'); - this.logger.warn('Please run: task-master auth login'); - } catch (error) { - this.logger.debug('Error during legacy auth migration:', error); - } } /** @@ -134,8 +86,7 @@ export class AuthManager { * @returns Access token or null if not authenticated */ async getAccessToken(): Promise { - const session = await this.supabaseClient.getSession(); - return session?.access_token || null; + return this.sessionManager.getAccessToken(); } /** @@ -144,24 +95,7 @@ export class AuthManager { * @returns AuthCredentials object or null if not authenticated */ async getAuthCredentials(): Promise { - const session = await this.supabaseClient.getSession(); - if (!session) return null; - - const user = session.user; - const context = this.contextStore.getUserContext(); - - return { - token: session.access_token, - refreshToken: session.refresh_token, - userId: user.id, - email: user.email, - expiresAt: session.expires_at - ? new Date(session.expires_at * 1000).toISOString() - : undefined, - tokenType: 'standard', - savedAt: new Date().toISOString(), - selectedContext: context || undefined - }; + return this.sessionManager.getAuthCredentials(); } /** @@ -179,61 +113,91 @@ export class AuthManager { * where browser-based auth is not practical */ async authenticateWithCode(token: string): Promise { - try { - this.logger.info('Authenticating with one-time token...'); + return this.sessionManager.authenticateWithCode(token); + } - // Verify the token and get session from Supabase - const session = await this.supabaseClient.verifyOneTimeCode(token); + /** + * Verify MFA code and complete authentication + * Call this after authenticateWithCode() throws MFA_REQUIRED error + */ + async verifyMFA(factorId: string, code: string): Promise { + return this.sessionManager.verifyMFA(factorId, code); + } - if (!session || !session.access_token) { - throw new AuthenticationError( - 'Failed to obtain access token from token', - 'NO_TOKEN' - ); - } - - // Get user information - const user = await this.supabaseClient.getUser(); - - if (!user) { - throw new AuthenticationError( - 'Failed to get user information', - 'INVALID_RESPONSE' - ); - } - - // Store user context - this.contextStore.saveContext({ - userId: user.id, - email: user.email - }); - - // Build credentials response - const context = this.contextStore.getUserContext(); - const credentials: AuthCredentials = { - token: session.access_token, - refreshToken: session.refresh_token, - userId: user.id, - email: user.email, - expiresAt: session.expires_at - ? new Date(session.expires_at * 1000).toISOString() - : undefined, - tokenType: 'standard', - savedAt: new Date().toISOString(), - selectedContext: context || undefined - }; - - this.logger.info('Successfully authenticated with token'); - return credentials; - } catch (error) { - if (error instanceof AuthenticationError) { - throw error; - } - throw new AuthenticationError( - `Token authentication failed: ${(error as Error).message}`, - 'CODE_AUTH_FAILED' + /** + * Verify MFA code with automatic retry logic + * Handles retry attempts for invalid MFA codes up to maxAttempts + * + * @param factorId - MFA factor ID from the MFA_REQUIRED error + * @param codeProvider - Function that prompts for and returns the MFA code + * @param maxAttempts - Maximum number of verification attempts (default: 3) + * @returns Result object with success status, attempts used, and credentials if successful + * + * @example + * ```typescript + * const result = await authManager.verifyMFAWithRetry( + * factorId, + * async () => await promptUserForMFACode(), + * 3 + * ); + * + * if (result.success) { + * console.log('MFA verified!', result.credentials); + * } else { + * console.error(`Failed after ${result.attemptsUsed} attempts`); + * } + * ``` + */ + async verifyMFAWithRetry( + factorId: string, + codeProvider: () => Promise, + maxAttempts = 3 + ): Promise { + // Guard against invalid maxAttempts values + if (maxAttempts < 1) { + throw new TypeError( + `Invalid maxAttempts value: ${maxAttempts}. Must be at least 1.` ); } + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const code = await codeProvider(); + const credentials = await this.verifyMFA(factorId, code); + return { + success: true, + attemptsUsed: attempt, + credentials + }; + } catch (error) { + // Only retry on invalid MFA code errors + if ( + error instanceof AuthenticationError && + error.code === 'INVALID_MFA_CODE' + ) { + // If we've exhausted attempts, return failure + if (attempt >= maxAttempts) { + return { + success: false, + attemptsUsed: attempt, + errorCode: 'INVALID_MFA_CODE' + }; + } + // Otherwise continue to next attempt + continue; + } + + // For other errors, fail immediately + throw error; + } + } + + // Should never reach here due to loop logic, but TypeScript needs it + return { + success: false, + attemptsUsed: maxAttempts, + errorCode: 'MFA_VERIFICATION_FAILED' + }; } /** @@ -249,75 +213,14 @@ export class AuthManager { * This method is mainly for explicit refresh requests. */ async refreshToken(): Promise { - try { - // Use Supabase's built-in session refresh - const session = await this.supabaseClient.refreshSession(); - - if (!session) { - throw new AuthenticationError( - 'Failed to refresh session', - 'REFRESH_FAILED' - ); - } - - // Sync user info to context store - this.contextStore.saveContext({ - userId: session.user.id, - email: session.user.email - }); - - // Build credentials response - const context = this.contextStore.getContext(); - const credentials: AuthCredentials = { - token: session.access_token, - refreshToken: session.refresh_token, - userId: session.user.id, - email: session.user.email, - expiresAt: session.expires_at - ? new Date(session.expires_at * 1000).toISOString() - : undefined, - savedAt: new Date().toISOString(), - selectedContext: context?.selectedContext - }; - - return credentials; - } catch (error) { - if (error instanceof AuthenticationError) { - throw error; - } - throw new AuthenticationError( - `Token refresh failed: ${(error as Error).message}`, - 'REFRESH_FAILED' - ); - } + return this.sessionManager.refreshToken(); } /** * Logout and clear credentials */ async logout(): Promise { - try { - // First try to sign out from Supabase to revoke tokens - await this.supabaseClient.signOut(); - } catch (error) { - // Log but don't throw - we still want to clear local credentials - this.logger.warn('Failed to sign out from Supabase:', error); - } - - // Clear app context - this.contextStore.clearContext(); - // Session is cleared by supabaseClient.signOut() - - // Clear legacy auth.json if it exists - try { - if (fs.existsSync(this.LEGACY_AUTH_FILE)) { - fs.unlinkSync(this.LEGACY_AUTH_FILE); - this.logger.debug('Cleared legacy auth.json'); - } - } catch (error) { - // Ignore errors clearing legacy file - this.logger.debug('No legacy credentials to clear'); - } + return this.sessionManager.logout(); } /** @@ -325,26 +228,21 @@ export class AuthManager { * @returns true if a valid session exists */ async hasValidSession(): Promise { - try { - const session = await this.supabaseClient.getSession(); - return session !== null; - } catch { - return false; - } + return this.sessionManager.hasValidSession(); } /** * Get the current Supabase session */ async getSession() { - return this.supabaseClient.getSession(); + return this.sessionManager.getSession(); } /** * Get stored user context (userId, email) */ getStoredContext() { - return this.contextStore.getContext(); + return this.sessionManager.getStoredContext(); } /** @@ -382,8 +280,8 @@ export class AuthManager { */ private async getOrganizationService(): Promise { if (!this.organizationService) { - // Check if we have a valid Supabase session - const session = await this.supabaseClient.getSession(); + // Check if we have a valid Supabase session via SessionManager + const session = await this.sessionManager.getSession(); if (!session) { throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED'); diff --git a/packages/tm-core/src/modules/auth/services/session-manager.spec.ts b/packages/tm-core/src/modules/auth/services/session-manager.spec.ts new file mode 100644 index 00000000..a38072fe --- /dev/null +++ b/packages/tm-core/src/modules/auth/services/session-manager.spec.ts @@ -0,0 +1,500 @@ +/** + * Tests for SessionManager + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// Mock the logger +const mockLogger = { + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + error: vi.fn() +}; + +vi.mock('../../../common/logger/index.js', () => ({ + getLogger: () => mockLogger +})); + +// Mock fs with default implementations +vi.mock('fs', () => ({ + default: { + existsSync: vi.fn(() => false), + unlinkSync: vi.fn() + }, + existsSync: vi.fn(() => false), + unlinkSync: vi.fn() +})); + +// Mock SupabaseAuthClient +const mockSupabaseClient = { + initialize: vi.fn(), + getSession: vi.fn(), + getUser: vi.fn(), + verifyOneTimeCode: vi.fn(), + checkMFARequired: vi.fn(), + verifyMFA: vi.fn(), + refreshSession: vi.fn(), + signOut: vi.fn() +}; + +vi.mock('../../integration/clients/supabase-client.js', () => ({ + SupabaseAuthClient: vi.fn(() => mockSupabaseClient) +})); + +// Mock ContextStore +const mockContextStore = { + getUserContext: vi.fn(), + getContext: vi.fn(), + saveContext: vi.fn(), + clearContext: vi.fn() +}; + +vi.mock('./context-store.js', () => ({ + ContextStore: { + getInstance: () => mockContextStore + } +})); + +import { SessionManager } from './session-manager.js'; +import { AuthenticationError } from '../types.js'; + +describe('SessionManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Initialization', () => { + it('should initialize session on construction', async () => { + mockSupabaseClient.initialize.mockResolvedValue(undefined); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + // Wait for initialization to complete + await sessionManager.waitForInitialization(); + + expect(mockSupabaseClient.initialize).toHaveBeenCalled(); + }); + + it('should handle initialization errors gracefully', async () => { + mockSupabaseClient.initialize.mockRejectedValue(new Error('No session')); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + // Should not throw + await expect( + sessionManager.waitForInitialization() + ).resolves.toBeUndefined(); + + expect(mockLogger.debug).toHaveBeenCalledWith( + 'No existing session to restore' + ); + }); + + it('should prevent race conditions by waiting for initialization', async () => { + let initResolved = false; + mockSupabaseClient.initialize.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + initResolved = true; + }); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + // Call method that should wait for initialization + const sessionPromise = sessionManager.getSession(); + + // Verify init hasn't completed yet + expect(initResolved).toBe(false); + + await sessionPromise; + + // Now it should have completed + expect(initResolved).toBe(true); + }); + }); + + describe('Legacy Migration', () => { + const LEGACY_AUTH_FILE = path.join( + os.homedir(), + '.taskmaster', + 'auth.json' + ); + + it('should delete legacy file if valid session exists', async () => { + // Setup all mocks before creating SessionManager + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.unlinkSync).mockReturnValue(undefined); + + // Mock initialization to succeed + mockSupabaseClient.initialize.mockResolvedValue(undefined); + + // First call in migrateLegacyAuth, second call checks session + mockSupabaseClient.getSession + .mockResolvedValueOnce({ + access_token: 'valid-token', + user: { id: 'user-1', email: 'test@example.com' } + }) + .mockResolvedValue({ + access_token: 'valid-token', + user: { id: 'user-1', email: 'test@example.com' } + }); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + await sessionManager.waitForInitialization(); + + expect(fs.unlinkSync).toHaveBeenCalledWith(LEGACY_AUTH_FILE); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('Migrated to Supabase auth') + ); + }); + + it('should warn if legacy file exists but no session', async () => { + // Setup all mocks before creating SessionManager + vi.mocked(fs.existsSync).mockReturnValue(true); + + // Mock initialization to succeed + mockSupabaseClient.initialize.mockResolvedValue(undefined); + + // No session available + mockSupabaseClient.getSession.mockResolvedValue(null); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + await sessionManager.waitForInitialization(); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Legacy auth.json found') + ); + }); + }); + + describe('Session State', () => { + it('should return true for valid session', async () => { + mockSupabaseClient.getSession.mockResolvedValue({ + access_token: 'valid-token' + }); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + const hasSession = await sessionManager.hasValidSession(); + + expect(hasSession).toBe(true); + }); + + it('should return false when no session exists', async () => { + mockSupabaseClient.getSession.mockResolvedValue(null); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + const hasSession = await sessionManager.hasValidSession(); + + expect(hasSession).toBe(false); + }); + + it('should return false on session check error', async () => { + mockSupabaseClient.getSession.mockRejectedValue(new Error('Failed')); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + const hasSession = await sessionManager.hasValidSession(); + + expect(hasSession).toBe(false); + }); + }); + + describe('Token Operations', () => { + it('should get access token from session', async () => { + mockSupabaseClient.getSession.mockResolvedValue({ + access_token: 'test-token' + }); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + const token = await sessionManager.getAccessToken(); + + expect(token).toBe('test-token'); + }); + + it('should return null when no session', async () => { + mockSupabaseClient.getSession.mockResolvedValue(null); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + const token = await sessionManager.getAccessToken(); + + expect(token).toBeNull(); + }); + + it('should build auth credentials from session', async () => { + const mockSession = { + access_token: 'test-token', + refresh_token: 'refresh-token', + expires_at: 1234567890, + user: { + id: 'user-1', + email: 'test@example.com' + } + }; + + mockSupabaseClient.getSession.mockResolvedValue(mockSession); + mockContextStore.getUserContext.mockReturnValue({ + briefId: 'brief-1', + briefName: 'Test Brief' + }); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + const credentials = await sessionManager.getAuthCredentials(); + + expect(credentials).toMatchObject({ + token: 'test-token', + refreshToken: 'refresh-token', + userId: 'user-1', + email: 'test@example.com', + tokenType: 'standard' + }); + expect(credentials?.selectedContext).toEqual({ + briefId: 'brief-1', + briefName: 'Test Brief' + }); + }); + + it('should refresh token and sync context', async () => { + const mockSession = { + access_token: 'new-token', + refresh_token: 'new-refresh-token', + expires_at: 9999999999, + user: { + id: 'user-1', + email: 'test@example.com' + } + }; + + mockSupabaseClient.refreshSession.mockResolvedValue(mockSession); + mockContextStore.getContext.mockReturnValue({ + selectedContext: { briefId: 'brief-1' } + }); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + const credentials = await sessionManager.refreshToken(); + + expect(mockContextStore.saveContext).toHaveBeenCalledWith({ + userId: 'user-1', + email: 'test@example.com' + }); + expect(credentials.token).toBe('new-token'); + }); + + it('should throw on refresh failure', async () => { + mockSupabaseClient.refreshSession.mockResolvedValue(null); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + await expect(sessionManager.refreshToken()).rejects.toThrow( + AuthenticationError + ); + }); + }); + + describe('Authentication with Code', () => { + it('should authenticate with one-time code', async () => { + const mockSession = { + access_token: 'test-token', + refresh_token: 'refresh-token', + expires_at: 1234567890 + }; + + const mockUser = { + id: 'user-1', + email: 'test@example.com' + }; + + mockSupabaseClient.verifyOneTimeCode.mockResolvedValue(mockSession); + mockSupabaseClient.getUser.mockResolvedValue(mockUser); + mockSupabaseClient.checkMFARequired.mockResolvedValue({ + required: false + }); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + const credentials = + await sessionManager.authenticateWithCode('test-code'); + + expect(credentials.token).toBe('test-token'); + expect(credentials.userId).toBe('user-1'); + expect(mockContextStore.saveContext).toHaveBeenCalledWith({ + userId: 'user-1', + email: 'test@example.com' + }); + }); + + it('should throw MFA_REQUIRED when MFA needed', async () => { + const mockSession = { + access_token: 'test-token', + refresh_token: 'refresh-token' + }; + + mockSupabaseClient.verifyOneTimeCode.mockResolvedValue(mockSession); + mockSupabaseClient.getUser.mockResolvedValue({ + id: 'user-1', + email: 'test@example.com' + }); + mockSupabaseClient.checkMFARequired.mockResolvedValue({ + required: true, + factorId: 'factor-123', + factorType: 'totp' + }); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + try { + await sessionManager.authenticateWithCode('test-code'); + expect.fail('Should have thrown MFA_REQUIRED error'); + } catch (error) { + expect(error).toBeInstanceOf(AuthenticationError); + expect((error as AuthenticationError).code).toBe('MFA_REQUIRED'); + expect((error as AuthenticationError).mfaChallenge).toEqual({ + factorId: 'factor-123', + factorType: 'totp' + }); + } + }); + }); + + describe('MFA Verification', () => { + it('should verify MFA and return credentials', async () => { + const mockSession = { + access_token: 'mfa-token', + refresh_token: 'mfa-refresh-token', + expires_at: 1234567890 + }; + + const mockUser = { + id: 'user-1', + email: 'test@example.com' + }; + + mockSupabaseClient.verifyMFA.mockResolvedValue(mockSession); + mockSupabaseClient.getUser.mockResolvedValue(mockUser); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + const credentials = await sessionManager.verifyMFA( + 'factor-123', + '123456' + ); + + expect(credentials.token).toBe('mfa-token'); + expect(mockContextStore.saveContext).toHaveBeenCalled(); + }); + + it('should throw on MFA verification failure', async () => { + mockSupabaseClient.verifyMFA.mockResolvedValue(null); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + await expect( + sessionManager.verifyMFA('factor-123', '123456') + ).rejects.toThrow(AuthenticationError); + }); + }); + + describe('Logout', () => { + it('should sign out and clear all credentials', async () => { + // Setup all mocks before creating SessionManager + vi.mocked(fs.existsSync).mockReturnValue(false); // No legacy file during init + mockSupabaseClient.initialize.mockResolvedValue(undefined); + mockSupabaseClient.signOut.mockResolvedValue(undefined); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + // Now mock existsSync to return true for logout test + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.unlinkSync).mockReturnValue(undefined); + + await sessionManager.logout(); + + expect(mockSupabaseClient.signOut).toHaveBeenCalled(); + expect(mockContextStore.clearContext).toHaveBeenCalled(); + expect(fs.unlinkSync).toHaveBeenCalled(); + }); + + it('should clear local state even if Supabase signout fails', async () => { + mockSupabaseClient.signOut.mockRejectedValue(new Error('Network error')); + vi.mocked(fs.existsSync).mockReturnValue(false); + + const sessionManager = new SessionManager( + mockSupabaseClient as any, + mockContextStore as any + ); + + // Should not throw + await expect(sessionManager.logout()).resolves.toBeUndefined(); + + expect(mockContextStore.clearContext).toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to sign out'), + expect.anything() + ); + }); + }); +}); diff --git a/packages/tm-core/src/modules/auth/services/session-manager.ts b/packages/tm-core/src/modules/auth/services/session-manager.ts new file mode 100644 index 00000000..f3d9bea6 --- /dev/null +++ b/packages/tm-core/src/modules/auth/services/session-manager.ts @@ -0,0 +1,390 @@ +/** + * Session Manager + * Handles session initialization, authentication, and lifecycle management + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { getLogger } from '../../../common/logger/index.js'; +import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js'; +import { ContextStore } from './context-store.js'; +import type { AuthCredentials } from '../types.js'; +import { AuthenticationError } from '../types.js'; + +/** + * SessionManager - Focused service for session and token management + */ +export class SessionManager { + private readonly logger = getLogger('SessionManager'); + private readonly LEGACY_AUTH_FILE = path.join( + os.homedir(), + '.taskmaster', + 'auth.json' + ); + private initializationPromise: Promise; + + constructor( + private supabaseClient: SupabaseAuthClient, + private contextStore: ContextStore + ) { + // Initialize session with proper promise tracking to prevent race conditions + this.initializationPromise = this.initialize(); + } + + /** + * Initialize session - called once during construction + * Ensures all async initialization completes before session operations + */ + private async initialize(): Promise { + try { + await this.initializeSupabaseSession(); + await this.migrateLegacyAuth(); + } catch (error) { + // Log but don't throw - initialization errors are handled gracefully + this.logger.debug('Session initialization completed with warnings'); + } + } + + /** + * Wait for initialization to complete + * Call this before any operation that depends on session state + */ + async waitForInitialization(): Promise { + await this.initializationPromise; + } + + /** + * Initialize Supabase session from stored credentials + */ + private async initializeSupabaseSession(): Promise { + try { + await this.supabaseClient.initialize(); + } catch (error) { + // Log but don't throw - session might not exist yet + this.logger.debug('No existing session to restore'); + } + } + + /** + * Migrate legacy auth.json to Supabase session + * Called once during SessionManager initialization + */ + private async migrateLegacyAuth(): Promise { + if (!fs.existsSync(this.LEGACY_AUTH_FILE)) { + return; + } + + try { + // Check if we have a valid Supabase session (don't use hasValidSession to avoid circular wait) + const session = await this.supabaseClient.getSession(); + if (session) { + fs.unlinkSync(this.LEGACY_AUTH_FILE); + this.logger.info('Migrated to Supabase auth, removed legacy auth.json'); + return; + } + + // Otherwise, user needs to re-authenticate + this.logger.warn('Legacy auth.json found but no valid Supabase session.'); + this.logger.warn('Please run: task-master auth login'); + } catch (error) { + this.logger.debug('Error during legacy auth migration:', error); + } + } + + // ========== Session State ========== + + /** + * Check if valid Supabase session exists + * @returns true if a valid session exists + */ + async hasValidSession(): Promise { + await this.waitForInitialization(); + try { + const session = await this.supabaseClient.getSession(); + return session !== null; + } catch { + return false; + } + } + + /** + * Get the current Supabase session + */ + async getSession() { + await this.waitForInitialization(); + return this.supabaseClient.getSession(); + } + + /** + * Get stored user context (userId, email) + */ + getStoredContext() { + return this.contextStore.getContext(); + } + + // ========== Token Operations ========== + + /** + * Get access token from current Supabase session + * @returns Access token or null if not authenticated + */ + async getAccessToken(): Promise { + await this.waitForInitialization(); + const session = await this.supabaseClient.getSession(); + return session?.access_token || null; + } + + /** + * Get authentication credentials from Supabase session + * Modern replacement for legacy getCredentials() + * @returns AuthCredentials object or null if not authenticated + */ + async getAuthCredentials(): Promise { + await this.waitForInitialization(); + const session = await this.supabaseClient.getSession(); + if (!session) return null; + + const user = session.user; + const context = this.contextStore.getUserContext(); + + return { + token: session.access_token, + refreshToken: session.refresh_token, + userId: user.id, + email: user.email, + expiresAt: session.expires_at + ? new Date(session.expires_at * 1000).toISOString() + : undefined, + tokenType: 'standard', + savedAt: new Date().toISOString(), + selectedContext: context || undefined + }; + } + + /** + * Refresh authentication token using Supabase session + * Note: Supabase handles token refresh automatically via the session storage adapter. + * This method is mainly for explicit refresh requests. + */ + async refreshToken(): Promise { + await this.waitForInitialization(); + try { + // Use Supabase's built-in session refresh + const session = await this.supabaseClient.refreshSession(); + + if (!session) { + throw new AuthenticationError( + 'Failed to refresh session', + 'REFRESH_FAILED' + ); + } + + // Sync user info to context store + this.contextStore.saveContext({ + userId: session.user.id, + email: session.user.email + }); + + // Build credentials response + const context = this.contextStore.getContext(); + const credentials: AuthCredentials = { + token: session.access_token, + refreshToken: session.refresh_token, + userId: session.user.id, + email: session.user.email, + expiresAt: session.expires_at + ? new Date(session.expires_at * 1000).toISOString() + : undefined, + savedAt: new Date().toISOString(), + selectedContext: context?.selectedContext + }; + + return credentials; + } catch (error) { + if (error instanceof AuthenticationError) { + throw error; + } + throw new AuthenticationError( + `Token refresh failed: ${(error as Error).message}`, + 'REFRESH_FAILED' + ); + } + } + + // ========== Authentication ========== + + /** + * Authenticate using a one-time token + * This is useful for CLI authentication in SSH/remote environments + * where browser-based auth is not practical + */ + async authenticateWithCode(token: string): Promise { + await this.waitForInitialization(); + try { + this.logger.info('Authenticating with one-time token...'); + + // Verify the token and get session from Supabase + const session = await this.supabaseClient.verifyOneTimeCode(token); + + if (!session || !session.access_token) { + throw new AuthenticationError( + 'Failed to obtain access token from token', + 'NO_TOKEN' + ); + } + + // Get user information + const user = await this.supabaseClient.getUser(); + + if (!user) { + throw new AuthenticationError( + 'Failed to get user information', + 'INVALID_RESPONSE' + ); + } + + // Check if MFA is required for this user + const mfaCheck = await this.supabaseClient.checkMFARequired(); + + if (mfaCheck.required && mfaCheck.factorId && mfaCheck.factorType) { + // MFA is required - throw an error with the MFA challenge information + throw new AuthenticationError( + 'MFA verification required. Please provide your authentication code.', + 'MFA_REQUIRED', + undefined, + { + factorId: mfaCheck.factorId, + factorType: mfaCheck.factorType + } + ); + } + + // Store user context + this.contextStore.saveContext({ + userId: user.id, + email: user.email + }); + + // Build credentials response + const context = this.contextStore.getUserContext(); + const credentials: AuthCredentials = { + token: session.access_token, + refreshToken: session.refresh_token, + userId: user.id, + email: user.email, + expiresAt: session.expires_at + ? new Date(session.expires_at * 1000).toISOString() + : undefined, + tokenType: 'standard', + savedAt: new Date().toISOString(), + selectedContext: context || undefined + }; + + this.logger.info('Successfully authenticated with token'); + return credentials; + } catch (error) { + if (error instanceof AuthenticationError) { + throw error; + } + throw new AuthenticationError( + `Token authentication failed: ${(error as Error).message}`, + 'CODE_AUTH_FAILED' + ); + } + } + + /** + * Verify MFA code and complete authentication + * Call this after authenticateWithCode() throws MFA_REQUIRED error + */ + async verifyMFA(factorId: string, code: string): Promise { + await this.waitForInitialization(); + try { + this.logger.info('Verifying MFA code...'); + + // Verify MFA code and get upgraded session + const session = await this.supabaseClient.verifyMFA(factorId, code); + + if (!session || !session.access_token) { + throw new AuthenticationError( + 'Failed to obtain access token after MFA verification', + 'NO_TOKEN' + ); + } + + // Get user information + const user = await this.supabaseClient.getUser(); + + if (!user) { + throw new AuthenticationError( + 'Failed to get user information', + 'INVALID_RESPONSE' + ); + } + + // Store user context + this.contextStore.saveContext({ + userId: user.id, + email: user.email + }); + + // Build credentials response + const context = this.contextStore.getUserContext(); + const credentials: AuthCredentials = { + token: session.access_token, + refreshToken: session.refresh_token, + userId: user.id, + email: user.email, + expiresAt: session.expires_at + ? new Date(session.expires_at * 1000).toISOString() + : undefined, + tokenType: 'standard', + savedAt: new Date().toISOString(), + selectedContext: context || undefined + }; + + this.logger.info('Successfully verified MFA and authenticated'); + return credentials; + } catch (error) { + if (error instanceof AuthenticationError) { + throw error; + } + throw new AuthenticationError( + `MFA verification failed: ${(error as Error).message}`, + 'MFA_VERIFICATION_FAILED' + ); + } + } + + // ========== Session Lifecycle ========== + + /** + * Logout and clear credentials + */ + async logout(): Promise { + await this.waitForInitialization(); + try { + // First try to sign out from Supabase to revoke tokens + await this.supabaseClient.signOut(); + } catch (error) { + // Log but don't throw - we still want to clear local credentials + this.logger.warn('Failed to sign out from Supabase:', error); + } + + // Clear app context + this.contextStore.clearContext(); + // Session is cleared by supabaseClient.signOut() + + // Clear legacy auth.json if it exists + try { + if (fs.existsSync(this.LEGACY_AUTH_FILE)) { + fs.unlinkSync(this.LEGACY_AUTH_FILE); + this.logger.debug('Cleared legacy auth.json'); + } + } catch (error) { + // Ignore errors clearing legacy file + this.logger.debug('No legacy credentials to clear'); + } + } +} diff --git a/packages/tm-core/src/modules/auth/types.ts b/packages/tm-core/src/modules/auth/types.ts index 497b0a15..6c5429b5 100644 --- a/packages/tm-core/src/modules/auth/types.ts +++ b/packages/tm-core/src/modules/auth/types.ts @@ -61,6 +61,30 @@ export interface CliData { timestamp?: number; } +/** + * MFA challenge information + */ +export interface MFAChallenge { + /** ID of the MFA factor that needs verification */ + factorId: string; + /** Type of MFA factor (e.g., 'totp') */ + factorType: string; +} + +/** + * Result of MFA verification with retry + */ +export interface MFAVerificationResult { + /** Whether verification was successful */ + success: boolean; + /** Number of attempts used */ + attemptsUsed: number; + /** Credentials if successful */ + credentials?: AuthCredentials; + /** Error code if failed */ + errorCode?: AuthErrorCode; +} + /** * Authentication error codes */ @@ -90,19 +114,27 @@ export type AuthErrorCode = | 'CODE_EXCHANGE_FAILED' | 'SESSION_SET_FAILED' | 'CODE_AUTH_FAILED' - | 'INVALID_CODE'; + | 'INVALID_CODE' + | 'MFA_REQUIRED' + | 'MFA_VERIFICATION_FAILED' + | 'INVALID_MFA_CODE'; /** * Authentication error class */ export class AuthenticationError extends Error { + /** Optional MFA challenge information when MFA is required */ + public mfaChallenge?: MFAChallenge; + constructor( message: string, public code: AuthErrorCode, - public cause?: unknown + public cause?: unknown, + mfaChallenge?: MFAChallenge ) { super(message); this.name = 'AuthenticationError'; + this.mfaChallenge = mfaChallenge; if (cause && cause instanceof Error) { this.stack = `${this.stack}\nCaused by: ${cause.stack}`; } diff --git a/packages/tm-core/src/modules/integration/clients/supabase-client.spec.ts b/packages/tm-core/src/modules/integration/clients/supabase-client.spec.ts new file mode 100644 index 00000000..1dcc8c28 --- /dev/null +++ b/packages/tm-core/src/modules/integration/clients/supabase-client.spec.ts @@ -0,0 +1,546 @@ +/** + * Tests for SupabaseAuthClient + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Session } from '@supabase/supabase-js'; + +// Mock logger +const mockLogger = { + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + error: vi.fn() +}; + +vi.mock('../../../common/logger/index.js', () => ({ + getLogger: () => mockLogger +})); + +// Mock SupabaseSessionStorage +vi.mock('../../auth/services/supabase-session-storage.js', () => ({ + SupabaseSessionStorage: class { + clear() {} + getItem() { + return null; + } + setItem() {} + removeItem() {} + } +})); + +// Import after mocking (synchronous imports) +import { SupabaseAuthClient } from './supabase-client.js'; +import { AuthenticationError } from '../../auth/types.js'; + +describe('SupabaseAuthClient', () => { + let authClient: InstanceType; + let mockSupabaseClient: any; + + beforeEach(() => { + // Set required environment variables + process.env.TM_SUPABASE_URL = 'https://test.supabase.co'; + process.env.TM_SUPABASE_ANON_KEY = 'test-anon-key'; + + authClient = new SupabaseAuthClient(); + + // Create mock Supabase client + mockSupabaseClient = { + auth: { + getSession: vi.fn(), + setSession: vi.fn(), + signOut: vi.fn(), + refreshSession: vi.fn(), + getUser: vi.fn(), + signInWithOAuth: vi.fn(), + exchangeCodeForSession: vi.fn(), + verifyOtp: vi.fn(), + mfa: { + challenge: vi.fn(), + verify: vi.fn(), + getAuthenticatorAssuranceLevel: vi.fn(), + listFactors: vi.fn() + } + } + }; + + vi.clearAllMocks(); + }); + + describe('verifyMFA', () => { + it('should verify MFA and refresh session to upgrade to AAL2', async () => { + // Mock the challenge response + const mockChallenge = { + data: { id: 'challenge-123' }, + error: null + }; + + // Mock the MFA verification response + const mockVerifyResponse = { + data: { + access_token: 'temp-token', + refresh_token: 'temp-refresh' + }, + error: null + }; + + // Mock the refreshed session with AAL2 + const mockSession: Session = { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + expires_at: Date.now() + 3600, + token_type: 'bearer', + user: { + id: 'user-123', + email: 'test@example.com', + app_metadata: {}, + user_metadata: {}, + aud: 'authenticated', + created_at: new Date().toISOString() + } + }; + + const mockRefreshSessionResponse = { + data: { session: mockSession }, + error: null + }; + + // Setup mocks + mockSupabaseClient.auth.mfa.challenge.mockResolvedValue(mockChallenge); + mockSupabaseClient.auth.mfa.verify.mockResolvedValue(mockVerifyResponse); + mockSupabaseClient.auth.refreshSession.mockResolvedValue( + mockRefreshSessionResponse + ); + + // Override getClient to return our mock + (authClient as any).client = mockSupabaseClient; + + // Execute + const result = await authClient.verifyMFA('factor-123', '123456'); + + // Verify refreshSession was called after MFA verification + expect(mockSupabaseClient.auth.refreshSession).toHaveBeenCalled(); + + // Verify the returned session + expect(result).toEqual(mockSession); + }); + + it('should throw AuthenticationError when MFA challenge fails', async () => { + const mockChallenge = { + data: null, + error: { message: 'Challenge failed' } + }; + + mockSupabaseClient.auth.mfa.challenge.mockResolvedValue(mockChallenge); + (authClient as any).client = mockSupabaseClient; + + const error = await authClient + .verifyMFA('factor-123', '123456') + .catch((e) => e); + expect(error).toBeInstanceOf(AuthenticationError); + expect(error.code).toBe('MFA_VERIFICATION_FAILED'); + }); + + it('should throw AuthenticationError when MFA verification fails', async () => { + const mockChallenge = { + data: { id: 'challenge-123' }, + error: null + }; + + const mockVerifyResponse = { + data: null, + error: { message: 'Invalid code' } + }; + + mockSupabaseClient.auth.mfa.challenge.mockResolvedValue(mockChallenge); + mockSupabaseClient.auth.mfa.verify.mockResolvedValue(mockVerifyResponse); + (authClient as any).client = mockSupabaseClient; + + const error = await authClient + .verifyMFA('factor-123', '123456') + .catch((e) => e); + expect(error).toBeInstanceOf(AuthenticationError); + expect(error.code).toBe('INVALID_MFA_CODE'); + }); + + it('should throw AuthenticationError when refreshSession fails', async () => { + const mockChallenge = { + data: { id: 'challenge-123' }, + error: null + }; + + const mockVerifyResponse = { + data: { + access_token: 'temp-token', + refresh_token: 'temp-refresh' + }, + error: null + }; + + const mockRefreshSessionResponse = { + data: { session: null }, + error: { message: 'Refresh failed' } + }; + + mockSupabaseClient.auth.mfa.challenge.mockResolvedValue(mockChallenge); + mockSupabaseClient.auth.mfa.verify.mockResolvedValue(mockVerifyResponse); + mockSupabaseClient.auth.refreshSession.mockResolvedValue( + mockRefreshSessionResponse + ); + (authClient as any).client = mockSupabaseClient; + + const error = await authClient + .verifyMFA('factor-123', '123456') + .catch((e) => e); + expect(error).toBeInstanceOf(AuthenticationError); + expect(error.code).toBe('REFRESH_FAILED'); + }); + + it('should throw AuthenticationError when refreshSession returns no session', async () => { + const mockChallenge = { + data: { id: 'challenge-123' }, + error: null + }; + + const mockVerifyResponse = { + data: { + access_token: 'temp-token', + refresh_token: 'temp-refresh' + }, + error: null + }; + + const mockRefreshSessionResponse = { + data: { session: null }, + error: null + }; + + mockSupabaseClient.auth.mfa.challenge.mockResolvedValue(mockChallenge); + mockSupabaseClient.auth.mfa.verify.mockResolvedValue(mockVerifyResponse); + mockSupabaseClient.auth.refreshSession.mockResolvedValue( + mockRefreshSessionResponse + ); + (authClient as any).client = mockSupabaseClient; + + const error = await authClient + .verifyMFA('factor-123', '123456') + .catch((e) => e); + expect(error).toBeInstanceOf(AuthenticationError); + expect(error.message).toBe( + 'Failed to refresh session after MFA: No session returned' + ); + }); + + it('should throw AuthenticationError when MFA verification returns no data', async () => { + const mockChallenge = { + data: { id: 'challenge-123' }, + error: null + }; + + const mockVerifyResponse = { + data: null, + error: null + }; + + mockSupabaseClient.auth.mfa.challenge.mockResolvedValue(mockChallenge); + mockSupabaseClient.auth.mfa.verify.mockResolvedValue(mockVerifyResponse); + (authClient as any).client = mockSupabaseClient; + + const error = await authClient + .verifyMFA('factor-123', '123456') + .catch((e) => e); + expect(error).toBeInstanceOf(AuthenticationError); + expect(error.code).toBe('INVALID_RESPONSE'); + expect(error.message).toBe('No data returned from MFA verification'); + }); + }); + + describe('checkMFARequired', () => { + it('should return required: true when user has verified MFA factors but is at AAL1', async () => { + const mockSession: Session = { + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 3600, + expires_at: Date.now() + 3600, + token_type: 'bearer', + user: { + id: 'user-123', + email: 'test@example.com', + app_metadata: {}, + user_metadata: {}, + aud: 'authenticated', + created_at: new Date().toISOString() + } + }; + + const mockGetSessionResponse = { + data: { session: mockSession }, + error: null + }; + + const mockAALResponse = { + data: { currentLevel: 'aal1', nextLevel: 'aal2' }, + error: null + }; + + const mockFactorsResponse = { + data: { + totp: [ + { + id: 'factor-123', + status: 'verified', + factor_type: 'totp' + } + ] + }, + error: null + }; + + mockSupabaseClient.auth.getSession.mockResolvedValue( + mockGetSessionResponse + ); + mockSupabaseClient.auth.mfa.getAuthenticatorAssuranceLevel.mockResolvedValue( + mockAALResponse + ); + mockSupabaseClient.auth.mfa.listFactors.mockResolvedValue( + mockFactorsResponse + ); + (authClient as any).client = mockSupabaseClient; + + const result = await authClient.checkMFARequired(); + + expect(result).toEqual({ + required: true, + factorId: 'factor-123', + factorType: 'totp' + }); + }); + + it('should return required: false when session is already at AAL2', async () => { + const mockSession: Session = { + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 3600, + expires_at: Date.now() + 3600, + token_type: 'bearer', + user: { + id: 'user-123', + email: 'test@example.com', + app_metadata: {}, + user_metadata: {}, + aud: 'authenticated', + created_at: new Date().toISOString() + } + }; + + const mockGetSessionResponse = { + data: { session: mockSession }, + error: null + }; + + const mockAALResponse = { + data: { currentLevel: 'aal2' }, + error: null + }; + + mockSupabaseClient.auth.getSession.mockResolvedValue( + mockGetSessionResponse + ); + mockSupabaseClient.auth.mfa.getAuthenticatorAssuranceLevel.mockResolvedValue( + mockAALResponse + ); + (authClient as any).client = mockSupabaseClient; + + const result = await authClient.checkMFARequired(); + + expect(result).toEqual({ required: false }); + }); + + it('should return required: false when user has no verified MFA factors', async () => { + const mockSession: Session = { + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 3600, + expires_at: Date.now() + 3600, + token_type: 'bearer', + user: { + id: 'user-123', + email: 'test@example.com', + app_metadata: {}, + user_metadata: {}, + aud: 'authenticated', + created_at: new Date().toISOString() + } + }; + + const mockGetSessionResponse = { + data: { session: mockSession }, + error: null + }; + + const mockAALResponse = { + data: { currentLevel: 'aal1' }, + error: null + }; + + const mockFactorsResponse = { + data: { totp: [] }, + error: null + }; + + mockSupabaseClient.auth.getSession.mockResolvedValue( + mockGetSessionResponse + ); + mockSupabaseClient.auth.mfa.getAuthenticatorAssuranceLevel.mockResolvedValue( + mockAALResponse + ); + mockSupabaseClient.auth.mfa.listFactors.mockResolvedValue( + mockFactorsResponse + ); + (authClient as any).client = mockSupabaseClient; + + const result = await authClient.checkMFARequired(); + + expect(result).toEqual({ required: false }); + }); + + it('should return required: false when getSession fails', async () => { + const mockGetSessionResponse = { + data: { session: null }, + error: { message: 'Session error' } + }; + + mockSupabaseClient.auth.getSession.mockResolvedValue( + mockGetSessionResponse + ); + (authClient as any).client = mockSupabaseClient; + + const result = await authClient.checkMFARequired(); + + expect(result).toEqual({ required: false }); + }); + + it('should return required: false when getAuthenticatorAssuranceLevel fails', async () => { + const mockSession: Session = { + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 3600, + expires_at: Date.now() + 3600, + token_type: 'bearer', + user: { + id: 'user-123', + email: 'test@example.com', + app_metadata: {}, + user_metadata: {}, + aud: 'authenticated', + created_at: new Date().toISOString() + } + }; + + const mockGetSessionResponse = { + data: { session: mockSession }, + error: null + }; + + const mockAALResponse = { + data: null, + error: { message: 'AAL error' } + }; + + mockSupabaseClient.auth.getSession.mockResolvedValue( + mockGetSessionResponse + ); + mockSupabaseClient.auth.mfa.getAuthenticatorAssuranceLevel.mockResolvedValue( + mockAALResponse + ); + (authClient as any).client = mockSupabaseClient; + + const result = await authClient.checkMFARequired(); + + expect(result).toEqual({ required: false }); + }); + + it('should return required: false when listFactors fails', async () => { + const mockSession: Session = { + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 3600, + expires_at: Date.now() + 3600, + token_type: 'bearer', + user: { + id: 'user-123', + email: 'test@example.com', + app_metadata: {}, + user_metadata: {}, + aud: 'authenticated', + created_at: new Date().toISOString() + } + }; + + const mockGetSessionResponse = { + data: { session: mockSession }, + error: null + }; + + const mockAALResponse = { + data: { currentLevel: 'aal1', nextLevel: 'aal2' }, + error: null + }; + + const mockFactorsResponse = { + data: null, + error: { message: 'Factors error' } + }; + + mockSupabaseClient.auth.getSession.mockResolvedValue( + mockGetSessionResponse + ); + mockSupabaseClient.auth.mfa.getAuthenticatorAssuranceLevel.mockResolvedValue( + mockAALResponse + ); + mockSupabaseClient.auth.mfa.listFactors.mockResolvedValue( + mockFactorsResponse + ); + (authClient as any).client = mockSupabaseClient; + + const result = await authClient.checkMFARequired(); + + expect(result).toEqual({ required: false }); + }); + }); + + describe('signOut', () => { + it('should sign out with local scope', async () => { + const mockSignOutResponse = { + error: null + }; + + mockSupabaseClient.auth.signOut.mockResolvedValue(mockSignOutResponse); + (authClient as any).client = mockSupabaseClient; + + await authClient.signOut(); + + expect(mockSupabaseClient.auth.signOut).toHaveBeenCalledWith({ + scope: 'local' + }); + }); + + it('should handle signOut errors gracefully', async () => { + const mockSignOutResponse = { + error: { message: 'Sign out failed' } + }; + + mockSupabaseClient.auth.signOut.mockResolvedValue(mockSignOutResponse); + (authClient as any).client = mockSupabaseClient; + + // signOut should not throw errors - it handles them gracefully + await expect(authClient.signOut()).resolves.not.toThrow(); + + // Verify signOut was still called + expect(mockSupabaseClient.auth.signOut).toHaveBeenCalledWith({ + scope: 'local' + }); + }); + }); +}); diff --git a/packages/tm-core/src/modules/integration/clients/supabase-client.ts b/packages/tm-core/src/modules/integration/clients/supabase-client.ts index 046f2ea0..f779634c 100644 --- a/packages/tm-core/src/modules/integration/clients/supabase-client.ts +++ b/packages/tm-core/src/modules/integration/clients/supabase-client.ts @@ -3,9 +3,9 @@ */ import { - Session, - SupabaseClient as SupabaseJSClient, - User, + type Session, + type SupabaseClient as SupabaseJSClient, + type User, createClient } from '@supabase/supabase-js'; import { getLogger } from '../../../common/logger/index.js'; @@ -266,8 +266,8 @@ export class SupabaseAuthClient { const client = this.getClient(); try { - // Sign out with global scope to revoke all refresh tokens - const { error } = await client.auth.signOut({ scope: 'global' }); + // Sign out with local scope to clear only this device's session + const { error } = await client.auth.signOut({ scope: 'local' }); if (error) { this.logger.warn('Failed to sign out:', error); @@ -357,4 +357,151 @@ export class SupabaseAuthClient { ); } } + + /** + * Check if MFA is required for the current session + * @returns Object with required=true and factor details if MFA is required, + * or required=false if session is already at AAL2 or no MFA is configured + */ + async checkMFARequired(): Promise<{ + required: boolean; + factorId?: string; + factorType?: string; + }> { + const client = this.getClient(); + + try { + // Get the current session + const { + data: { session }, + error: sessionError + } = await client.auth.getSession(); + + if (sessionError || !session) { + this.logger.warn('No session available to check MFA'); + return { required: false }; + } + + // Check the current Authentication Assurance Level (AAL) + // AAL1 = basic authentication (password/oauth) + // AAL2 = MFA verified + const { data: aalData, error: aalError } = + await client.auth.mfa.getAuthenticatorAssuranceLevel(); + + if (aalError) { + this.logger.warn('Failed to get AAL:', aalError); + return { required: false }; + } + + // If already at AAL2, MFA is not required + if (aalData?.currentLevel === 'aal2') { + this.logger.info('Session already at AAL2, MFA not required'); + return { required: false }; + } + + // Get MFA factors for this user + const { data: factors, error: factorsError } = + await client.auth.mfa.listFactors(); + + if (factorsError) { + this.logger.warn('Failed to list MFA factors:', factorsError); + return { required: false }; + } + + // Check if user has any verified MFA factors + const verifiedFactors = factors?.totp?.filter( + (factor) => factor.status === 'verified' + ); + + if (!verifiedFactors || verifiedFactors.length === 0) { + this.logger.info('No verified MFA factors found'); + return { required: false }; + } + + // MFA is required - user has MFA enabled but session is only at AAL1 + const factor = verifiedFactors[0]; // Use the first verified factor + this.logger.info('MFA verification required', { + factorId: factor.id, + factorType: factor.factor_type + }); + + return { + required: true, + factorId: factor.id, + factorType: factor.factor_type + }; + } catch (error) { + this.logger.error('Error checking MFA requirement:', error); + return { required: false }; + } + } + + /** + * Verify MFA code and upgrade session to AAL2 + */ + async verifyMFA(factorId: string, code: string): Promise { + const client = this.getClient(); + + try { + this.logger.info('Verifying MFA code...'); + + // Create MFA challenge + const { data: challengeData, error: challengeError } = + await client.auth.mfa.challenge({ factorId }); + + if (challengeError || !challengeData) { + throw new AuthenticationError( + `Failed to create MFA challenge: ${challengeError?.message || 'Unknown error'}`, + 'MFA_VERIFICATION_FAILED' + ); + } + + // Verify the TOTP code + const { data, error } = await client.auth.mfa.verify({ + factorId, + challengeId: challengeData.id, + code + }); + + if (error) { + this.logger.error('MFA verification failed:', error); + throw new AuthenticationError( + `Invalid MFA code: ${error.message}`, + 'INVALID_MFA_CODE' + ); + } + + if (!data) { + throw new AuthenticationError( + 'No data returned from MFA verification', + 'INVALID_RESPONSE' + ); + } + + // After successful MFA verification, refresh the session to get the upgraded AAL2 session + const { + data: { session }, + error: refreshError + } = await client.auth.refreshSession(); + + if (refreshError || !session) { + throw new AuthenticationError( + `Failed to refresh session after MFA: ${refreshError?.message || 'No session returned'}`, + 'REFRESH_FAILED' + ); + } + + this.logger.info('Successfully verified MFA, session upgraded to AAL2'); + return session; + } catch (error) { + if (error instanceof AuthenticationError) { + throw error; + } + + throw new AuthenticationError( + `MFA verification failed: ${(error as Error).message}`, + 'MFA_VERIFICATION_FAILED' + ); + } + } } From 9f6f3affe322512a8708624850c144b4b890e782 Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Mon, 24 Nov 2025 21:19:20 +0100 Subject: [PATCH 4/7] feat: update supported models with new opus versions and correct swe scores for existing models (#1440) --- .changeset/dry-wombats-marry.md | 5 ++++ .changeset/nine-worlds-deny.md | 5 ++++ scripts/modules/supported-models.json | 34 +++++++++++++++++++++++---- 3 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 .changeset/dry-wombats-marry.md create mode 100644 .changeset/nine-worlds-deny.md diff --git a/.changeset/dry-wombats-marry.md b/.changeset/dry-wombats-marry.md new file mode 100644 index 00000000..c1030166 --- /dev/null +++ b/.changeset/dry-wombats-marry.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Correct swe scores of haiku 4.5 and sonnet 4.5 diff --git a/.changeset/nine-worlds-deny.md b/.changeset/nine-worlds-deny.md new file mode 100644 index 00000000..88fc655b --- /dev/null +++ b/.changeset/nine-worlds-deny.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": minor +--- + +Add support for opus 4.1 and opus 4.5 anthropic models diff --git a/scripts/modules/supported-models.json b/scripts/modules/supported-models.json index 5733b057..26959d8e 100644 --- a/scripts/modules/supported-models.json +++ b/scripts/modules/supported-models.json @@ -45,8 +45,9 @@ "supported": true }, { - "id": "claude-sonnet-4-5-20250929", - "swe_score": 0.73, + "id": "claude-sonnet-4-5", + "name": "Claude Sonnet 4.5", + "swe_score": 0.772, "cost_per_1m_tokens": { "input": 3.0, "output": 15.0 @@ -56,8 +57,9 @@ "supported": true }, { - "id": "claude-haiku-4-5-20251001", - "swe_score": 0.45, + "id": "claude-haiku-4-5", + "name": "Claude Haiku 4.5", + "swe_score": 0.733, "cost_per_1m_tokens": { "input": 1.0, "output": 5.0 @@ -65,6 +67,30 @@ "allowed_roles": ["main", "fallback"], "max_tokens": 200000, "supported": true + }, + { + "id": "claude-opus-4-1", + "name": "Claude Opus 4.1", + "swe_score": 0.745, + "cost_per_1m_tokens": { + "input": 15.0, + "output": 75.0 + }, + "allowed_roles": ["main", "fallback"], + "max_tokens": 32000, + "supported": true + }, + { + "id": "claude-opus-4-5", + "name": "Claude Opus 4.5", + "swe_score": 0.809, + "cost_per_1m_tokens": { + "input": 5.0, + "output": 25.0 + }, + "allowed_roles": ["main", "fallback"], + "max_tokens": 32000, + "supported": true } ], "claude-code": [ From 72e2c399d431faed88dc485a41b9ca72642febd7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Nov 2025 20:19:33 +0000 Subject: [PATCH 5/7] docs: Auto-update and format models.md --- docs/models.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/models.md b/docs/models.md index 6b1b3e0b..3944d15a 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,4 +1,4 @@ -# Available Models as of November 18, 2025 +# Available Models as of November 24, 2025 ## Main Models @@ -8,8 +8,10 @@ | anthropic | claude-opus-4-20250514 | 0.725 | 15 | 75 | | anthropic | claude-3-7-sonnet-20250219 | 0.623 | 3 | 15 | | anthropic | claude-3-5-sonnet-20241022 | 0.49 | 3 | 15 | -| anthropic | claude-sonnet-4-5-20250929 | 0.73 | 3 | 15 | -| anthropic | claude-haiku-4-5-20251001 | 0.45 | 1 | 5 | +| anthropic | claude-sonnet-4-5 | 0.772 | 3 | 15 | +| anthropic | claude-haiku-4-5 | 0.733 | 1 | 5 | +| anthropic | claude-opus-4-1 | 0.745 | 15 | 75 | +| anthropic | claude-opus-4-5 | 0.809 | 5 | 25 | | claude-code | opus | 0.725 | 0 | 0 | | claude-code | sonnet | 0.727 | 0 | 0 | | claude-code | haiku | 0.45 | 0 | 0 | @@ -162,8 +164,10 @@ | anthropic | claude-opus-4-20250514 | 0.725 | 15 | 75 | | anthropic | claude-3-7-sonnet-20250219 | 0.623 | 3 | 15 | | anthropic | claude-3-5-sonnet-20241022 | 0.49 | 3 | 15 | -| anthropic | claude-sonnet-4-5-20250929 | 0.73 | 3 | 15 | -| anthropic | claude-haiku-4-5-20251001 | 0.45 | 1 | 5 | +| anthropic | claude-sonnet-4-5 | 0.772 | 3 | 15 | +| anthropic | claude-haiku-4-5 | 0.733 | 1 | 5 | +| anthropic | claude-opus-4-1 | 0.745 | 15 | 75 | +| anthropic | claude-opus-4-5 | 0.809 | 5 | 25 | | claude-code | opus | 0.725 | 0 | 0 | | claude-code | sonnet | 0.727 | 0 | 0 | | claude-code | haiku | 0.45 | 0 | 0 | From 0195feffeed6b782e768b347aa61cb08070042e1 Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Mon, 24 Nov 2025 21:53:14 +0100 Subject: [PATCH 6/7] feat: enhance MFA retry logic with configurable options (#1441) --- apps/cli/src/commands/auth.command.ts | 12 +- .../auth/managers/auth-manager.spec.ts | 149 ++++++++++++++++-- .../src/modules/auth/managers/auth-manager.ts | 23 ++- 3 files changed, 169 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/commands/auth.command.ts b/apps/cli/src/commands/auth.command.ts index d2293fc1..83a68aea 100644 --- a/apps/cli/src/commands/auth.command.ts +++ b/apps/cli/src/commands/auth.command.ts @@ -603,7 +603,7 @@ Examples: error.name === 'ExitPromptError' || error.message?.includes('force closed') ) { - ui.displayWarning('\nMFA verification cancelled by user'); + ui.displayWarning(' MFA verification cancelled by user'); throw new AuthenticationError( 'MFA verification cancelled', 'MFA_VERIFICATION_FAILED' @@ -612,7 +612,15 @@ Examples: throw error; } }, - 3 // Max attempts + { + maxAttempts: 3, + onInvalidCode: (_attempt: number, remaining: number) => { + // Callback invoked when invalid code is entered + if (remaining > 0) { + ui.displayError(`Invalid MFA code. Please try again.`); + } + } + } ); // Handle result from core diff --git a/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts b/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts index c69b5928..ae22d17c 100644 --- a/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts +++ b/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts @@ -195,7 +195,7 @@ describe('AuthManager - MFA Retry Logic', () => { const result = await authManager.verifyMFAWithRetry( 'factor-123', codeProvider, - 3 + { maxAttempts: 3 } ); expect(result.success).toBe(true); @@ -232,7 +232,7 @@ describe('AuthManager - MFA Retry Logic', () => { const result = await authManager.verifyMFAWithRetry( 'factor-123', codeProvider, - 3 + { maxAttempts: 3 } ); expect(result.success).toBe(true); @@ -254,7 +254,7 @@ describe('AuthManager - MFA Retry Logic', () => { const result = await authManager.verifyMFAWithRetry( 'factor-123', codeProvider, - 3 + { maxAttempts: 3 } ); expect(result.success).toBe(false); @@ -276,7 +276,9 @@ describe('AuthManager - MFA Retry Logic', () => { vi.spyOn(authManager, 'verifyMFA').mockRejectedValue(networkError); await expect( - authManager.verifyMFAWithRetry('factor-123', codeProvider, 3) + authManager.verifyMFAWithRetry('factor-123', codeProvider, { + maxAttempts: 3 + }) ).rejects.toThrow('Network error'); // Should not retry on non-INVALID_MFA_CODE errors @@ -295,7 +297,7 @@ describe('AuthManager - MFA Retry Logic', () => { const result = await authManager.verifyMFAWithRetry( 'factor-123', codeProvider, - 5 // Custom max attempts + { maxAttempts: 5 } // Custom max attempts ); expect(result.success).toBe(false); @@ -311,7 +313,7 @@ describe('AuthManager - MFA Retry Logic', () => { new AuthenticationError('Invalid MFA code', 'INVALID_MFA_CODE') ); - // Don't pass maxAttempts - should default to 3 + // Don't pass options - should default to 3 const result = await authManager.verifyMFAWithRetry( 'factor-123', codeProvider @@ -328,24 +330,151 @@ describe('AuthManager - MFA Retry Logic', () => { // Test with 0 await expect( - authManager.verifyMFAWithRetry('factor-123', codeProvider, 0) + authManager.verifyMFAWithRetry('factor-123', codeProvider, { + maxAttempts: 0 + }) ).rejects.toThrow(TypeError); await expect( - authManager.verifyMFAWithRetry('factor-123', codeProvider, 0) + authManager.verifyMFAWithRetry('factor-123', codeProvider, { + maxAttempts: 0 + }) ).rejects.toThrow('Invalid maxAttempts value: 0. Must be at least 1.'); // Test with negative await expect( - authManager.verifyMFAWithRetry('factor-123', codeProvider, -1) + authManager.verifyMFAWithRetry('factor-123', codeProvider, { + maxAttempts: -1 + }) ).rejects.toThrow(TypeError); await expect( - authManager.verifyMFAWithRetry('factor-123', codeProvider, -1) + authManager.verifyMFAWithRetry('factor-123', codeProvider, { + maxAttempts: -1 + }) ).rejects.toThrow('Invalid maxAttempts value: -1. Must be at least 1.'); // Verify code provider was never called expect(codeProvider).not.toHaveBeenCalled(); }); + + it('should invoke onInvalidCode callback when invalid code is entered', async () => { + const authManager = AuthManager.getInstance(); + const codeProvider = vi.fn(async () => '000000'); + const onInvalidCode = vi.fn(); + + // Mock verification to always fail + vi.spyOn(authManager, 'verifyMFA').mockRejectedValue( + new AuthenticationError('Invalid MFA code', 'INVALID_MFA_CODE') + ); + + await authManager.verifyMFAWithRetry('factor-123', codeProvider, { + maxAttempts: 3, + onInvalidCode + }); + + // Should be called 3 times (after each failed attempt) + expect(onInvalidCode).toHaveBeenCalledTimes(3); + + // Verify callback arguments: (attempt, remaining) + expect(onInvalidCode).toHaveBeenNthCalledWith(1, 1, 2); // 1st attempt, 2 remaining + expect(onInvalidCode).toHaveBeenNthCalledWith(2, 2, 1); // 2nd attempt, 1 remaining + expect(onInvalidCode).toHaveBeenNthCalledWith(3, 3, 0); // 3rd attempt, 0 remaining + }); + + it('should not invoke onInvalidCode callback on successful verification', async () => { + const authManager = AuthManager.getInstance(); + const codeProvider = vi.fn(async () => '123456'); + const onInvalidCode = vi.fn(); + + // Mock successful verification + vi.spyOn(authManager, 'verifyMFA').mockResolvedValue({ + token: 'test-token', + userId: 'test-user', + email: 'test@example.com', + tokenType: 'standard', + savedAt: new Date().toISOString() + }); + + const result = await authManager.verifyMFAWithRetry( + 'factor-123', + codeProvider, + { + maxAttempts: 3, + onInvalidCode + } + ); + + expect(result.success).toBe(true); + expect(onInvalidCode).not.toHaveBeenCalled(); + }); + + it('should work without onInvalidCode callback (backward compatibility)', async () => { + const authManager = AuthManager.getInstance(); + const codeProvider = vi.fn(async () => '123456'); + + // Mock successful verification + vi.spyOn(authManager, 'verifyMFA').mockResolvedValue({ + token: 'test-token', + userId: 'test-user', + email: 'test@example.com', + tokenType: 'standard', + savedAt: new Date().toISOString() + }); + + // Call without onInvalidCode - should not throw + const result = await authManager.verifyMFAWithRetry( + 'factor-123', + codeProvider, + { + maxAttempts: 3 + } + ); + + expect(result.success).toBe(true); + }); + + it('should invoke onInvalidCode with correct remaining attempts', async () => { + const authManager = AuthManager.getInstance(); + let attemptCount = 0; + const codeProvider = vi.fn(async () => { + attemptCount++; + return `code-${attemptCount}`; + }); + const onInvalidCode = vi.fn(); + + // Fail twice, then succeed + vi.spyOn(authManager, 'verifyMFA') + .mockRejectedValueOnce( + new AuthenticationError('Invalid MFA code', 'INVALID_MFA_CODE') + ) + .mockRejectedValueOnce( + new AuthenticationError('Invalid MFA code', 'INVALID_MFA_CODE') + ) + .mockResolvedValueOnce({ + token: 'test-token', + userId: 'test-user', + email: 'test@example.com', + tokenType: 'standard', + savedAt: new Date().toISOString() + }); + + const result = await authManager.verifyMFAWithRetry( + 'factor-123', + codeProvider, + { + maxAttempts: 3, + onInvalidCode + } + ); + + expect(result.success).toBe(true); + expect(result.attemptsUsed).toBe(3); + + // Verify callback was called for the first two failed attempts + expect(onInvalidCode).toHaveBeenCalledTimes(2); + expect(onInvalidCode).toHaveBeenNthCalledWith(1, 1, 2); // 1st attempt, 2 remaining + expect(onInvalidCode).toHaveBeenNthCalledWith(2, 2, 1); // 2nd attempt, 1 remaining + }); }); }); diff --git a/packages/tm-core/src/modules/auth/managers/auth-manager.ts b/packages/tm-core/src/modules/auth/managers/auth-manager.ts index 7b3af187..f295cff9 100644 --- a/packages/tm-core/src/modules/auth/managers/auth-manager.ts +++ b/packages/tm-core/src/modules/auth/managers/auth-manager.ts @@ -130,7 +130,7 @@ export class AuthManager { * * @param factorId - MFA factor ID from the MFA_REQUIRED error * @param codeProvider - Function that prompts for and returns the MFA code - * @param maxAttempts - Maximum number of verification attempts (default: 3) + * @param options - Optional configuration for retry behavior * @returns Result object with success status, attempts used, and credentials if successful * * @example @@ -138,7 +138,10 @@ export class AuthManager { * const result = await authManager.verifyMFAWithRetry( * factorId, * async () => await promptUserForMFACode(), - * 3 + * { + * maxAttempts: 3, + * onInvalidCode: (attempt, remaining) => console.log(`Invalid code. ${remaining} attempts remaining.`) + * } * ); * * if (result.success) { @@ -151,8 +154,14 @@ export class AuthManager { async verifyMFAWithRetry( factorId: string, codeProvider: () => Promise, - maxAttempts = 3 + options?: { + maxAttempts?: number; + onInvalidCode?: (attempt: number, remaining: number) => void; + } ): Promise { + const maxAttempts = options?.maxAttempts ?? 3; + const onInvalidCode = options?.onInvalidCode; + // Guard against invalid maxAttempts values if (maxAttempts < 1) { throw new TypeError( @@ -175,6 +184,14 @@ export class AuthManager { error instanceof AuthenticationError && error.code === 'INVALID_MFA_CODE' ) { + // Calculate remaining attempts + const remaining = maxAttempts - attempt; + + // Notify callback of invalid code + if (onInvalidCode) { + onInvalidCode(attempt, remaining); + } + // If we've exhausted attempts, return failure if (attempt >= maxAttempts) { return { From 01d4d9930f57f638301752771b81a5dc99e033f5 Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Mon, 24 Nov 2025 23:00:13 +0100 Subject: [PATCH 7/7] chore: apply requested changes (#1442) --- CLAUDE.md | 1 + apps/cli/src/commands/auth.command.ts | 6 +++-- .../auth/managers/auth-manager.spec.ts | 8 +++++-- .../src/modules/auth/managers/auth-manager.ts | 8 +++++-- .../auth/services/session-manager.spec.ts | 17 +++++++------- .../modules/auth/services/session-manager.ts | 7 ++++-- .../clients/supabase-client.spec.ts | 23 +++++++++++++++++++ 7 files changed, 53 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ec4c6dd3..58f3dec9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -177,3 +177,4 @@ Apply standard software engineering principles: - **Add a changeset for code changes** - Run `npx changeset` after making code changes (not needed for docs-only PRs) - When creating changesets, remember that it's user-facing, meaning we don't have to get into the specifics of the code, but rather mention what the end-user is getting or fixing from this changeset - Run `npm run turbo:typecheck` before pushing to ensure TypeScript type checks pass +- Run `npm run test -w ` to test a package diff --git a/apps/cli/src/commands/auth.command.ts b/apps/cli/src/commands/auth.command.ts index 83a68aea..30c14542 100644 --- a/apps/cli/src/commands/auth.command.ts +++ b/apps/cli/src/commands/auth.command.ts @@ -551,13 +551,15 @@ Examples: private async handleMFAVerification( mfaError: AuthenticationError ): Promise { - if (!mfaError.mfaChallenge) { + if (!mfaError.mfaChallenge?.factorId) { throw new AuthenticationError( 'MFA challenge information missing', 'MFA_VERIFICATION_FAILED' ); } + const { factorId } = mfaError.mfaChallenge; + console.log( chalk.yellow( '\nāš ļø Multi-factor authentication is enabled on your account' @@ -571,7 +573,7 @@ Examples: // Use @tm/core's retry logic - presentation layer just handles UI const result = await this.authManager.verifyMFAWithRetry( - mfaError.mfaChallenge.factorId, + factorId, async () => { // Prompt for MFA code try { diff --git a/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts b/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts index ae22d17c..ee7e41af 100644 --- a/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts +++ b/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts @@ -339,7 +339,9 @@ describe('AuthManager - MFA Retry Logic', () => { authManager.verifyMFAWithRetry('factor-123', codeProvider, { maxAttempts: 0 }) - ).rejects.toThrow('Invalid maxAttempts value: 0. Must be at least 1.'); + ).rejects.toThrow( + 'Invalid maxAttempts value: 0. Must be a positive integer.' + ); // Test with negative await expect( @@ -352,7 +354,9 @@ describe('AuthManager - MFA Retry Logic', () => { authManager.verifyMFAWithRetry('factor-123', codeProvider, { maxAttempts: -1 }) - ).rejects.toThrow('Invalid maxAttempts value: -1. Must be at least 1.'); + ).rejects.toThrow( + 'Invalid maxAttempts value: -1. Must be a positive integer.' + ); // Verify code provider was never called expect(codeProvider).not.toHaveBeenCalled(); diff --git a/packages/tm-core/src/modules/auth/managers/auth-manager.ts b/packages/tm-core/src/modules/auth/managers/auth-manager.ts index f295cff9..38f12b02 100644 --- a/packages/tm-core/src/modules/auth/managers/auth-manager.ts +++ b/packages/tm-core/src/modules/auth/managers/auth-manager.ts @@ -163,9 +163,13 @@ export class AuthManager { const onInvalidCode = options?.onInvalidCode; // Guard against invalid maxAttempts values - if (maxAttempts < 1) { + if ( + !Number.isFinite(maxAttempts) || + !Number.isInteger(maxAttempts) || + maxAttempts < 1 + ) { throw new TypeError( - `Invalid maxAttempts value: ${maxAttempts}. Must be at least 1.` + `Invalid maxAttempts value: ${maxAttempts}. Must be a positive integer.` ); } diff --git a/packages/tm-core/src/modules/auth/services/session-manager.spec.ts b/packages/tm-core/src/modules/auth/services/session-manager.spec.ts index a38072fe..9e0b7d25 100644 --- a/packages/tm-core/src/modules/auth/services/session-manager.spec.ts +++ b/packages/tm-core/src/modules/auth/services/session-manager.spec.ts @@ -397,17 +397,16 @@ describe('SessionManager', () => { mockContextStore as any ); - try { - await sessionManager.authenticateWithCode('test-code'); - expect.fail('Should have thrown MFA_REQUIRED error'); - } catch (error) { - expect(error).toBeInstanceOf(AuthenticationError); - expect((error as AuthenticationError).code).toBe('MFA_REQUIRED'); - expect((error as AuthenticationError).mfaChallenge).toEqual({ + const promise = sessionManager.authenticateWithCode('test-code'); + + await expect(promise).rejects.toThrow(AuthenticationError); + await expect(promise).rejects.toMatchObject({ + code: 'MFA_REQUIRED', + mfaChallenge: { factorId: 'factor-123', factorType: 'totp' - }); - } + } + }); }); }); diff --git a/packages/tm-core/src/modules/auth/services/session-manager.ts b/packages/tm-core/src/modules/auth/services/session-manager.ts index f3d9bea6..085c4005 100644 --- a/packages/tm-core/src/modules/auth/services/session-manager.ts +++ b/packages/tm-core/src/modules/auth/services/session-manager.ts @@ -42,7 +42,10 @@ export class SessionManager { await this.migrateLegacyAuth(); } catch (error) { // Log but don't throw - initialization errors are handled gracefully - this.logger.debug('Session initialization completed with warnings'); + this.logger.debug( + 'Session initialization completed with warnings', + error + ); } } @@ -102,7 +105,7 @@ export class SessionManager { await this.waitForInitialization(); try { const session = await this.supabaseClient.getSession(); - return session !== null; + return session != null; } catch { return false; } diff --git a/packages/tm-core/src/modules/integration/clients/supabase-client.spec.ts b/packages/tm-core/src/modules/integration/clients/supabase-client.spec.ts index 1dcc8c28..28344f42 100644 --- a/packages/tm-core/src/modules/integration/clients/supabase-client.spec.ts +++ b/packages/tm-core/src/modules/integration/clients/supabase-client.spec.ts @@ -37,7 +37,15 @@ describe('SupabaseAuthClient', () => { let authClient: InstanceType; let mockSupabaseClient: any; + // Store original env values for cleanup + let originalSupabaseUrl: string | undefined; + let originalSupabaseAnonKey: string | undefined; + beforeEach(() => { + // Store original values + originalSupabaseUrl = process.env.TM_SUPABASE_URL; + originalSupabaseAnonKey = process.env.TM_SUPABASE_ANON_KEY; + // Set required environment variables process.env.TM_SUPABASE_URL = 'https://test.supabase.co'; process.env.TM_SUPABASE_ANON_KEY = 'test-anon-key'; @@ -67,6 +75,21 @@ describe('SupabaseAuthClient', () => { vi.clearAllMocks(); }); + afterEach(() => { + // Restore original env values + if (originalSupabaseUrl === undefined) { + delete process.env.TM_SUPABASE_URL; + } else { + process.env.TM_SUPABASE_URL = originalSupabaseUrl; + } + + if (originalSupabaseAnonKey === undefined) { + delete process.env.TM_SUPABASE_ANON_KEY; + } else { + process.env.TM_SUPABASE_ANON_KEY = originalSupabaseAnonKey; + } + }); + describe('verifyMFA', () => { it('should verify MFA and refresh session to upgrade to AAL2', async () => { // Mock the challenge response