Skill: okikesolutions/plan2meal (version 1.2.5)

--- name: plan2meal description: Manage recipes and grocery lists in Plan2Meal via chat (add recipe URLs, list/search/show/delete recipes, and create/manage grocery lists). Use when users explicitly want Plan2Meal recipe/grocery actions. Do not use for generic cooking advice, nutrition coaching, or non-Plan2Meal todo/shopping tools. Success = command executed with clear result text (IDs, counts, links/errors) and accurate data-routing disclosure. requiredEnv: - CONVEX_URL - AUTH_GITHUB_ID - AUTH_GITHUB_SECRET - GITHUB_CALLBACK_URL - CLAWDBOT_URL optionalEnv: - AUTH_GOOGLE_ID - AUTH_GOOGLE_SECRET - GOOGLE_CALLBACK_URL - AUTH_APPLE_ID - AUTH_APPLE_SECRET - APPLE_CALLBACK_URL - ALLOW_DEFAULT_BACKEND primaryEnv: CONVEX_URL --- # Plan2Meal Skill A ClawdHub skill for managing recipes and grocery lists via Plan2Meal. ## Data routing + security disclosure (mandatory) - Shared backend/API target: `https://gallant-bass-875.convex.cloud` - Auth and recipe/grocery API traffic goes to configured `CONVEX_URL`. - Shared backend usage is blocked unless `ALLOW_DEFAULT_BACKEND=true`. - Never claim local-only processing when backend calls are involved. ## Use when / don't use when (routing guardrails) Use when: - User asks to add/list/search/show/delete Plan2Meal recipes. - User asks to create/view/update Plan2Meal grocery lists. - User asks to authenticate for Plan2Meal commands. Do NOT use when: - User asks for general meal ideas (no Plan2Meal action requested). - User asks for health/nutrition analysis outside stored Plan2Meal data. - User asks to manage Apple Reminders/Notes/other systems. ## Setup 1. Install: ```bash clawdhub install plan2meal ``` 2. Configure environment: ```bash cp .env.example .env ``` 3. Environment variables: - `CONVEX_URL` (**required**, recommended self-hosted backend) - `ALLOW_DEFAULT_BACKEND=true` (only if intentionally using shared backend) - OAuth provider creds: - GitHub (required): `AUTH_GITHUB_ID`, `AUTH_GITHUB_SECRET`, `GITHUB_CALLBACK_URL` - Google (optional): `AUTH_GOOGLE_ID`, `AUTH_GOOGLE_SECRET`, `GOOGLE_CALLBACK_URL` - Apple (optional): `AUTH_APPLE_ID`, `AUTH_APPLE_SECRET`, `APPLE_CALLBACK_URL` - `CLAWDBOT_URL` (required, bot callback host) ## Commands - `plan2meal login` - `plan2meal logout` - `plan2meal add <url>` - `plan2meal list` - `plan2meal search <term>` - `plan2meal show <id>` - `plan2meal delete <id>` - `plan2meal lists` - `plan2meal list-show <id>` - `plan2meal list-create <name>` - `plan2meal list-add <listId> <recipeId>` - `plan2meal help` ## Output templates Use response formatting templates from `references/output-templates.md`. ## Security checklist before replying - If command failed due to auth/config, provide exact next step. - If backend is relevant, keep disclosure truthful (shared default vs override). - Never expose secrets/tokens in output.

path: README.md
content: # Plan2Meal ClawdHub Skill

Manage recipes and grocery lists from your Plan2Meal app via chat.

## Data routing disclosure

- API target is `CONVEX_URL`.
- Shared default backend is `https://gallant-bass-875.convex.cloud`.
- Shared backend is **blocked by default** unless `ALLOW_DEFAULT_BACKEND=true`.

## Quick Start

```bash
# Install via ClawdHub
clawdhub install plan2meal

# Configure environment
cp .env.example .env
# Set CONVEX_URL and OAuth credentials
```

Required baseline env:
- `CONVEX_URL`
- `AUTH_GITHUB_ID`, `AUTH_GITHUB_SECRET`, `GITHUB_CALLBACK_URL`
- `CLAWDBOT_URL`

Optional providers:
- Google: `AUTH_GOOGLE_ID`, `AUTH_GOOGLE_SECRET`, `GOOGLE_CALLBACK_URL`
- Apple: `AUTH_APPLE_ID`, `AUTH_APPLE_SECRET`, `APPLE_CALLBACK_URL`

## Commands

### Recipes
- `plan2meal add <url>` - Add recipe from URL
- `plan2meal list` - List your recipes
- `plan2meal search <term>` - Search recipes
- `plan2meal show <id>` - View recipe details
- `plan2meal delete <id>` - Delete recipe

### Grocery Lists
- `plan2meal lists` - List all grocery lists
- `plan2meal list-show <id>` - View list with items
- `plan2meal list-create <name>` - Create new list
- `plan2meal list-add <listId> <recipeId>` - Add recipe to list

### Help
- `plan2meal help` - Show all commands

## Setup

See [SKILL.md](SKILL.md) for detailed setup instructions.


path: eslint.config.mjs
content: import eslint from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
  {
    ignores: ["dist/", "node_modules/", "*.js", "!eslint.config.js"],
  },
  {
    files: ["src/**/*.ts"],
    languageOptions: {
      parserOptions: {
        project: "./tsconfig.json",
      },
    },
    rules: {
      "@typescript-eslint/no-unused-vars": [
        "error",
        { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
      ],
      "@typescript-eslint/no-explicit-any": "warn",
      "@typescript-eslint/consistent-type-imports": "error",
      "no-console": "off",
    },
  }
);


