文件预览

picnic-cli.mjs

查看 Picnic Grocery 技能包中的文件内容。

文件内容

picnic-cli.mjs

#!/usr/bin/env node
/**
 * Picnic CLI - Wrapper for the picnic-api npm package
 * Usage: node picnic-cli.mjs <command> [args]
 */

import PicnicClient from 'picnic-api';
import fs from 'fs';
import path from 'path';
import os from 'os';

const CONFIG_PATH = path.join(os.homedir(), '.config', 'picnic', 'config.json');

function loadConfig() {
  try {
    if (fs.existsSync(CONFIG_PATH)) {
      return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
    }
  } catch (e) {
    // ignore
  }
  return {};
}

function saveConfig(config) {
  const dir = path.dirname(CONFIG_PATH);
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
}

function getClient() {
  const config = loadConfig();
  if (!config.authKey) {
    console.error(JSON.stringify({ error: 'Not logged in. Run: picnic login <email> <password>' }));
    process.exit(1);
  }
  return new PicnicClient({
    countryCode: config.countryCode || 'DE',
    authKey: config.authKey
  });
}

function output(data) {
  console.log(JSON.stringify(data, null, 2));
}

async function main() {
  const [,, command, ...args] = process.argv;

  try {
    switch (command) {
      case 'login': {
        const [email, password, countryCode = 'DE'] = args;
        if (!email || !password) {
          output({ error: 'Usage: picnic login <email> <password> [countryCode=DE]' });
          process.exit(1);
        }
        const client = new PicnicClient({ countryCode });
        const result = await client.login(email, password);
        
        if (result.second_factor_authentication_required) {
          // Save partial config, user needs to verify 2FA
          saveConfig({ 
            authKey: result.authKey, 
            countryCode,
            needs2FA: true 
          });
          output({ 
            status: '2fa_required',
            message: 'Two-factor authentication required. Run: picnic verify-2fa <code>',
            authKey: result.authKey
          });
        } else {
          saveConfig({ authKey: result.authKey, countryCode });
          output({ status: 'ok', message: 'Logged in successfully' });
        }
        break;
      }

      case 'verify-2fa': {
        const [code] = args;
        if (!code) {
          output({ error: 'Usage: picnic verify-2fa <code>' });
          process.exit(1);
        }
        const client = getClient();
        const result = await client.verify2FACode(code);
        const config = loadConfig();
        delete config.needs2FA;
        saveConfig(config);
        output({ status: 'ok', message: '2FA verified successfully' });
        break;
      }

      case 'send-2fa': {
        const [channel = 'SMS'] = args;
        const client = getClient();
        await client.generate2FACode(channel);
        output({ status: 'ok', message: `2FA code sent via ${channel}` });
        break;
      }

      case 'status': {
        const config = loadConfig();
        if (config.authKey) {
          const client = getClient();
          try {
            const user = await client.getUserDetails();
            output({ 
              status: 'logged_in',
              user: {
                firstName: user.firstname,
                lastName: user.lastname,
                email: user.contact_email,
                address: user.address
              }
            });
          } catch (e) {
            output({ status: 'error', message: 'Auth key expired, please login again' });
          }
        } else {
          output({ status: 'not_logged_in' });
        }
        break;
      }

      case 'search': {
        const query = args.join(' ');
        if (!query) {
          output({ error: 'Usage: picnic search <query>' });
          process.exit(1);
        }
        const client = getClient();
        const results = await client.search(query);
        output({
          query,
          count: results.length,
          products: results.slice(0, 20).map(p => ({
            id: p.id,
            name: p.name,
            price: p.display_price,
            unit: p.unit_quantity,
            available: p.max_count > 0
          }))
        });
        break;
      }

      case 'cart': {
        const client = getClient();
        const cart = await client.getShoppingCart();
        output({
          itemCount: cart.total_count,
          totalPrice: cart.total_price,
          items: (cart.items || []).map(item => ({
            id: item.items?.[0]?.id,
            name: item.items?.[0]?.name,
            quantity: item.items?.[0]?.decorators?.find(d => d.type === 'QUANTITY')?.quantity,
            price: item.items?.[0]?.display_price
          })).filter(i => i.id)
        });
        break;
      }

      case 'add': {
        const [productId, count = '1'] = args;
        if (!productId) {
          output({ error: 'Usage: picnic add <productId> [count=1]' });
          process.exit(1);
        }
        const client = getClient();
        const cart = await client.addProductToShoppingCart(productId, parseInt(count, 10));
        output({ 
          status: 'ok', 
          message: `Added ${count}x product to cart`,
          totalItems: cart.total_count,
          totalPrice: cart.total_price
        });
        break;
      }

      case 'remove': {
        const [productId, count = '1'] = args;
        if (!productId) {
          output({ error: 'Usage: picnic remove <productId> [count=1]' });
          process.exit(1);
        }
        const client = getClient();
        const cart = await client.removeProductFromShoppingCart(productId, parseInt(count, 10));
        output({ 
          status: 'ok', 
          message: `Removed ${count}x product from cart`,
          totalItems: cart.total_count,
          totalPrice: cart.total_price
        });
        break;
      }

      case 'clear': {
        const client = getClient();
        await client.clearShoppingCart();
        output({ status: 'ok', message: 'Cart cleared' });
        break;
      }

      case 'slots': {
        const client = getClient();
        const slotsData = await client.getDeliverySlots();
        const slots = [];
        for (const slotGroup of slotsData.delivery_slots || []) {
          for (const slot of slotGroup.slot_selector_data || []) {
            if (slot.state === 'SELECTABLE') {
              slots.push({
                id: slot.slot_id,
                date: slotGroup.start,
                window: `${slot.window_start} - ${slot.window_end}`,
                price: slot.price,
                selected: slot.selected
              });
            }
          }
        }
        output({ slots: slots.slice(0, 15) });
        break;
      }

      case 'set-slot': {
        const [slotId] = args;
        if (!slotId) {
          output({ error: 'Usage: picnic set-slot <slotId>' });
          process.exit(1);
        }
        const client = getClient();
        const cart = await client.setDeliverySlot(slotId);
        output({ status: 'ok', message: 'Delivery slot set' });
        break;
      }

      case 'deliveries': {
        const client = getClient();
        const deliveries = await client.getDeliveries();
        output({
          deliveries: deliveries.slice(0, 10).map(d => ({
            id: d.delivery_id,
            status: d.status,
            date: d.slot?.window_start?.split('T')[0],
            window: `${d.slot?.window_start?.split('T')[1]?.slice(0,5)} - ${d.slot?.window_end?.split('T')[1]?.slice(0,5)}`,
            totalPrice: d.orders?.reduce((sum, o) => sum + (o.total_price || 0), 0)
          }))
        });
        break;
      }

      case 'delivery': {
        const [deliveryId] = args;
        if (!deliveryId) {
          output({ error: 'Usage: picnic delivery <deliveryId>' });
          process.exit(1);
        }
        const client = getClient();
        const delivery = await client.getDelivery(deliveryId);
        
        // Extract items from orders
        const items = [];
        for (const order of delivery.orders || []) {
          for (const line of order.items || []) {
            const article = line.items?.[0];
            if (article) {
              const qty = article.decorators?.find(d => d.type === 'QUANTITY')?.quantity || 1;
              items.push({
                name: article.name,
                quantity: qty,
                unitSize: article.unit_quantity,
                price: line.display_price
              });
            }
          }
        }
        
        output({
          id: delivery.delivery_id,
          status: delivery.status,
          date: delivery.slot?.window_start?.split('T')[0],
          window: `${delivery.slot?.window_start?.split('T')[1]?.slice(0,5)} - ${delivery.slot?.window_end?.split('T')[1]?.slice(0,5)}`,
          totalPrice: delivery.orders?.reduce((sum, o) => sum + (o.total_price || 0), 0),
          items
        });
        break;
      }

      case 'categories': {
        const client = getClient();
        const categories = await client.getCategories(1);
        output({
          categories: (categories.catalog || []).map(c => ({
            id: c.id,
            name: c.name,
            itemCount: c.items?.length
          }))
        });
        break;
      }

      case 'user': {
        const client = getClient();
        const user = await client.getUserDetails();
        output({
          firstName: user.firstname,
          lastName: user.lastname,
          email: user.contact_email,
          phone: user.phone,
          address: user.address,
          householdSize: user.household_details?.adults
        });
        break;
      }

      case 'help':
      default:
        output({
          usage: 'picnic <command> [args]',
          commands: {
            'login <email> <password> [country=DE]': 'Login to Picnic (country: DE or NL)',
            'verify-2fa <code>': 'Verify 2FA code after login',
            'send-2fa [channel=SMS]': 'Request new 2FA code',
            'status': 'Check login status',
            'user': 'Show user details',
            'search <query>': 'Search for products',
            'cart': 'Show current cart',
            'add <productId> [count]': 'Add product to cart',
            'remove <productId> [count]': 'Remove product from cart',
            'clear': 'Clear the cart',
            'slots': 'Show available delivery slots',
            'set-slot <slotId>': 'Select a delivery slot',
            'deliveries': 'List past/current deliveries',
            'delivery <id>': 'Get delivery details',
            'categories': 'List product categories'
          }
        });
        break;
    }
  } catch (error) {
    output({ error: error.message });
    process.exit(1);
  }
}

main();