diff --git a/SKILL.md b/SKILL.md index ce15f9a..faaa640 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,11 +1,11 @@ --- name: kronan-cli description: > - Search Krónan for groceries and prices, get SKU numbers, add to or change - shopping cart, and view past order history using the official Krónan Public API. - Leita að verði á matvörum í Krónunni, vinna með innkaupakörfu (bæta í eða breyta), - og skoða gamlar pantanir. -version: 0.2.0 + Comprehensive CLI for Krónan.is grocery store using the official Public API. + Search products, browse categories, manage shopping cart and lists, view orders, + track purchase statistics, and manage shopping notes. Full CRUD operations + for all API features. Designed for both humans and AI agents. +version: 0.3.0 requires: binaries: - gh # GitHub CLI — required for install @@ -88,6 +88,13 @@ kronan product kronan product 02500188 --json ``` +### Browse by category + +```bash +kronan categories # List all categories +kronan category 01-01-02-epli # Browse products in category +``` + ### Cart management ```bash @@ -96,19 +103,50 @@ kronan cart add [quantity] # Add item to cart kronan cart clear # Clear all items from cart ``` -### Order history +### Order history and modifications ```bash kronan orders # Recent orders kronan orders --json # JSON output for parsing kronan order # Specific order details (use order token, not ID) + +# Modify orders (before fulfillment) +kronan order delete-lines [lineId2...] +kronan order lower-quantity --quantity N +kronan order toggle-substitution +``` + +### Product lists (full CRUD) + +```bash +kronan lists # List all product lists +kronan lists create [--description "..."] # Create new list +kronan lists view # View list details +kronan lists delete [--force] # Delete a list +kronan lists add [qty] # Add item to list +kronan lists remove # Remove item from list +kronan lists clear [--force] # Clear all items ``` -### Product lists +### Shopping notes (Skundalisti) ```bash -kronan lists # View saved product lists -kronan lists --json +kronan notes # View shopping note +kronan notes add [--text "..."] [--sku SKU] [--quantity N] +kronan notes update [--text "..."] [--quantity N] +kronan notes remove # Remove item +kronan notes toggle # Mark complete/incomplete +kronan notes clear [--force] # Clear all items +kronan notes archived # View completed items +``` + +### Purchase statistics + +```bash +kronan stats [--limit N] [--offset N] # View purchase history +kronan stats --include-ignored # Include hidden products +kronan stats ignore # Hide product from stats +kronan stats unignore # Unhide product ``` ### User identity @@ -122,44 +160,98 @@ kronan me --json All commands support `--json` for structured output. This makes kronan-cli suitable as a tool for AI agents managing grocery shopping. -**Important:** Commands that change state (`cart add`, `cart clear`) can modify the user's real shopping cart. Agents **must ask for explicit user confirmation** before running any state-changing command. +**Important:** Commands that change state can modify the user's real data. Agents **must ask for explicit user confirmation** before running any state-changing command. -Read-only commands (`search`, `product`, `orders`, `order`, `cart` (view), `lists`, `me`, `status`) are safe to run without confirmation. +State-changing commands: +- `cart add`, `cart clear` +- `order delete-lines`, `order lower-quantity`, `order toggle-substitution` +- `lists create`, `lists delete`, `lists add`, `lists remove`, `lists clear` +- `notes add`, `notes update`, `notes remove`, `notes toggle`, `notes clear` +- `stats ignore`, `stats unignore` -Example agent workflow: +Read-only commands are safe to run without confirmation: +- `search`, `product`, `categories`, `category` +- `orders`, `order` (view) +- `cart` (view) +- `lists` (view) +- `notes` (view), `notes archived` +- `stats` (view) +- `me`, `status` -```bash -# 1. Check what the user usually buys -kronan orders --json +### Example agent workflows + +**Build a weekly cart from frequently purchased items:** -# 2. Search for a specific product -kronan search "nymjolk" --json --limit 5 +```bash +# 1. Get purchase statistics to find frequently bought items +kronan stats --limit 50 --json -# 3. Add items to cart -kronan cart add 100224198 6 -kronan cart add 02200946 1 +# 2. Add top items to cart at their typical quantities +kronan cart add 100224198 6 # Nýmjólk x6 +kronan cart add 02200946 1 # Heimilisbrauð -# 4. Review the cart +# 3. Review the cart kronan cart --json ``` -### Typical weekly shopping pattern +**Create a shopping list for a recipe:** + +```bash +# 1. Create a new list +kronan lists create "Pizza Night" --description "Ingredients for homemade pizza" + +# 2. Search for products and add to list +kronan search "mozzarella" --json +kronan lists add 100246180 2 + +kronan search "pizzasósa" --json +kronan lists add 100221958 1 + +# 3. View the completed list +kronan lists view +``` + +**Manage shopping with notes (Skundalisti):** + +```bash +# 1. Add items to shopping note +kronan notes add --text "Mjólk" +kronan notes add --sku 100224198 --quantity 2 -An agent can analyze order history to find frequently purchased items and auto-populate the cart: +# 2. Mark items as you shop +kronan notes toggle -1. Fetch orders with `kronan orders --json` -2. Count product frequency across orders (by SKU) -3. Add the top items at their typical quantities with `kronan cart add ` -4. Present the cart for user review with `kronan cart` +# 3. View remaining items +kronan notes + +# 4. View completed items +kronan notes archived +``` + +**Analyze and optimize purchases:** + +```bash +# View purchase frequency for all products +kronan stats --json + +# Hide irrelevant products from stats +kronan stats ignore +``` ## Flags | Flag | Description | |------|-------------| | `--json` | Structured JSON output (for AI agents) | -| `--page ` | Page number (search) | +| `--page ` | Page number (search, category) | | `--limit ` | Results per page | -| `--offset ` | Offset for pagination (orders) | +| `--offset ` | Offset for pagination | +| `--include-ignored` | Include ignored products in stats | +| `--force` | Skip confirmation for destructive operations | +| `--text "..."` | Text for shopping note item | +| `--sku SKU` | Product SKU | +| `--quantity N` | Quantity (default: 1) | +| `--description "..."` | Description for product list | ## API Reference @@ -175,13 +267,26 @@ Key endpoints: |----------|--------|------|-------------| | `/products/search/` | POST | Yes | Product search | | `/products/{sku}/` | GET | Yes | Product detail | +| `/categories/` | GET | Yes | Category tree | +| `/categories/{slug}/products/` | GET | Yes | Category products | | `/checkout/` | GET | Yes | View checkout/cart | | `/checkout/lines/` | POST | Yes | Add/replace checkout lines | | `/orders/` | GET | Yes | Order history | +| `/orders/{token}/` | GET | Yes | Order details | +| `/orders/{token}/delete-lines/` | POST | Yes | Delete order lines | +| `/orders/{token}/lower-quantity-lines/` | POST | Yes | Lower line quantity | +| `/orders/{token}/lines-toggle-substitution/` | POST | Yes | Toggle substitution | | `/me/` | GET | Yes | Current identity | -| `/product-lists/` | GET | Yes | Product lists | -| `/shopping-notes/` | GET | Yes | Shopping notes | +| `/product-lists/` | GET/POST | Yes | List/create product lists | +| `/product-lists/{token}/` | GET/PATCH/DELETE | Yes | Product list CRUD | +| `/product-lists/{token}/update-item/` | POST | Yes | Add/update list item | +| `/shopping-notes/` | GET | Yes | View shopping note | +| `/shopping-notes/add-line/` | POST | Yes | Add note line | +| `/shopping-notes/change-line/` | PATCH | Yes | Update note line | +| `/shopping-notes/delete-line/` | DELETE | Yes | Delete note line | +| `/shopping-notes/toggle-complete-on-line/` | PATCH | Yes | Toggle completion | | `/product-purchase-stats/` | GET | Yes | Purchase statistics | +| `/product-purchase-stats/{id}/set-ignored/` | PATCH | Yes | Ignore/unignore product | Auth header format: `Authorization: AccessToken {token}` @@ -195,3 +300,9 @@ If you were using the previous version with Cognito authentication: 4. Update any scripts using `kronan login` to use `kronan token` instead Note: Order IDs in the new API are tokens (UUIDs), not numeric IDs. + +## Version History + +- **v0.3.0** - Added comprehensive commands: categories, order modifications, product lists CRUD, shopping notes, purchase statistics +- **v0.2.0** - Migrated to Krónan Public API with AccessToken authentication +- **v0.1.0** - Initial release with Cognito authentication diff --git a/package.json b/package.json index d562da0..d1123d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kronan-cli", - "version": "0.1.0", + "version": "0.3.0", "module": "src/index.ts", "type": "module", "private": true, diff --git a/src/api.ts b/src/api.ts index e5631ab..a9cbfcb 100644 --- a/src/api.ts +++ b/src/api.ts @@ -670,10 +670,20 @@ export async function deleteAllProductListItems( export async function getShoppingNote( token: AuthToken, ): Promise { - const result = await apiRequest("/shopping-notes/", { - token, - }); - return result[0]!; + const result = await apiRequest( + "/shopping-notes/", + { + token, + }, + ); + // API can return either a single object or an array with one object + if (Array.isArray(result)) { + if (result.length === 0) { + throw new Error("No shopping note found"); + } + return result[0]!; + } + return result; } /** diff --git a/src/commands/categories.ts b/src/commands/categories.ts new file mode 100644 index 0000000..ab5a36c --- /dev/null +++ b/src/commands/categories.ts @@ -0,0 +1,66 @@ +/** + * Categories commands + */ + +import { getCategories, getCategoryProducts } from "../api.ts"; +import { requireAuth } from "../auth.ts"; + +export async function categoriesCommand( + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const categories = await getCategories(token); + + if (options.json) { + console.log(JSON.stringify(categories, null, 2)); + return; + } + + console.log("Categories:\n"); + for (const cat of categories) { + console.log(` ${cat.name}`); + if (cat.children && cat.children.length > 0) { + for (const child1 of cat.children) { + console.log(` └── ${child1.name} (${child1.slug})`); + if (child1.children && child1.children.length > 0) { + for (const child2 of child1.children) { + console.log(` └── ${child2.name} (${child2.slug})`); + } + } + } + } + console.log(""); + } +} + +export async function categoryProductsCommand( + slug: string, + options: { page?: number; json?: boolean } = {}, +): Promise { + const { page = 1, json = false } = options; + const token = await requireAuth(); + const result = await getCategoryProducts(token, slug, page); + + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(`${result.name} (${result.count} products):\n`); + + for (const product of result.products) { + const sale = product.onSale ? " [SALE]" : ""; + const shortage = product.temporaryShortage ? " [OUT OF STOCK]" : ""; + console.log(` ${product.sku} ${product.name}`); + console.log( + ` ${product.price} kr ${product.priceInfo}${sale}${shortage}`, + ); + console.log(""); + } + + if (result.hasNextPage) { + console.log( + ` → More results: kronan category ${slug} --page ${result.page + 1}`, + ); + } +} diff --git a/src/commands/orders-modify.ts b/src/commands/orders-modify.ts new file mode 100644 index 0000000..37e2736 --- /dev/null +++ b/src/commands/orders-modify.ts @@ -0,0 +1,90 @@ +/** + * Order modification commands + */ + +import { + deleteOrderLines, + lowerOrderLineQuantities, + type PublicOrder, + toggleOrderLineSubstitution, +} from "../api.ts"; +import { requireAuth } from "../auth.ts"; + +function formatOrder(order: PublicOrder): void { + const date = new Date(order.created).toLocaleDateString("is-IS"); + const total = order.total.toLocaleString("is-IS"); + + console.log(`Order ${order.token}`); + console.log(` Date: ${date}`); + console.log(` Status: ${order.status}`); + console.log(` Type: ${order.type || "N/A"}`); + console.log(` Total: ${total} kr`); + console.log(` Items:`); + + for (const line of order.lines) { + const unitPrice = line.unitPrice.toLocaleString("is-IS"); + console.log( + ` [${line.id}] ${line.sku} ${line.productName} x${line.quantity} ${unitPrice} kr/ea`, + ); + } +} + +export async function orderDeleteLinesCommand( + orderToken: string, + lineIds: number[], + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const order = await deleteOrderLines(token, orderToken, lineIds); + + if (options.json) { + console.log(JSON.stringify(order, null, 2)); + return; + } + + console.log(`Deleted ${lineIds.length} line(s) from order.`); + console.log(""); + formatOrder(order); +} + +export async function orderLowerQuantityCommand( + orderToken: string, + lineIds: number[], + quantity: number, + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const order = await lowerOrderLineQuantities( + token, + orderToken, + lineIds, + quantity, + ); + + if (options.json) { + console.log(JSON.stringify(order, null, 2)); + return; + } + + console.log(`Lowered quantity to ${quantity} for ${lineIds.length} line(s).`); + console.log(""); + formatOrder(order); +} + +export async function orderToggleSubstitutionCommand( + orderToken: string, + lineIds: number[], + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const order = await toggleOrderLineSubstitution(token, orderToken, lineIds); + + if (options.json) { + console.log(JSON.stringify(order, null, 2)); + return; + } + + console.log(`Toggled substitution for ${lineIds.length} line(s).`); + console.log(""); + formatOrder(order); +} diff --git a/src/commands/product-lists.ts b/src/commands/product-lists.ts new file mode 100644 index 0000000..4175898 --- /dev/null +++ b/src/commands/product-lists.ts @@ -0,0 +1,164 @@ +/** + * Product lists commands (full CRUD) + */ + +import { + createProductList, + deleteAllProductListItems, + deleteProductList, + getProductList, + getProductLists, + type PublicProductListDetail, + updateProductList, + updateProductListItem, +} from "../api.ts"; +import { requireAuth } from "../auth.ts"; + +function formatProductList(list: PublicProductListDetail): void { + console.log(`${list.name}`); + if (list.description) { + console.log(` Description: ${list.description}`); + } + console.log(` Token: ${list.token}`); + console.log(` Items:`); + + if (list.items.length === 0) { + console.log(" (empty)"); + } else { + for (const item of list.items) { + console.log( + ` ${item.product.sku} ${item.product.name} x${item.quantity}`, + ); + } + } +} + +export async function listsCommand( + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const lists = await getProductLists(token); + + if (options.json) { + console.log(JSON.stringify(lists, null, 2)); + return; + } + + if (lists.results.length === 0) { + console.log("No product lists found."); + return; + } + + console.log(`Product lists (${lists.count} total):\n`); + for (const list of lists.results) { + console.log( + ` ${list.name} ${list.hasProducts ? "(has items)" : "(empty)"}`, + ); + console.log(` Token: ${list.token}`); + } +} + +export async function listDetailCommand( + listToken: string, + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const list = await getProductList(token, listToken); + + if (options.json) { + console.log(JSON.stringify(list, null, 2)); + return; + } + + formatProductList(list); +} + +export async function listCreateCommand( + name: string, + options: { description?: string; json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const list = await createProductList(token, name, options.description); + + if (options.json) { + console.log(JSON.stringify(list, null, 2)); + return; + } + + console.log(`Created list: ${list.name}`); + console.log(` Token: ${list.token}`); +} + +export async function listUpdateCommand( + listToken: string, + updates: { name?: string; description?: string }, + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const list = await updateProductList(token, listToken, updates); + + if (options.json) { + console.log(JSON.stringify(list, null, 2)); + return; + } + + console.log(`Updated list: ${list.name}`); +} + +export async function listDeleteCommand( + listToken: string, + _options: { force?: boolean } = {}, +): Promise { + const token = await requireAuth(); + await deleteProductList(token, listToken); + + console.log(`Deleted list: ${listToken}`); +} + +export async function listAddItemCommand( + listToken: string, + sku: string, + quantity: number, + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const list = await updateProductListItem(token, listToken, sku, quantity); + + if (options.json) { + console.log(JSON.stringify(list, null, 2)); + return; + } + + console.log(`Added/updated item: ${sku} x${quantity}`); + console.log(""); + formatProductList(list); +} + +export async function listRemoveItemCommand( + listToken: string, + sku: string, + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + // Set quantity to 0 to remove + const list = await updateProductListItem(token, listToken, sku, 0); + + if (options.json) { + console.log(JSON.stringify(list, null, 2)); + return; + } + + console.log(`Removed item: ${sku}`); + console.log(""); + formatProductList(list); +} + +export async function listClearCommand( + listToken: string, + _options: { force?: boolean } = {}, +): Promise { + const token = await requireAuth(); + await deleteAllProductListItems(token, listToken); + + console.log(`Cleared all items from list: ${listToken}`); +} diff --git a/src/commands/purchase-stats.ts b/src/commands/purchase-stats.ts new file mode 100644 index 0000000..50a8f0d --- /dev/null +++ b/src/commands/purchase-stats.ts @@ -0,0 +1,93 @@ +/** + * Purchase statistics commands + */ + +import { getPurchaseStats, setPurchaseStatIgnored } from "../api.ts"; +import { requireAuth } from "../auth.ts"; + +export async function statsCommand( + options: { + limit?: number; + offset?: number; + includeIgnored?: boolean; + json?: boolean; + } = {}, +): Promise { + const { + limit = 15, + offset = 0, + includeIgnored = false, + json = false, + } = options; + const token = await requireAuth(); + const result = await getPurchaseStats(token, { + limit, + offset, + includeIgnored, + }); + + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(`Purchase statistics (${result.count} products):\n`); + + for (const stat of result.results) { + if (stat.isIgnored && !includeIgnored) continue; + + const product = stat.product; + const avgQty = stat.averagePurchaseQuantity?.toFixed(1) || "N/A"; + const interval = stat.averagePurchaseIntervalDays?.toFixed(0) || "N/A"; + const lastDate = stat.lastPurchaseDate + ? new Date(stat.lastPurchaseDate).toLocaleDateString("is-IS") + : "N/A"; + + console.log(` ${product.sku} ${product.name}`); + console.log( + ` ${stat.purchaseCount} purchases, ${stat.quantityPurchased} total qty`, + ); + console.log(` Avg: ${avgQty} qty/order, every ${interval} days`); + console.log( + ` Last: ${lastDate} (${stat.lastPurchaseQuantity} qty)`, + ); + if (stat.isIgnored) { + console.log(` [IGNORED]`); + } + console.log(""); + } + + if (result.next) { + console.log(` → More: kronan stats --offset ${offset + limit}`); + } +} + +export async function statsIgnoreCommand( + id: number, + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const stat = await setPurchaseStatIgnored(token, id, true); + + if (options.json) { + console.log(JSON.stringify(stat, null, 2)); + return; + } + + console.log(`Ignored product: ${stat.product.name}`); +} + +export async function statsUnignoreCommand( + id: number, + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const stat = await setPurchaseStatIgnored(token, id, false); + + if (options.json) { + console.log(JSON.stringify(stat, null, 2)); + return; + } + + console.log(`Unignored product: ${stat.product.name}`); +} diff --git a/src/commands/shopping-notes.ts b/src/commands/shopping-notes.ts new file mode 100644 index 0000000..346a77c --- /dev/null +++ b/src/commands/shopping-notes.ts @@ -0,0 +1,154 @@ +/** + * Shopping notes commands (Skundalisti / Scan and Go) + */ + +import { + addShoppingNoteLine, + clearShoppingNote, + deleteShoppingNoteLine, + getArchivedShoppingNoteLines, + getShoppingNote, + type PublicShoppingNote, + toggleShoppingNoteLineComplete, + updateShoppingNoteLine, +} from "../api.ts"; +import { requireAuth } from "../auth.ts"; + +function formatShoppingNote(note: PublicShoppingNote): void { + console.log(`${note.name || "Shopping Note"}`); + console.log(`Token: ${note.token}`); + console.log(`\nItems:`); + + if (note.lines.length === 0) { + console.log(" (empty)"); + } else { + for (const line of note.lines) { + const status = line.completed ? "[✓]" : "[ ]"; + const text = line.text || line.sku || "Unknown item"; + console.log(` ${status} ${text} x${line.quantity}`); + } + } +} + +export async function notesCommand( + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const note = await getShoppingNote(token); + + if (options.json) { + console.log(JSON.stringify(note, null, 2)); + return; + } + + formatShoppingNote(note); +} + +export async function notesAddCommand( + options: { + text?: string; + sku?: string; + quantity?: number; + json?: boolean; + } = {}, +): Promise { + const { text, sku, quantity = 1, json = false } = options; + + if (!text && !sku) { + throw new Error("Either --text or --sku must be provided"); + } + + const token = await requireAuth(); + const note = await addShoppingNoteLine(token, { text, sku, quantity }); + + if (json) { + console.log(JSON.stringify(note, null, 2)); + return; + } + + console.log("Added item to shopping note."); + console.log(""); + formatShoppingNote(note); +} + +export async function notesUpdateCommand( + lineToken: string, + updates: { text?: string; quantity?: number }, + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const note = await updateShoppingNoteLine(token, lineToken, updates); + + if (options.json) { + console.log(JSON.stringify(note, null, 2)); + return; + } + + console.log("Updated shopping note item."); + console.log(""); + formatShoppingNote(note); +} + +export async function notesRemoveCommand( + lineToken: string, + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + await deleteShoppingNoteLine(token, lineToken); + + if (options.json) { + console.log(JSON.stringify({ deleted: lineToken }, null, 2)); + return; + } + + console.log(`Removed item: ${lineToken}`); +} + +export async function notesToggleCommand( + lineToken: string, + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const note = await toggleShoppingNoteLineComplete(token, lineToken); + + if (options.json) { + console.log(JSON.stringify(note, null, 2)); + return; + } + + console.log("Toggled item completion."); + console.log(""); + formatShoppingNote(note); +} + +export async function notesClearCommand( + _options: { force?: boolean } = {}, +): Promise { + const token = await requireAuth(); + await clearShoppingNote(token); + + console.log("Cleared all items from shopping note."); +} + +export async function notesArchivedCommand( + options: { json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const lines = await getArchivedShoppingNoteLines(token); + + if (options.json) { + console.log(JSON.stringify(lines, null, 2)); + return; + } + + console.log("Archived (completed) items:\n"); + if (lines.length === 0) { + console.log(" (none)"); + } else { + for (const line of lines) { + const text = line.text || line.sku || "Unknown item"; + const date = new Date(line.completedAt).toLocaleDateString("is-IS"); + console.log(` [✓] ${text} x${line.quantity} (completed ${date})`); + } + } +} diff --git a/src/index.ts b/src/index.ts index 2ad2168..9d2c5a3 100755 --- a/src/index.ts +++ b/src/index.ts @@ -31,15 +31,46 @@ import { cartAddCommand, cartClearCommand, cartViewCommand, - listCommand, } from "./commands/cart.ts"; +import { + categoriesCommand, + categoryProductsCommand, +} from "./commands/categories.ts"; import { logoutCommand, statusCommand, tokenCommand, } from "./commands/login.ts"; import { orderDetailCommand, ordersCommand } from "./commands/orders.ts"; +import { + orderDeleteLinesCommand, + orderLowerQuantityCommand, + orderToggleSubstitutionCommand, +} from "./commands/orders-modify.ts"; +import { + listAddItemCommand, + listClearCommand, + listCreateCommand, + listDeleteCommand, + listDetailCommand, + listRemoveItemCommand, + listsCommand, +} from "./commands/product-lists.ts"; +import { + statsCommand, + statsIgnoreCommand, + statsUnignoreCommand, +} from "./commands/purchase-stats.ts"; import { productDetailCommand, searchCommand } from "./commands/search.ts"; +import { + notesAddCommand, + notesArchivedCommand, + notesClearCommand, + notesCommand, + notesRemoveCommand, + notesToggleCommand, + notesUpdateCommand, +} from "./commands/shopping-notes.ts"; const args = process.argv.slice(2); const command = args[0]; @@ -107,6 +138,24 @@ async function main() { break; } + case "categories": { + await categoriesCommand({ json: jsonOutput }); + break; + } + + case "category": { + const slug = args[1]; + if (!slug) { + console.error("Usage: kronan category [--page N] [--json]"); + process.exit(1); + } + await categoryProductsCommand(slug, { + page: getFlag("page") ? parseInt(getFlag("page")!, 10) : undefined, + json: jsonOutput, + }); + break; + } + case "orders": await ordersCommand({ limit: getFlag("limit") ? parseInt(getFlag("limit")!, 10) : undefined, @@ -118,12 +167,106 @@ async function main() { break; case "order": { - const orderToken = args[1]; - if (!orderToken) { - console.error("Usage: kronan order [--json]"); - process.exit(1); + // Check if args[1] is a subcommand for order modifications + const subcommand = args[1]; + if ( + subcommand === "delete-lines" || + subcommand === "lower-quantity" || + subcommand === "toggle-substitution" + ) { + // Order modification subcommands + switch (subcommand) { + case "delete-lines": { + const orderToken = args[2]; + if (!orderToken) { + console.error( + "Usage: kronan order delete-lines [--json]", + ); + process.exit(1); + } + const lineIds = args + .slice(3) + .filter((arg) => !arg.startsWith("-")) + .map((id) => parseInt(id, 10)); + if (lineIds.length === 0) { + console.error( + "Usage: kronan order delete-lines [--json]", + ); + process.exit(1); + } + await orderDeleteLinesCommand(orderToken, lineIds, { + json: jsonOutput, + }); + break; + } + case "lower-quantity": { + const orderToken = args[2]; + if (!orderToken) { + console.error( + "Usage: kronan order lower-quantity --quantity N [--json]", + ); + process.exit(1); + } + const lineIds = args + .slice(3) + .filter((arg) => !arg.startsWith("-")) + .map((id) => parseInt(id, 10)); + if (lineIds.length === 0) { + console.error( + "Usage: kronan order lower-quantity --quantity N [--json]", + ); + process.exit(1); + } + const quantity = getFlag("quantity") + ? parseInt(getFlag("quantity")!, 10) + : 1; + await orderLowerQuantityCommand(orderToken, lineIds, quantity, { + json: jsonOutput, + }); + break; + } + case "toggle-substitution": { + const orderToken = args[2]; + if (!orderToken) { + console.error( + "Usage: kronan order toggle-substitution [--json]", + ); + process.exit(1); + } + const lineIds = args + .slice(3) + .filter((arg) => !arg.startsWith("-")) + .map((id) => parseInt(id, 10)); + if (lineIds.length === 0) { + console.error( + "Usage: kronan order toggle-substitution [--json]", + ); + process.exit(1); + } + await orderToggleSubstitutionCommand(orderToken, lineIds, { + json: jsonOutput, + }); + break; + } + } + } else { + // View order details + const orderToken = args[1]; + if (!orderToken) { + console.error("Usage: kronan order [--json]"); + console.error( + " kronan order delete-lines [--json]", + ); + console.error( + " kronan order lower-quantity --quantity N [--json]", + ); + console.error( + " kronan order toggle-substitution [--json]", + ); + process.exit(1); + } + await orderDetailCommand(orderToken, { json: jsonOutput }); } - await orderDetailCommand(orderToken, { json: jsonOutput }); break; } @@ -160,9 +303,214 @@ async function main() { break; } - case "lists": - await listCommand({ json: jsonOutput }); + case "lists": { + // Get subcommand (arg at index 1 that doesn't start with -) + const subcommand = args.find( + (arg) => !arg.startsWith("-") && args.indexOf(arg) === 1, + ); + switch (subcommand) { + case "create": { + const name = args[2]; + if (!name || name.startsWith("-")) { + console.error( + "Usage: kronan lists create [--description ] [--json]", + ); + process.exit(1); + } + await listCreateCommand(name, { + description: getFlag("description"), + json: jsonOutput, + }); + break; + } + case "view": { + const token = args[2]; + if (!token || token.startsWith("-")) { + console.error("Usage: kronan lists view [--json]"); + process.exit(1); + } + await listDetailCommand(token, { json: jsonOutput }); + break; + } + case "delete": { + const token = args[2]; + if (!token || token.startsWith("-")) { + console.error( + "Usage: kronan lists delete [--force] [--json]", + ); + process.exit(1); + } + await listDeleteCommand(token, { + force: hasFlag("force"), + }); + break; + } + case "add": { + const listToken = args[2]; + const sku = args[3]; + const qty = args[4] ? parseInt(args[4], 10) : 1; + if ( + !listToken || + listToken.startsWith("-") || + !sku || + sku.startsWith("-") + ) { + console.error( + "Usage: kronan lists add [quantity] [--json]", + ); + process.exit(1); + } + await listAddItemCommand(listToken, sku, qty, { json: jsonOutput }); + break; + } + case "remove": { + const listToken = args[2]; + const sku = args[3]; + if ( + !listToken || + listToken.startsWith("-") || + !sku || + sku.startsWith("-") + ) { + console.error( + "Usage: kronan lists remove [--json]", + ); + process.exit(1); + } + await listRemoveItemCommand(listToken, sku, { json: jsonOutput }); + break; + } + case "clear": { + const token = args[2]; + if (!token || token.startsWith("-")) { + console.error( + "Usage: kronan lists clear [--force] [--json]", + ); + process.exit(1); + } + await listClearCommand(token, { + force: hasFlag("force"), + }); + break; + } + default: { + // Default: list all lists + if (subcommand && subcommand !== "view") { + console.error(`Unknown lists subcommand: ${subcommand}`); + console.error( + "Usage: kronan lists [create|view|delete|add|remove|clear]", + ); + process.exit(1); + } + await listsCommand({ json: jsonOutput }); + break; + } + } break; + } + + case "notes": { + // Get subcommand (arg at index 1 that doesn't start with -) + const subcommand = args.find( + (arg) => !arg.startsWith("-") && args.indexOf(arg) === 1, + ); + switch (subcommand) { + case "add": { + const text = getFlag("text"); + const sku = getFlag("sku"); + const quantity = getFlag("quantity") + ? parseInt(getFlag("quantity")!, 10) + : undefined; + await notesAddCommand({ text, sku, quantity, json: jsonOutput }); + break; + } + case "update": { + const lineToken = args[2]; + if (!lineToken || lineToken.startsWith("-")) { + console.error( + "Usage: kronan notes update [--text ] [--quantity ] [--json]", + ); + process.exit(1); + } + const updates: { text?: string; quantity?: number } = {}; + if (getFlag("text")) updates.text = getFlag("text"); + if (getFlag("quantity")) + updates.quantity = parseInt(getFlag("quantity")!, 10); + await notesUpdateCommand(lineToken, updates, { json: jsonOutput }); + break; + } + case "remove": { + const lineToken = args[2]; + if (!lineToken || lineToken.startsWith("-")) { + console.error("Usage: kronan notes remove [--json]"); + process.exit(1); + } + await notesRemoveCommand(lineToken, { json: jsonOutput }); + break; + } + case "toggle": { + const lineToken = args[2]; + if (!lineToken || lineToken.startsWith("-")) { + console.error("Usage: kronan notes toggle [--json]"); + process.exit(1); + } + await notesToggleCommand(lineToken, { json: jsonOutput }); + break; + } + case "clear": { + await notesClearCommand({ force: hasFlag("force") }); + break; + } + case "archived": { + await notesArchivedCommand({ json: jsonOutput }); + break; + } + default: { + // Default: show notes + if (subcommand) { + console.error(`Unknown notes subcommand: ${subcommand}`); + console.error( + "Usage: kronan notes [add|update|remove|toggle|clear|archived]", + ); + process.exit(1); + } + await notesCommand({ json: jsonOutput }); + break; + } + } + break; + } + + case "stats": { + // Check if args[1] is a subcommand (not a flag) + const subcommand = args[1]; + if (subcommand === "ignore" || subcommand === "unignore") { + const id = args[2]; + if (!id || id.startsWith("-")) { + console.error(`Usage: kronan stats ${subcommand} [--json]`); + process.exit(1); + } + const idNum = parseInt(id, 10); + if (subcommand === "ignore") { + await statsIgnoreCommand(idNum, { json: jsonOutput }); + } else { + await statsUnignoreCommand(idNum, { json: jsonOutput }); + } + } else { + // stats command with optional flags + await statsCommand({ + limit: getFlag("limit") + ? parseInt(getFlag("limit")!, 10) + : undefined, + offset: getFlag("offset") + ? parseInt(getFlag("offset")!, 10) + : undefined, + includeIgnored: hasFlag("include-ignored"), + json: jsonOutput, + }); + } + break; + } case "me": case "profile": { @@ -202,41 +550,88 @@ async function main() { function printHelp() { console.log(`kronan-cli - Krónan grocery store CLI -Commands: - token Save access token (create at https://kronan.is/adgangur/adgangslyklar) - logout Clear stored token - status Show login status +Authentication: + token Save access token (create at https://kronan.is/adgangur/adgangslyklar) + logout Clear stored token + status Show login status + +Products & Search: + search Search for products + product Get product details by SKU + categories List all categories + category List products in a category + +Orders: + orders View order history + order View specific order details + order delete-lines + Delete lines from an order + order lower-quantity --quantity N + Lower quantity of order lines + order toggle-substitution + Toggle substitution for order lines + +Cart: + cart View cart contents (default) + cart add [qty] Add item to cart + cart clear Clear cart - search Search for products - product Get product details by SKU +Product Lists: + lists View all product lists + lists create Create a new list (--description) + lists view View list details + lists delete Delete a list (--force) + lists add [qty] + Add item to list + lists remove Remove item from list + lists clear Clear all items from list (--force) - orders View order history - order View specific order details +Shopping Notes: + notes View shopping notes + notes add Add a note (--text, --sku, --quantity) + notes update Update a note line (--text, --quantity) + notes remove Remove a note line + notes toggle Toggle note line status + notes clear Clear all notes (--force) + notes archived View archived notes - cart add [qty] Add item to cart - cart view View cart contents (default) - cart clear Clear cart - lists View product lists +Purchase Stats: + stats View purchase history (--limit, --offset, --include-ignored) + stats ignore Ignore a purchase stat entry + stats unignore Unignore a purchase stat entry - me Show current identity - help Show this help +User: + me Show current identity + help Show this help Flags: - --json Output structured JSON (for AI agents) - --page Page number (search) - --limit Results per page - --offset Offset for pagination (orders) + --json Output structured JSON (for AI agents) + --page Page number (search, category) + --limit Results per page + --offset Offset for pagination + --quantity Quantity for add/lower-quantity commands + --description Description for lists create + --text Text for notes + --sku SKU for notes + --force Force destructive operations + --include-ignored Include ignored items in stats Examples: kronan token abc123def456 kronan search "mjólk" kronan search "epli" --json --limit 5 kronan product 02500188 + kronan categories + kronan category mjolkursvorur kronan orders --json kronan order abc123... + kronan order delete-lines abc123 line1 line2 kronan cart kronan cart add 02500188 2 - kronan cart clear + kronan lists create "Weekly Shopping" --description "Groceries for the week" + kronan lists add mylist-token 02500188 3 + kronan notes add --text "Buy milk" --sku 02500188 --quantity 2 + kronan stats --limit 10 `); }