path: package.json
content: {
  "name": "plan2meal-skill",
  "version": "1.2.5",
  "description": "ClawdHub skill for Plan2Meal recipe and grocery list management",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "lint": "eslint src/",
    "lint:fix": "eslint src/ --fix",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": ["clawdhub", "skill", "recipe", "meal-planner", "grocery"],
  "author": "",
  "license": "MIT",
  "dependencies": {
    "axios": "^1.6.2"
  },
  "devDependencies": {
    "@eslint/js": "^9.17.0",
    "@types/node": "^20.10.0",
    "eslint": "^9.17.0",
    "typescript": "^5.3.0",
    "typescript-eslint": "^8.18.0"
  }
}


path: references/output-templates.md
content: # Plan2Meal Output Templates

Use these concise formats.

## Success: recipe added

✅ Recipe added successfully!

📖 **<Recipe Name>**
🔗 Source: <domain>
⚙️ Method: `<method>`
⏰ Scraped at: <time>

🥘 **Ingredients** (<count>)
• <item>
• <item>

## Success: list recipes

📚 **Your Recipes** (<total> total)

• `<id>` - <name>
• `<id>` - <name>

## Success: search

🔍 **Search Results** for "<term>" (<count>)

• `<id>` - <name>
• `<id>` - <name>

## Success: list created

✅ Grocery list created!

🛒 **<name>**
ID: `<id>`

## Error: auth required

❌ You're not authenticated for Plan2Meal.
Run: `plan2meal login`

## Error: invalid URL

❌ Invalid URL. Please provide a valid recipe URL.

## Error: backend/config

❌ Plan2Meal backend is unreachable or misconfigured.
Default backend is `https://gallant-bass-875.convex.cloud` unless `CONVEX_URL` is overridden.


path: src/commands.ts
content: /**
 * Command Handlers for Plan2Meal Skill
 */

import type { ConvexClient } from './convex';
import type { RecipeMetadata, Recipe, GroceryList } from './types';
import { markdownEscape, formatTime } from './utils';

/**
 * OAuth Providers interface
 */
interface OAuthProviders {
  github: unknown;
  google: unknown;
  apple: unknown;
}

/**
 * Skill configuration interface
 */
interface SkillConfig {
  convexUrl: string;
  githubClientId: string;
  githubClientSecret: string;
  googleClientId: string;
  googleClientSecret: string;
  appleClientId: string;
  appleClientSecret: string;
  clawdbotUrl: string;
}

/**
 * Command Handlers for Plan2Meal Skill
 */
export class Plan2MealCommands {
  private convex: ConvexClient;
  private oauth: OAuthProviders;
  private config: SkillConfig;

  constructor(convexClient: ConvexClient, oauthProviders: OAuthProviders, config: SkillConfig) {
    this.convex = convexClient;
    this.oauth = oauthProviders;
    this.config = config;
  }

  /**
   * Show help
   */
  help(): { text: string } {
    return {
      text: `📚 **Plan2Meal Commands**

**Authentication**
\`plan2meal login\` - Authenticate with GitHub, Google, or Apple
\`plan2meal logout\` - Logout and clear session

**Recipes**
\`plan2meal add <url>\` - Fetch recipe from URL and add it
\`plan2meal list\` - List your recent recipes
\`plan2meal search <term>\` - Search your recipes
\`plan2meal show <id>\` - Show recipe details
\`plan2meal delete <id>\` - Delete a recipe

**Grocery Lists**
\`plan2meal lists\` - List your grocery lists
\`plan2meal list-create <name>\` - Create a new grocery list
\`plan2meal list-show <id>\` - Show grocery list with items
\`plan2meal list-add <listId> <recipeId>\` - Add recipe to grocery list

**Examples**
\`plan2meal add https://www.allrecipes.com/recipe/12345/pasta\`
\`plan2meal search pasta\`
\`plan2meal list-create Weekly Shopping\`
`
    };
  }

  /**
   * Add recipe from URL
   */
  async addRecipe(url: string): Promise<{ text: string }> {
    // Fetch metadata from URL
    const extractionResult = await this.convex.fetchRecipeMetadata(url);

    if (!extractionResult.success) {
      return {
        text: `❌ Failed to extract recipe from URL: ${extractionResult.error || 'Unknown error'}`
      };
    }

    const { metadata, scrapeMethod, scrapedAt } = extractionResult;

    // Create recipe in Convex
    await this.convex.createRecipe({
      name: metadata.name || 'Untitled Recipe',
      url: url,
      ingredients: metadata.ingredients || [],
      steps: metadata.steps || [],
      calories: metadata.calories || null,
      servings: metadata.servings || 2,
      prepTime: metadata.prepTime || null,
      cookTime: metadata.cookTime || null,
      difficulty: metadata.difficulty || 'medium',
      cuisine: metadata.cuisine || '',
      tags: metadata.tags || [],
      nutritionInfo: metadata.nutritionInfo || null,
      localization: metadata.localization || { language: 'en', region: 'US' }
    });

    const timestamp = new Date(scrapedAt).toLocaleTimeString();
    const methodEmoji = scrapeMethod === 'native-fetch-json' ? '⚡' : 
                       scrapeMethod === 'firecrawl-json' ? '🔥' : 
                       scrapeMethod === 'gpt-5-nano' ? '🤖' : '⚠️';

    return {
      text: `✅ Recipe added successfully!\n\n📖 **${markdownEscape(metadata.name || 'Untitled Recipe')}**\n` +
            `🔗 Source: ${new URL(url).hostname}\n` +
            `${methodEmoji} Method: \`${scrapeMethod}\`\n` +
            `⏰ Scraped at: ${timestamp}\n\n` +
            this.formatRecipePreview(metadata)
    };
  }

  /**
   * List user's recipes
   */
  async listRecipes(): Promise<{ text: string }> {
    const recipes = await this.convex.getMyRecipes();

    if (!recipes || recipes.length === 0) {
      return { text: '📭 You have no recipes yet. Add one with `plan2meal add <url>`' };
    }

    // Show most recent first
    const sorted = [...recipes].reverse().slice(0, 10);

    let text = `📚 **Your Recipes** (${recipes.length} total)\n\n`;
    for (const recipe of sorted) {
      const time = formatTime(recipe.prepTime, recipe.cookTime);
      text += `• \`${recipe._id}\` - ${markdownEscape(recipe.name)}\n`;
      if (time) text += `  └─ ${time}\n`;
    }

    return { text };
  }

  /**
   * Search recipes
   */
  async searchRecipes(term: string): Promise<{ text: string }> {
    const recipes = await this.convex.searchRecipes(term);

    if (!recipes || recipes.length === 0) {
      return { text: `🔍 No recipes found for "${markdownEscape(term)}"` };
    }

    let text = `🔍 **Search Results** for "${markdownEscape(term)}" (${recipes.length})\n\n`;
    for (const recipe of recipes.slice(0, 10)) {
      text += `• \`${recipe._id}\` - ${markdownEscape(recipe.name)}\n`;
    }

    return { text };
  }

  /**
   * Show recipe details
   */
  async showRecipe(id: string): Promise<{ text: string }> {
    const recipe = await this.convex.getRecipeById(id);

    if (!recipe) {
      return { text: `❌ Recipe not found: \`${id}\`` };
    }

    return {
      text: this.formatRecipe(recipe)
    };
  }

  /**
   * Delete recipe
   */
  async deleteRecipe(id: string): Promise<{ text: string }> {
    try {
      await this.convex.deleteRecipe(id);
      return { text: `✅ Recipe deleted: \`${id}\`` };
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error';
      return { text: `❌ Failed to delete recipe: ${message}` };
    }
  }

  /**
   * List grocery lists
   */
  async lists(): Promise<{ text: string }> {
    const lists = await this.convex.getMyLists();

    if (!lists || lists.length === 0) {
      return { text: '📭 You have no grocery lists. Create one with `plan2meal list-create <name>`' };
    }

    let text = `🛒 **Your Grocery Lists** (${lists.length})\n\n`;
    for (const list of lists) {
      text += `• \`${list._id}\` - ${markdownEscape(list.name)}`;
      if (list.isCompleted) text += ' ✅';
      text += '\n';
    }

    return { text };
  }

  /**
   * Show grocery list
   */
  async showList(id: string): Promise<{ text: string }> {
    const list = await this.convex.getListById(id);

    if (!list) {
      return { text: `❌ Grocery list not found: \`${id}\`` };
    }

    return {
      text: this.formatGroceryList(list)
    };
  }

  /**
   * Create grocery list
   */
  async createList(name: string): Promise<{ text: string }> {
    const list = await this.convex.createGroceryList(name);
    return {
      text: `✅ Grocery list created!\n\n🛒 **${markdownEscape(name)}**\n` +
            `ID: \`${list}\``
    };
  }

  /**
   * Add recipe to grocery list
   */
  async addRecipeToList(listId: string, recipeId: string): Promise<{ text: string }> {
    try {
      await this.convex.addRecipeToList(listId, recipeId);
      return { text: `✅ Recipe added to grocery list!` };
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error';
      return { text: `❌ Failed to add recipe: ${message}` };
    }
  }

  /**
   * Format recipe preview
   */
  private formatRecipePreview(metadata: RecipeMetadata): string {
    let text = '';
    
    if (metadata.ingredients && metadata.ingredients.length > 0) {
      text += `🥘 **Ingredients** (${metadata.ingredients.length})\n`;
      text += metadata.ingredients.slice(0, 5).map(i => `• ${markdownEscape(i)}`).join('\n');
      if (metadata.ingredients.length > 5) {
        text += `\n  ...and ${metadata.ingredients.length - 5} more`;
      }
      text += '\n';
    }
    
    return text;
  }

  /**
   * Format full recipe
   */
  private formatRecipe(recipe: Recipe): string {
    let text = `📖 **${markdownEscape(recipe.name)}**\n`;
    text += '─'.repeat(30) + '\n';
    
    if (recipe.url) {
      try {
        text += `🔗 Source: ${new URL(recipe.url).hostname}\n`;
      } catch {
        text += `🔗 Source: ${markdownEscape(recipe.url)}\n`;
      }
    }
    
    const time = formatTime(recipe.prepTime, recipe.cookTime);
    if (time) text += `⏰ ${time}\n`;
    
    if (recipe.servings) text += `🍽️ ${recipe.servings} servings\n`;
    if (recipe.difficulty) text += `📊 Difficulty: ${recipe.difficulty}\n`;
    if (recipe.cuisine) text += `🌍 Cuisine: ${recipe.cuisine}\n`;
    
    text += '\n';
    
    // Ingredients
    if (recipe.ingredients && recipe.ingredients.length > 0) {
      text += `🥘 **Ingredients** (${recipe.ingredients.length})\n`;
      recipe.ingredients.forEach(i => text += `• ${markdownEscape(i)}\n`);
      text += '\n';
    }
    
    // Steps
    if (recipe.steps && recipe.steps.length > 0) {
      text += `🔪 **Instructions**\n`;
      recipe.steps.forEach((step, i) => text += `${i + 1}. ${markdownEscape(step)}\n`);
    }
    
    text += `\n🆔 \`${recipe._id}\``;
    
    return text;
  }

  /**
   * Format grocery list
   */
  private formatGroceryList(list: GroceryList): string {
    let text = `🛒 **${markdownEscape(list.name)}**\n`;
    text += '─'.repeat(30) + '\n';
    
    if (list.description) {
      text += `${markdownEscape(list.description)}\n\n`;
    }
    
    // Recipes
    if (list.recipes && list.recipes.length > 0) {
      text += `📖 **Recipes** (${list.recipes.length})\n`;
      list.recipes.forEach(r => text += `• ${markdownEscape(r.name)}\n`);
      text += '\n';
    }
    
    // Items
    if (list.items && list.items.length > 0) {
      const completed = list.items.filter(i => i.isCompleted).length;
      text += `🛍️ **Items** (${list.items.length} total, ${completed} completed)\n`;
      list.items.forEach(item => {
        const check = item.isCompleted ? '✅' : '⬜';
        let itemText = `${check} ${markdownEscape(item.ingredient)}`;
        if (item.quantity) itemText += ` (${item.quantity}${item.unit ? ' ' + item.unit : ''})`;
        text += `${itemText}\n`;
      });
    }
    
    text += `\n🆔 \`${list._id}\``;
    
    return text;
  }
}


path: src/convex.ts
content: /**
 * Convex API Client for Plan2Meal
 */

import type { AxiosInstance } from 'axios';
import axios from 'axios';
import type { RecipeMetadata, ExtractionResult, Recipe, GroceryList, GroceryListItem } from './types';

/**
 * Convex API Client for Plan2Meal
 */
export class ConvexClient {
  private convexUrl: string;
  private authToken: string;
  private provider: string;
  private httpClient: AxiosInstance;

  constructor(convexUrl: string, authToken: string, provider: string = 'github') {
    this.convexUrl = convexUrl;
    this.authToken = authToken;
    this.provider = provider;
    
    this.httpClient = axios.create({
      baseURL: convexUrl,
      headers: this.getAuthHeaders(),
    });
  }

  /**
   * Get authentication headers based on provider
   */
  private getAuthHeaders(): Record<string, string> {
    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
    };

    // Different providers use different auth mechanisms
    if (this.provider === 'github') {
      headers['Authorization'] = `Bearer ${this.authToken}`;
    } else if (this.provider === 'google') {
      headers['Authorization'] = `Bearer ${this.authToken}`;
    } else if (this.provider === 'apple') {
      headers['Authorization'] = `Bearer ${this.authToken}`;
    } else {
      headers['Authorization'] = `Bearer ${this.authToken}`;
    }

    return headers;
  }

  /**
   * Generic query executor
   */
  async query<T>(functionName: string, args: Record<string, unknown> = {}): Promise<T> {
    try {
      const response = await this.httpClient.post('/api/query', {
        functionName,
        args,
      });
      return response.data;
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error';
      console.error(`Convex query ${functionName} failed:`, message);
      throw new Error(`Query failed: ${message}`);
    }
  }

  /**
   * Generic mutation executor
   */
  async mutation<T>(functionName: string, args: Record<string, unknown> = {}): Promise<T> {
    try {
      const response = await this.httpClient.post('/api/mutation', {
        functionName,
        args,
      });
      return response.data;
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error';
      console.error(`Convex mutation ${functionName} failed:`, message);
      throw new Error(`Mutation failed: ${message}`);
    }
  }

  /**
   * Generic action executor
   */
  async action<T>(functionName: string, args: Record<string, unknown> = {}): Promise<T> {
    try {
      const response = await this.httpClient.post('/api/action', {
        functionName,
        args,
      });
      return response.data;
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error';
      console.error(`Convex action ${functionName} failed:`, message);
      throw new Error(`Action failed: ${message}`);
    }
  }

  // ==================== Recipes ====================

  async getAllRecipes(): Promise<Recipe[]> {
    return this.query<Recipe[]>('recipes.get');
  }

  async getMyRecipes(): Promise<Recipe[]> {
    return this.query<Recipe[]>('recipes.getMyRecipes');
  }

  async getRecipeById(id: string): Promise<Recipe | null> {
    return this.query<Recipe | null>('recipes.getById', { id });
  }

  async searchRecipes(term: string): Promise<Recipe[]> {
    return this.query<Recipe[]>('recipes.searchMyRecipes', { searchTerm: term });
  }

  async createRecipe(recipeData: RecipeMetadata): Promise<string> {
    return this.mutation<string>('recipes.create', recipeData as unknown as Record<string, unknown>);
  }

  async updateRecipe(id: string, updates: Partial<RecipeMetadata>): Promise<Recipe | null> {
    return this.mutation<Recipe | null>('recipes.update', { id, ...updates });
  }

  async deleteRecipe(id: string): Promise<void> {
    await this.mutation<void>('recipes.remove', { id });
  }

  // ==================== Recipe Extraction ====================

  async fetchRecipeMetadata(url: string): Promise<ExtractionResult> {
    return this.action<ExtractionResult>('recipeExtraction.fetchRecipeMetadata', { url });
  }

  // ==================== Grocery Lists ====================

  async getMyLists(): Promise<GroceryList[]> {
    return this.query<GroceryList[]>('groceryLists.getMyLists');
  }

  async getListById(id: string): Promise<GroceryList | null> {
    return this.query<GroceryList | null>('groceryLists.getById', { id });
  }

  async createGroceryList(name: string, description: string = ''): Promise<string> {
    return this.mutation<string>('groceryLists.create', { name, description });
  }

  async updateGroceryList(id: string, updates: Partial<GroceryList>): Promise<GroceryList | null> {
    return this.mutation<GroceryList | null>('groceryLists.update', { id, ...updates });
  }

  async deleteGroceryList(id: string): Promise<void> {
    await this.mutation<void>('groceryLists.remove', { id });
  }

  async addRecipeToList(groceryListId: string, recipeId: string, servings: number = 1): Promise<void> {
    await this.mutation<void>('groceryLists.addRecipeToList', { groceryListId, recipeId, servings });
  }

  async removeRecipeFromList(groceryListId: string, recipeId: string): Promise<void> {
    await this.mutation<void>('groceryLists.removeRecipeFromList', { groceryListId, recipeId });
  }

  async toggleItemCompleted(id: string, isCompleted: boolean): Promise<GroceryListItem | null> {
    return this.mutation<GroceryListItem | null>('groceryLists.toggleItemCompleted', { id, isCompleted });
  }

  async addCustomItem(
    groceryListId: string,
    ingredient: string,
    quantity?: string,
    unit?: string
  ): Promise<void> {
    await this.mutation<void>('groceryLists.addCustomItem', { groceryListId, ingredient, quantity, unit });
  }

  async removeItem(id: string): Promise<void> {
    await this.mutation<void>('groceryLists.removeItem', { id });
  }

  async regenerateListItems(groceryListId: string): Promise<void> {
    await this.mutation<void>('groceryLists.regenerateItems', { groceryListId });
  }
}


path: src/index.ts
content: /**
 * Plan2Meal ClawdHub Skill - Main Entry Point
 */

import axios from 'axios';
import { ConvexClient } from './convex';
import { Plan2MealCommands } from './commands';
import { isValidUrl, generateState } from './utils';
import type { SkillConfig, CommandPattern, AuthProvider, Session, UserInfo } from './types';

const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
const OAUTH_STATE_TTL_MS = 1000 * 60 * 10; // 10 minutes

interface OAuthState {
  provider: AuthProvider;
  createdAt: number;
}

const DEFAULT_CONVEX_URL = 'https://gallant-bass-875.convex.cloud';
const ALLOW_DEFAULT_BACKEND = process.env.ALLOW_DEFAULT_BACKEND === 'true';

let config: SkillConfig = {
  convexUrl: process.env.CONVEX_URL || DEFAULT_CONVEX_URL,
  githubClientId: process.env.AUTH_GITHUB_ID || process.env.GITHUB_CLIENT_ID || '',
  githubClientSecret: process.env.AUTH_GITHUB_SECRET || process.env.GITHUB_CLIENT_SECRET || '',
  githubCallbackUrl: process.env.GITHUB_CALLBACK_URL || '',
  googleClientId: process.env.AUTH_GOOGLE_ID || process.env.GOOGLE_CLIENT_ID || '',
  googleClientSecret: process.env.AUTH_GOOGLE_SECRET || process.env.GOOGLE_CLIENT_SECRET || '',
  googleCallbackUrl: process.env.GOOGLE_CALLBACK_URL || '',
  appleClientId: process.env.AUTH_APPLE_ID || process.env.APPLE_CLIENT_ID || '',
  appleClientSecret: process.env.AUTH_APPLE_SECRET || process.env.APPLE_CLIENT_SECRET || '',
  appleCallbackUrl: process.env.APPLE_CALLBACK_URL || '',
  clawdbotUrl: process.env.CLAWDBOT_URL || 'http://localhost:3010'
};

const sessionStore = new Map<string, Session>();
const oauthStateStore = new Map<string, OAuthState>();

function initialize(customConfig?: Partial<SkillConfig>): { name: string; version: string; commands: CommandPattern[] } {
  if (customConfig) {
    config = { ...config, ...customConfig };
  }

  return {
    name: 'plan2meal',
    version: '1.2.5',
    commands: getCommandPatterns()
  };
}

function getCommandPatterns(): CommandPattern[] {
  return [
    { pattern: /^plan2meal\s+login$/i, description: 'Login with GitHub, Google, or Apple' },
    { pattern: /^plan2meal\s+login\s+--github$/i, description: 'Login with GitHub' },
    { pattern: /^plan2meal\s+login\s+--google$/i, description: 'Login with Google' },
    { pattern: /^plan2meal\s+login\s+--apple$/i, description: 'Login with Apple' },
    { pattern: /^plan2meal\s+logout$/i, description: 'Logout and clear session' },
    { pattern: /^plan2meal\s+add\s+(.+)$/i, description: 'Add recipe from URL' },
    { pattern: /^plan2meal\s+list$/i, description: 'List your recipes' },
    { pattern: /^plan2meal\s+search\s+(.+)$/i, description: 'Search recipes' },
    { pattern: /^plan2meal\s+show\s+(.+)$/i, description: 'Show recipe details' },
    { pattern: /^plan2meal\s+delete\s+(.+)$/i, description: 'Delete a recipe' },
    { pattern: /^plan2meal\s+lists$/i, description: 'List grocery lists' },
    { pattern: /^plan2meal\s+list-show\s+(.+)$/i, description: 'Show grocery list' },
    { pattern: /^plan2meal\s+list-create\s+(.+)$/i, description: 'Create grocery list' },
    { pattern: /^plan2meal\s+list-add\s+(\S+)\s+(\S+)$/i, description: 'Add recipe to list' },
    { pattern: /^plan2meal\s+help$/i, description: 'Show help' }
  ];
}

function getConvexClient(authToken: string, provider: AuthProvider = 'github'): ConvexClient {
  return new ConvexClient(config.convexUrl, authToken, provider);
}

function getCommands(authToken: string, provider: AuthProvider = 'github'): Plan2MealCommands {
  const convex = getConvexClient(authToken, provider);
  return new Plan2MealCommands(convex, { github: null, google: null, apple: null }, config);
}

function getConfigIssue(): string | null {
  if (!config.convexUrl) {
    return 'Missing `CONVEX_URL`. Configure your Plan2Meal backend URL before using this skill.';
  }

  if (config.convexUrl === DEFAULT_CONVEX_URL && !ALLOW_DEFAULT_BACKEND) {
    return 'Default shared backend is blocked by default. Set `CONVEX_URL` to your own backend, or explicitly opt in with `ALLOW_DEFAULT_BACKEND=true`.';
  }

  return null;
}

function cleanupExpiredData(): void {
  const now = Date.now();

  for (const [sessionId, session] of sessionStore.entries()) {
    if (now - session.createdAt > SESSION_TTL_MS) {
      sessionStore.delete(sessionId);
    }
  }

  for (const [state, data] of oauthStateStore.entries()) {
    if (now - data.createdAt > OAUTH_STATE_TTL_MS) {
      oauthStateStore.delete(state);
    }
  }
}

function getSession(sessionId: string): Session | undefined {
  cleanupExpiredData();
  return sessionStore.get(sessionId);
}

async function handleMessage(message: string, context: { sessionId?: string; userId?: string } = {}): Promise<{ text: string; requiresAuth?: boolean }> {
  const sessionId = context.sessionId || 'default';
  const text = message.trim();

  cleanupExpiredData();

  if (text.startsWith('/oauth/callback') || text.includes('code=')) {
    return handleOAuthCallback(text, sessionId);
  }

  const configIssue = getConfigIssue();
  if (configIssue) {
    return { text: `❌ ${configIssue}` };
  }

  if (/^plan2meal\s+login/i.test(text)) {
    return routeCommand(text, '', 'github', sessionId);
  }

  const session = getSession(sessionId);
  if (!session?.authToken) {
    return showLoginOptions();
  }

  return routeCommand(text, session.authToken, session.provider, sessionId);
}

function createOAuthState(provider: AuthProvider): string {
  const state = `${provider}_${generateState()}`;
  oauthStateStore.set(state, { provider, createdAt: Date.now() });
  return state;
}

function showLoginOptions(): { text: string; requiresAuth: boolean } {
  let text = '🔐 **Plan2Meal Login**\n\nChoose your login method:\n\n';

  if (config.githubClientId && config.githubClientSecret && config.githubCallbackUrl) {
    const state = createOAuthState('github');
    const githubUrl = `https://github.com/login/oauth/authorize?client_id=${config.githubClientId}&redirect_uri=${encodeURIComponent(config.githubCallbackUrl)}&state=${state}&scope=read:user`;
    text += `🐙 **GitHub** - [Login with GitHub](${githubUrl})\n\n`;
  }

  if (config.googleClientId && config.googleClientSecret && config.googleCallbackUrl) {
    const state = createOAuthState('google');
    const googleUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${config.googleClientId}&redirect_uri=${encodeURIComponent(config.googleCallbackUrl)}&state=${state}&scope=openid email profile&response_type=code`;
    text += `🔵 **Google** - [Login with Google](${googleUrl})\n\n`;
  }

  if (config.appleClientId && config.appleClientSecret && config.appleCallbackUrl) {
    const state = createOAuthState('apple');
    const appleUrl = `https://appleid.apple.com/auth/authorize?client_id=${config.appleClientId}&redirect_uri=${encodeURIComponent(config.appleCallbackUrl)}&state=${state}&scope=name email&response_type=code&response_mode=form_post`;
    text += `🍎 **Apple** - [Login with Apple](${appleUrl})\n\n`;
  }

  text += '---\nUse OAuth login links above to authenticate.';

  if (config.convexUrl === DEFAULT_CONVEX_URL) {
    text += '\n\n⚠️ Data routing notice: you are using the shared default backend. For private/self-hosted mode, set `CONVEX_URL`.';
  }

  return { text, requiresAuth: false };
}

async function routeCommand(text: string, authToken: string, authProvider: AuthProvider, sessionId: string): Promise<{ text: string }> {
  if (/^plan2meal\s+logout$/i.test(text)) {
    clearSession(sessionId);
    return { text: '✅ You have been logged out. Use `plan2meal login` to authenticate again.' };
  }

  if (/^plan2meal\s+login$/i.test(text)) {
    return showLoginOptions();
  }

  if (/^plan2meal\s+login\s+--github$/i.test(text)) {
    if (!config.githubClientId || !config.githubClientSecret || !config.githubCallbackUrl) return { text: '❌ GitHub OAuth is not fully configured.' };
    const state = createOAuthState('github');
    const githubUrl = `https://github.com/login/oauth/authorize?client_id=${config.githubClientId}&redirect_uri=${encodeURIComponent(config.githubCallbackUrl)}&state=${state}&scope=read:user`;
    return { text: `🐙 **Login with GitHub**\n\n[Click here to authorize](${githubUrl})` };
  }

  if (/^plan2meal\s+login\s+--google$/i.test(text)) {
    if (!config.googleClientId || !config.googleClientSecret || !config.googleCallbackUrl) return { text: '❌ Google OAuth is not fully configured.' };
    const state = createOAuthState('google');
    const googleUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${config.googleClientId}&redirect_uri=${encodeURIComponent(config.googleCallbackUrl)}&state=${state}&scope=openid email profile&response_type=code`;
    return { text: `🔵 **Login with Google**\n\n[Click here to authorize](${googleUrl})` };
  }

  if (/^plan2meal\s+login\s+--apple$/i.test(text)) {
    if (!config.appleClientId || !config.appleClientSecret || !config.appleCallbackUrl) return { text: '❌ Apple OAuth is not fully configured.' };
    const state = createOAuthState('apple');
    const appleUrl = `https://appleid.apple.com/auth/authorize?client_id=${config.appleClientId}&redirect_uri=${encodeURIComponent(config.appleCallbackUrl)}&state=${state}&scope=name email&response_type=code&response_mode=form_post`;
    return { text: `🍎 **Login with Apple**\n\n[Click here to authorize](${appleUrl})` };
  }

  if (!authToken) {
    return showLoginOptions();
  }

  const cmd = getCommands(authToken, authProvider);

  const addMatch = text.match(/^plan2meal\s+add\s+(.+)$/i);
  if (addMatch) {
    const url = addMatch[1].trim();
    if (!isValidUrl(url)) return { text: '❌ Invalid URL. Please provide a valid recipe URL.' };
    return cmd.addRecipe(url);
  }

  if (/^plan2meal\s+list$/i.test(text)) return cmd.listRecipes();

  const searchMatch = text.match(/^plan2meal\s+search\s+(.+)$/i);
  if (searchMatch) return cmd.searchRecipes(searchMatch[1].trim());

  const showMatch = text.match(/^plan2meal\s+show\s+(.+)$/i);
  if (showMatch) return cmd.showRecipe(showMatch[1].trim());

  const deleteMatch = text.match(/^plan2meal\s+delete\s+(.+)$/i);
  if (deleteMatch) return cmd.deleteRecipe(deleteMatch[1].trim());

  if (/^plan2meal\s+lists$/i.test(text)) return cmd.lists();

  const listShowMatch = text.match(/^plan2meal\s+list-show\s+(.+)$/i);
  if (listShowMatch) return cmd.showList(listShowMatch[1].trim());

  const listCreateMatch = text.match(/^plan2meal\s+list-create\s+(.+)$/i);
  if (listCreateMatch) return cmd.createList(listCreateMatch[1].trim());

  const listAddMatch = text.match(/^plan2meal\s+list-add\s+(\S+)\s+(\S+)$/i);
  if (listAddMatch) return cmd.addRecipeToList(listAddMatch[1].trim(), listAddMatch[2].trim());

  if (/^plan2meal\s+help$/i.test(text)) return cmd.help();

  return { text: '❌ Unknown command. Type `plan2meal help` for available commands.' };
}

async function exchangeCodeForAccessToken(provider: AuthProvider, code: string): Promise<string> {
  if (provider === 'github') {
    const response = await axios.post(
      'https://github.com/login/oauth/access_token',
      {
        client_id: config.githubClientId,
        client_secret: config.githubClientSecret,
        code,
        redirect_uri: config.githubCallbackUrl
      },
      { headers: { Accept: 'application/json' } }
    );

    const [REDACTED_SECRET];
    if (!token) throw new Error(response.data?.error_description || 'GitHub token exchange failed');
    return token;
  }

  if (provider === 'google') {
    const params = new URLSearchParams({
      code,
      client_id: config.googleClientId,
      client_secret: config.googleClientSecret,
      redirect_uri: config.googleCallbackUrl,
      grant_type: 'authorization_code'
    });

    const response = await axios.post('https://oauth2.googleapis.com/token', params.toString(), {
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
    });

    const [REDACTED_SECRET];
    if (!token) throw new Error('Google token exchange failed');
    return token;
  }

  const params = new URLSearchParams({
    client_id: config.appleClientId,
    client_secret: config.appleClientSecret,
    code,
    grant_type: 'authorization_code',
    redirect_uri: config.appleCallbackUrl
  });

  const response = await axios.post('https://appleid.apple.com/auth/token', params.toString(), {
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
  });

  const [REDACTED_SECRET];
  if (!token) throw new Error('Apple token exchange failed');
  return token;
}

async function fetchUserInfo(provider: AuthProvider, [REDACTED_SECRET]): Promise<UserInfo | null> {
  try {
    if (provider === 'github') {
      const r = await axios.get('https://api.github.com/user', {
        headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' }
      });
      return {
        id: String(r.data.id),
        name: r.data.name || r.data.login,
        email: r.data.email || undefined,
        login: r.data.login,
        avatarUrl: r.data.avatar_url || undefined
      };
    }
  } catch {
    return null;
  }

  return null;
}

async function handleOAuthCallback(text: string, sessionId: string): Promise<{ text: string }> {
  let callbackUrl: URL;

  try {
    callbackUrl = new URL(text.startsWith('http') ? text : `http://localhost${text}`);
  } catch {
    return { text: '❌ Invalid callback URL.' };
  }

  const code = callbackUrl.searchParams.get('code');
  const state = callbackUrl.searchParams.get('state') || '';

  if (!code) return { text: '❌ No authorization code found.' };

  const stateEntry = oauthStateStore.get(state);
  if (!stateEntry) return { text: '❌ Invalid or expired OAuth state. Please run `plan2meal login` again.' };

  oauthStateStore.delete(state);

  if (Date.now() - stateEntry.createdAt > OAUTH_STATE_TTL_MS) {
    return { text: '❌ OAuth session expired. Please run `plan2meal login` again.' };
  }

  try {
    const accessToken = await exchangeCodeForAccessToken(stateEntry.provider, code);
    const userInfo = await fetchUserInfo(stateEntry.provider, accessToken);

    sessionStore.set(sessionId, {
      authToken: accessToken,
      provider: stateEntry.provider,
      userInfo,
      createdAt: Date.now()
    });

    const providerName = stateEntry.provider.charAt(0).toUpperCase() + stateEntry.provider.slice(1);
    const userLabel = userInfo?.name || userInfo?.email || 'Authenticated user';

    return {
      text: `✅ Successfully authenticated with **${providerName}** as **${userLabel}**!\n\nYou can now use Plan2Meal commands. Type \`plan2meal help\` to get started.`
    };
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    return { text: `❌ Authentication failed: ${message}` };
  }
}

function clearSession(sessionId: string): void {
  sessionStore.delete(sessionId);
}

module.exports = {
  initialize,
  handleMessage,
  clearSession,
  getSession,
  getCommandPatterns
};


path: src/types.ts
content: /**
 * Types for Plan2Meal Skill
 */

// ==================== Recipe Types ====================

export interface RecipeMetadata {
  name: string;
  description?: string;
  url?: string;
  ingredients: string[];
  steps: string[];
  prepTime?: string | null;
  cookTime?: string | null;
  servings?: number | null;
  calories?: number | null;
  difficulty?: string | null;
  cuisine?: string | null;
  tags?: string[] | null;
  nutritionInfo?: NutritionInfo | null;
  localization?: Localization | null;
}

export interface NutritionInfo {
  protein: number;
  carbs: number;
  fat: number;
}

export interface Localization {
  language: string;
  region: string;
}

export interface Recipe extends RecipeMetadata {
  _id: string;
  createdBy: string;
  createdAt: number;
  updatedAt: number;
}

// ==================== Grocery List Types ====================

export interface GroceryList {
  _id: string;
  name: string;
  description?: string;
  createdBy: string;
  createdAt: number;
  updatedAt: number;
  isCompleted: boolean;
  items?: GroceryListItem[];
  recipes?: Recipe[];
}

export interface GroceryListItem {
  _id: string;
  groceryListId: string;
  ingredient: string;
  quantity?: string;
  unit?: string;
  isCompleted: boolean;
  sourceRecipes?: SourceRecipe[];
  createdAt: number;
  updatedAt: number;
}

export interface SourceRecipe {
  recipeId: string;
  recipeName: string;
  originalIngredient: string;
}

// ==================== Extraction Result Types ====================

export interface ExtractionResult {
  success: boolean;
  error?: string;
  metadata: RecipeMetadata;
  sourceUrl: string;
  scrapeMethod: ScrapeMethod;
  scrapedAt: number;
}

export type ScrapeMethod = 
  | 'native-fetch-json'
  | 'firecrawl-json'
  | 'gpt-5-nano'
  | 'fallback';

// ==================== Auth Types ====================

export interface UserInfo {
  id: string;
  name: string;
  email?: string;
  login: string;
  avatarUrl?: string;
}

export interface Session {
  authToken: string;
  provider: AuthProvider;
  userInfo: UserInfo | null;
  createdAt: number;
}

export type AuthProvider = 'github' | 'google' | 'apple';

// ==================== Config Types ====================

export interface SkillConfig {
  convexUrl: string;
  githubClientId: string;
  githubClientSecret: string;
  githubCallbackUrl: string;
  googleClientId: string;
  googleClientSecret: string;
  googleCallbackUrl: string;
  appleClientId: string;
  appleClientSecret: string;
  appleCallbackUrl: string;
  clawdbotUrl: string;
}

// ==================== Command Types ====================

export interface CommandHandlerResult {
  text: string;
  requiresAuth?: boolean;
}

export interface CommandPattern {
  pattern: RegExp;
  description: string;
}


path: src/utils.ts
content: /**
 * Utility functions for Plan2Meal skill
 */

/**
 * Escape markdown special characters
 */
export function markdownEscape(text: string): string {
  if (!text) return '';
  const str = String(text);
  return str
    .replace(/[_*[\]()~`>#+=|{}.!-]/g, '\\$&')
    .replace(/\n/g, ' ');
}

/**
 * Format prep and cook time
 */
export function formatTime(prepTime?: string | null, cookTime?: string | null): string | null {
  const parts: string[] = [];
  if (prepTime) parts.push(`prep: ${prepTime}`);
  if (cookTime) parts.push(`cook: ${cookTime}`);
  return parts.length > 0 ? parts.join(' | ') : null;
}

/**
 * Validate URL
 */
export function isValidUrl(string: string): boolean {
  try {
    new URL(string);
    return true;
  } catch {
    return false;
  }
}

/**
 * Generate a random state string for OAuth
 */
export function generateState(): string {
  return Math.random().toString(36).substring(2, 15) + 
         Math.random().toString(36).substring(2, 15);
}

/**
 * Truncate text with ellipsis
 */
export function truncate(text: string, maxLength: number = 100): string {
  if (!text) return '';
  const str = String(text);
  if (str.length <= maxLength) return str;
  return str.slice(0, maxLength - 3) + '...';
}

/**
 * Parse recipe ID from various formats
 */
export function parseRecipeId(input: string): string {
  // Handle URL format: https://.../recipe/123
  if (input.includes('/')) {
    const parts = input.split('/');
    return parts[parts.length - 1];
  }
  return input.trim();
}

/**
 * Sleep for specified milliseconds
 */
export function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}


path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (HIGH)