willys-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +9 -0
- package/README.md +123 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +216 -0
- package/dist/crypto.d.ts +17 -0
- package/dist/crypto.js +32 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/skill.d.ts +1 -0
- package/dist/skill.js +84 -0
- package/dist/types.d.ts +135 -0
- package/dist/types.js +1 -0
- package/dist/willys-api.d.ts +62 -0
- package/dist/willys-api.js +221 -0
- package/package.json +48 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright © 2026 Erik Hellman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# willys-cli
|
|
2
|
+
|
|
3
|
+
TypeScript library and CLI for the [Willys](https://www.willys.se/) grocery store API.
|
|
4
|
+
|
|
5
|
+
Search for products, browse categories, and manage your shopping cart from the terminal.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g willys-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Credentials
|
|
14
|
+
|
|
15
|
+
Set your Willys login (personnummer + password) via environment variables or a `.env` file:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
WILLYS_USERNAME=199001011234
|
|
19
|
+
WILLYS_PASSWORD=yourpassword
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
You can also pass them as flags: `-u <username> -p <password>`.
|
|
23
|
+
|
|
24
|
+
## CLI Usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Search for products (default 10 results)
|
|
28
|
+
willys-cli search mjölk
|
|
29
|
+
|
|
30
|
+
# Search with more results (auto-paginates)
|
|
31
|
+
willys-cli search "ekologisk mjölk" 30
|
|
32
|
+
|
|
33
|
+
# List categories
|
|
34
|
+
willys-cli categories
|
|
35
|
+
|
|
36
|
+
# Browse a category
|
|
37
|
+
willys-cli browse frukt-och-gront/frukt/citrusfrukt
|
|
38
|
+
|
|
39
|
+
# Add to cart
|
|
40
|
+
willys-cli add 101233933_ST 2
|
|
41
|
+
|
|
42
|
+
# View cart
|
|
43
|
+
willys-cli cart
|
|
44
|
+
|
|
45
|
+
# Remove from cart
|
|
46
|
+
willys-cli remove 101233933_ST
|
|
47
|
+
|
|
48
|
+
# Clear cart
|
|
49
|
+
willys-cli clear
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Batch operations
|
|
53
|
+
|
|
54
|
+
Create a CSV file with one operation per line:
|
|
55
|
+
|
|
56
|
+
```csv
|
|
57
|
+
add,101233933_ST,2
|
|
58
|
+
add,101205823_ST,1
|
|
59
|
+
cart
|
|
60
|
+
remove,101205823_ST
|
|
61
|
+
clear
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Run it:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
willys-cli -i shopping-list.csv
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Library Usage
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
import { WillysApi } from "willys-cli";
|
|
74
|
+
|
|
75
|
+
const api = new WillysApi();
|
|
76
|
+
await api.login("199001011234", "yourpassword");
|
|
77
|
+
|
|
78
|
+
const results = await api.search("mjölk");
|
|
79
|
+
console.log(results.results[0].name); // "Mellanmjölk Längre Hållbarhet 1,5%"
|
|
80
|
+
|
|
81
|
+
await api.addToCart([{ code: "101233933_ST", qty: 2 }]);
|
|
82
|
+
const cart = await api.getCart();
|
|
83
|
+
|
|
84
|
+
await api.logout();
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### API Methods
|
|
88
|
+
|
|
89
|
+
| Method | Description |
|
|
90
|
+
|--------|-------------|
|
|
91
|
+
| `login(username, password)` | Authenticate (returns `Customer`) |
|
|
92
|
+
| `logout()` | End session |
|
|
93
|
+
| `getCustomer()` | Get current user profile |
|
|
94
|
+
| `search(query, page?, size?)` | Search products |
|
|
95
|
+
| `getCategories(storeId?)` | Get category tree |
|
|
96
|
+
| `browseCategory(path, page?, size?)` | List products in a category |
|
|
97
|
+
| `getCart()` | Get current cart |
|
|
98
|
+
| `addToCart([{code, qty}])` | Add products to cart |
|
|
99
|
+
| `removeFromCart(code)` | Remove a product from cart |
|
|
100
|
+
| `clearCart()` | Empty the cart |
|
|
101
|
+
|
|
102
|
+
## Claude Code Integration
|
|
103
|
+
|
|
104
|
+
Install the Claude Code skill into your project:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
cd your-project
|
|
108
|
+
willys-cli --install-skills
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
This creates `.claude/skills/willys-cli/SKILL.md`, enabling Claude Code to use the CLI as a tool.
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
git clone https://github.com/pansen/willys-agent.git
|
|
117
|
+
cd willys-agent
|
|
118
|
+
npm install
|
|
119
|
+
cp .env.example .env # add your credentials
|
|
120
|
+
npm run build # compile TypeScript
|
|
121
|
+
npm test # run integration tests
|
|
122
|
+
npm start # run CLI in dev mode (via tsx)
|
|
123
|
+
```
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { WillysApi } from "./willys-api.js";
|
|
6
|
+
import { SKILL_MD } from "./skill.js";
|
|
7
|
+
function formatProduct(p) {
|
|
8
|
+
const parts = [p.name];
|
|
9
|
+
if (p.manufacturer)
|
|
10
|
+
parts.push(`[${p.manufacturer}]`);
|
|
11
|
+
if (p.displayVolume)
|
|
12
|
+
parts.push(p.displayVolume);
|
|
13
|
+
parts.push(`— ${p.price}`);
|
|
14
|
+
if (p.comparePrice && p.comparePriceUnit) {
|
|
15
|
+
parts.push(`(${p.comparePrice}/${p.comparePriceUnit})`);
|
|
16
|
+
}
|
|
17
|
+
parts.push(`(${p.code})`);
|
|
18
|
+
return ` ${parts.join(" ")}`;
|
|
19
|
+
}
|
|
20
|
+
function printCart(cart) {
|
|
21
|
+
if (cart.totalUnitCount === 0) {
|
|
22
|
+
console.log("Cart is empty.");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
console.log(`Cart (${cart.totalUnitCount} items):`);
|
|
26
|
+
for (const p of cart.products) {
|
|
27
|
+
const parts = [` ${p.name}`];
|
|
28
|
+
if (p.manufacturer)
|
|
29
|
+
parts.push(`[${p.manufacturer}]`);
|
|
30
|
+
if (p.displayVolume)
|
|
31
|
+
parts.push(p.displayVolume);
|
|
32
|
+
parts.push(`x${p.pickQuantity} — ${p.totalPrice}`);
|
|
33
|
+
if (p.comparePrice && p.comparePriceUnit) {
|
|
34
|
+
parts.push(`(${p.comparePrice}/${p.comparePriceUnit})`);
|
|
35
|
+
}
|
|
36
|
+
parts.push(`(${p.code})`);
|
|
37
|
+
console.log(parts.join(" "));
|
|
38
|
+
}
|
|
39
|
+
console.log(`Total: ${cart.totalPrice}`);
|
|
40
|
+
}
|
|
41
|
+
async function runOperation(api, op, args) {
|
|
42
|
+
switch (op) {
|
|
43
|
+
case "cart": {
|
|
44
|
+
const cart = await api.getCart();
|
|
45
|
+
printCart(cart);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case "add": {
|
|
49
|
+
const code = args[0];
|
|
50
|
+
const qty = parseInt(args[1] ?? "1", 10);
|
|
51
|
+
if (!code)
|
|
52
|
+
throw new Error("add requires a product code");
|
|
53
|
+
const cart = await api.addToCart([{ code, qty }]);
|
|
54
|
+
console.log(`Added ${qty}x ${code}`);
|
|
55
|
+
printCart(cart);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case "remove": {
|
|
59
|
+
const code = args[0];
|
|
60
|
+
if (!code)
|
|
61
|
+
throw new Error("remove requires a product code");
|
|
62
|
+
const cart = await api.removeFromCart(code);
|
|
63
|
+
console.log(`Removed ${code}`);
|
|
64
|
+
printCart(cart);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "clear": {
|
|
68
|
+
await api.clearCart();
|
|
69
|
+
console.log("Cart cleared.");
|
|
70
|
+
const cart = await api.getCart();
|
|
71
|
+
printCart(cart);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "search": {
|
|
75
|
+
const query = args[0];
|
|
76
|
+
const count = parseInt(args[1] ?? "10", 10);
|
|
77
|
+
if (!query)
|
|
78
|
+
throw new Error("search requires a query");
|
|
79
|
+
const collected = [];
|
|
80
|
+
let page = 0;
|
|
81
|
+
const pageSize = Math.min(count, 30);
|
|
82
|
+
while (collected.length < count) {
|
|
83
|
+
const results = await api.search(query, page, pageSize);
|
|
84
|
+
collected.push(...results.results);
|
|
85
|
+
if (page === 0) {
|
|
86
|
+
console.log(`${results.pagination.totalNumberOfResults} results for "${query}":`);
|
|
87
|
+
}
|
|
88
|
+
if (page + 1 >= results.pagination.numberOfPages)
|
|
89
|
+
break;
|
|
90
|
+
page++;
|
|
91
|
+
}
|
|
92
|
+
for (const p of collected.slice(0, count)) {
|
|
93
|
+
console.log(formatProduct(p));
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case "categories": {
|
|
98
|
+
const tree = await api.getCategories();
|
|
99
|
+
function print(cat, depth) {
|
|
100
|
+
if (depth > 2)
|
|
101
|
+
return;
|
|
102
|
+
console.log(`${" ".repeat(depth)}${cat.title} (${cat.url})`);
|
|
103
|
+
for (const child of cat.children)
|
|
104
|
+
print(child, depth + 1);
|
|
105
|
+
}
|
|
106
|
+
print(tree, 0);
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case "browse": {
|
|
110
|
+
const catPath = args[0];
|
|
111
|
+
const page = parseInt(args[1] ?? "0", 10);
|
|
112
|
+
if (!catPath)
|
|
113
|
+
throw new Error("browse requires a category path");
|
|
114
|
+
const results = await api.browseCategory(catPath, page, 10);
|
|
115
|
+
console.log(`${results.pagination.totalNumberOfResults} products:`);
|
|
116
|
+
for (const p of results.results) {
|
|
117
|
+
console.log(formatProduct(p));
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
default:
|
|
122
|
+
throw new Error(`Unknown operation: ${op}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function usage() {
|
|
126
|
+
console.error(`Usage:
|
|
127
|
+
willys-cli [-u <username> -p <password>] <operation> [args...]
|
|
128
|
+
willys-cli [-u <username> -p <password>] -i <file>
|
|
129
|
+
|
|
130
|
+
Credentials are read from -u/-p flags, or WILLYS_USERNAME/WILLYS_PASSWORD
|
|
131
|
+
environment variables (also loaded from .env).
|
|
132
|
+
|
|
133
|
+
Operations:
|
|
134
|
+
cart Show cart contents
|
|
135
|
+
add <product-code> [qty] Add product to cart
|
|
136
|
+
remove <product-code> Remove product from cart
|
|
137
|
+
clear Clear the cart
|
|
138
|
+
search <query> [count] Search for products (default: 10)
|
|
139
|
+
categories List categories
|
|
140
|
+
browse <category-path> [page] Browse a category
|
|
141
|
+
|
|
142
|
+
File format (CSV, one operation per line):
|
|
143
|
+
add,<product-code>,<quantity>
|
|
144
|
+
remove,<product-code>
|
|
145
|
+
clear
|
|
146
|
+
cart`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
async function main() {
|
|
150
|
+
const argv = process.argv.slice(2);
|
|
151
|
+
let username = "";
|
|
152
|
+
let password = "";
|
|
153
|
+
let inputFile = "";
|
|
154
|
+
const positional = [];
|
|
155
|
+
for (let i = 0; i < argv.length; i++) {
|
|
156
|
+
switch (argv[i]) {
|
|
157
|
+
case "-u":
|
|
158
|
+
username = argv[++i] ?? "";
|
|
159
|
+
break;
|
|
160
|
+
case "-p":
|
|
161
|
+
password = argv[++i] ?? "";
|
|
162
|
+
break;
|
|
163
|
+
case "-i":
|
|
164
|
+
inputFile = argv[++i] ?? "";
|
|
165
|
+
break;
|
|
166
|
+
case "--install-skills": {
|
|
167
|
+
const dir = join(process.cwd(), ".claude", "skills", "willys-cli");
|
|
168
|
+
mkdirSync(dir, { recursive: true });
|
|
169
|
+
const dest = join(dir, "SKILL.md");
|
|
170
|
+
writeFileSync(dest, SKILL_MD);
|
|
171
|
+
console.log(`Installed willys-cli skill to ${dest}`);
|
|
172
|
+
process.exit(0);
|
|
173
|
+
}
|
|
174
|
+
case "-h":
|
|
175
|
+
case "--help":
|
|
176
|
+
usage();
|
|
177
|
+
break;
|
|
178
|
+
default:
|
|
179
|
+
positional.push(argv[i]);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!username)
|
|
183
|
+
username = process.env.WILLYS_USERNAME?.replace(/^"|"$/g, "") ?? "";
|
|
184
|
+
if (!password)
|
|
185
|
+
password = process.env.WILLYS_PASSWORD?.replace(/^"|"$/g, "") ?? "";
|
|
186
|
+
if (!username || !password) {
|
|
187
|
+
console.error("Error: No credentials provided. Use -u/-p flags or set WILLYS_USERNAME/WILLYS_PASSWORD.\n");
|
|
188
|
+
usage();
|
|
189
|
+
}
|
|
190
|
+
if (!inputFile && positional.length === 0)
|
|
191
|
+
usage();
|
|
192
|
+
const api = new WillysApi();
|
|
193
|
+
await api.login(username, password);
|
|
194
|
+
if (inputFile) {
|
|
195
|
+
const content = readFileSync(inputFile, "utf-8");
|
|
196
|
+
const lines = content
|
|
197
|
+
.split("\n")
|
|
198
|
+
.map((l) => l.trim())
|
|
199
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
const [op, ...args] = line.split(",").map((s) => s.trim());
|
|
202
|
+
console.log(`> ${op} ${args.join(" ")}`);
|
|
203
|
+
await runOperation(api, op, args);
|
|
204
|
+
console.log();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
const [op, ...args] = positional;
|
|
209
|
+
await runOperation(api, op, args);
|
|
210
|
+
}
|
|
211
|
+
await api.logout();
|
|
212
|
+
}
|
|
213
|
+
main().catch((e) => {
|
|
214
|
+
console.error(`Error: ${e.message}`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
});
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface EncryptedCredential {
|
|
2
|
+
key: string;
|
|
3
|
+
str: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Encrypts a credential string using AES-128-CBC with PBKDF2 key derivation.
|
|
7
|
+
* This replicates the client-side encryption used by the Willys website
|
|
8
|
+
* (module 89683 in their JS bundle).
|
|
9
|
+
*
|
|
10
|
+
* Algorithm:
|
|
11
|
+
* 1. Generate random 16-byte IV and 16-byte salt
|
|
12
|
+
* 2. Generate random 16-digit numeric key string
|
|
13
|
+
* 3. Derive AES-128-CBC key using PBKDF2 (SHA-1, 1000 iterations)
|
|
14
|
+
* 4. Encrypt plaintext with AES-128-CBC
|
|
15
|
+
* 5. Return { key, str: base64(hex(iv) + "::" + hex(salt) + "::" + base64(ciphertext)) }
|
|
16
|
+
*/
|
|
17
|
+
export declare function encryptCredential(plaintext: string): EncryptedCredential;
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { randomBytes, pbkdf2Sync, createCipheriv } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Encrypts a credential string using AES-128-CBC with PBKDF2 key derivation.
|
|
4
|
+
* This replicates the client-side encryption used by the Willys website
|
|
5
|
+
* (module 89683 in their JS bundle).
|
|
6
|
+
*
|
|
7
|
+
* Algorithm:
|
|
8
|
+
* 1. Generate random 16-byte IV and 16-byte salt
|
|
9
|
+
* 2. Generate random 16-digit numeric key string
|
|
10
|
+
* 3. Derive AES-128-CBC key using PBKDF2 (SHA-1, 1000 iterations)
|
|
11
|
+
* 4. Encrypt plaintext with AES-128-CBC
|
|
12
|
+
* 5. Return { key, str: base64(hex(iv) + "::" + hex(salt) + "::" + base64(ciphertext)) }
|
|
13
|
+
*/
|
|
14
|
+
export function encryptCredential(plaintext) {
|
|
15
|
+
const iv = randomBytes(16);
|
|
16
|
+
const salt = randomBytes(16);
|
|
17
|
+
// Generate a random 16-digit numeric key (like the JS: two 8-digit random number strings)
|
|
18
|
+
const key = Math.random().toString().substring(2, 10) +
|
|
19
|
+
Math.random().toString().substring(2, 10);
|
|
20
|
+
// Derive AES key using PBKDF2
|
|
21
|
+
const derivedKey = pbkdf2Sync(key, salt, 1000, 16, "sha1");
|
|
22
|
+
// Encrypt with AES-128-CBC
|
|
23
|
+
const cipher = createCipheriv("aes-128-cbc", derivedKey, iv);
|
|
24
|
+
const encrypted = Buffer.concat([
|
|
25
|
+
cipher.update(plaintext, "utf8"),
|
|
26
|
+
cipher.final(),
|
|
27
|
+
]);
|
|
28
|
+
// Format: base64(hex(iv) + "::" + hex(salt) + "::" + base64(ciphertext))
|
|
29
|
+
const combined = `${iv.toString("hex")}::${salt.toString("hex")}::${encrypted.toString("base64")}`;
|
|
30
|
+
const str = Buffer.from(combined).toString("base64");
|
|
31
|
+
return { key, str };
|
|
32
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/skill.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SKILL_MD = "---\nname: willys-cli\ndescription: Manages grocery shopping at Willys.se. Search for products, browse categories, and manage a shopping cart. Use when the user wants to find groceries, add/remove items from their Willys cart, or view their cart.\nallowed-tools: Bash(willys-cli:*)\n---\n\n# Willys Grocery CLI\n\nA CLI tool for shopping at Willys.se (Swedish grocery store).\n\nCredentials are read from WILLYS_USERNAME and WILLYS_PASSWORD environment variables,\nor from a .env file in the current directory. They can also be passed with -u and -p flags.\n\n## Commands\n\n### Search for products\n\n```bash\n# Search for products (default 10 results)\nwillys-cli search mj\u00F6lk\n\n# Search with a specific number of results (fetches multiple pages if needed)\nwillys-cli search \"ekologisk mj\u00F6lk\" 20\n```\n\nOutput includes product name, brand, volume, price, compare price, and product code.\n\n### Browse categories\n\n```bash\n# List all top-level categories (with 2 levels of subcategories)\nwillys-cli categories\n\n# Browse products in a specific category\nwillys-cli browse frukt-och-gront/frukt/citrusfrukt\n```\n\nCategory paths use the URL-style paths shown in the categories output (e.g. `kott-chark-och-fagel/korv`).\n\n### Cart operations\n\n```bash\n# Show current cart\nwillys-cli cart\n\n# Add a product (product code from search results, optional quantity defaults to 1)\nwillys-cli add 101233933_ST 2\n\n# Remove a product\nwillys-cli remove 101233933_ST\n\n# Clear entire cart\nwillys-cli clear\n```\n\nCart-modifying operations (add, remove, clear) print the updated cart after each change.\n\n### Batch operations from CSV file\n\n```bash\nwillys-cli -i shopping-list.csv\n```\n\nCSV format (one operation per line, lines starting with # are ignored):\n```\nadd,101233933_ST,2\nadd,101205823_ST,1\nremove,101233933_ST\ncart\nclear\n```\n\n## Product codes\n\nProduct codes look like `101233933_ST` or `100126409_KG`. They are shown in parentheses\nin search and browse output. Always use the exact code from the output.\n\n## Typical workflow\n\n1. Search for a product: `willys-cli search mj\u00F6lk`\n2. Pick a product code from the results\n3. Add it to cart: `willys-cli add 101233933_ST 2`\n4. Review the cart: `willys-cli cart`\n";
|
package/dist/skill.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export const SKILL_MD = `---
|
|
2
|
+
name: willys-cli
|
|
3
|
+
description: Manages grocery shopping at Willys.se. Search for products, browse categories, and manage a shopping cart. Use when the user wants to find groceries, add/remove items from their Willys cart, or view their cart.
|
|
4
|
+
allowed-tools: Bash(willys-cli:*)
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Willys Grocery CLI
|
|
8
|
+
|
|
9
|
+
A CLI tool for shopping at Willys.se (Swedish grocery store).
|
|
10
|
+
|
|
11
|
+
Credentials are read from WILLYS_USERNAME and WILLYS_PASSWORD environment variables,
|
|
12
|
+
or from a .env file in the current directory. They can also be passed with -u and -p flags.
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
### Search for products
|
|
17
|
+
|
|
18
|
+
\`\`\`bash
|
|
19
|
+
# Search for products (default 10 results)
|
|
20
|
+
willys-cli search mjölk
|
|
21
|
+
|
|
22
|
+
# Search with a specific number of results (fetches multiple pages if needed)
|
|
23
|
+
willys-cli search "ekologisk mjölk" 20
|
|
24
|
+
\`\`\`
|
|
25
|
+
|
|
26
|
+
Output includes product name, brand, volume, price, compare price, and product code.
|
|
27
|
+
|
|
28
|
+
### Browse categories
|
|
29
|
+
|
|
30
|
+
\`\`\`bash
|
|
31
|
+
# List all top-level categories (with 2 levels of subcategories)
|
|
32
|
+
willys-cli categories
|
|
33
|
+
|
|
34
|
+
# Browse products in a specific category
|
|
35
|
+
willys-cli browse frukt-och-gront/frukt/citrusfrukt
|
|
36
|
+
\`\`\`
|
|
37
|
+
|
|
38
|
+
Category paths use the URL-style paths shown in the categories output (e.g. \`kott-chark-och-fagel/korv\`).
|
|
39
|
+
|
|
40
|
+
### Cart operations
|
|
41
|
+
|
|
42
|
+
\`\`\`bash
|
|
43
|
+
# Show current cart
|
|
44
|
+
willys-cli cart
|
|
45
|
+
|
|
46
|
+
# Add a product (product code from search results, optional quantity defaults to 1)
|
|
47
|
+
willys-cli add 101233933_ST 2
|
|
48
|
+
|
|
49
|
+
# Remove a product
|
|
50
|
+
willys-cli remove 101233933_ST
|
|
51
|
+
|
|
52
|
+
# Clear entire cart
|
|
53
|
+
willys-cli clear
|
|
54
|
+
\`\`\`
|
|
55
|
+
|
|
56
|
+
Cart-modifying operations (add, remove, clear) print the updated cart after each change.
|
|
57
|
+
|
|
58
|
+
### Batch operations from CSV file
|
|
59
|
+
|
|
60
|
+
\`\`\`bash
|
|
61
|
+
willys-cli -i shopping-list.csv
|
|
62
|
+
\`\`\`
|
|
63
|
+
|
|
64
|
+
CSV format (one operation per line, lines starting with # are ignored):
|
|
65
|
+
\`\`\`
|
|
66
|
+
add,101233933_ST,2
|
|
67
|
+
add,101205823_ST,1
|
|
68
|
+
remove,101233933_ST
|
|
69
|
+
cart
|
|
70
|
+
clear
|
|
71
|
+
\`\`\`
|
|
72
|
+
|
|
73
|
+
## Product codes
|
|
74
|
+
|
|
75
|
+
Product codes look like \`101233933_ST\` or \`100126409_KG\`. They are shown in parentheses
|
|
76
|
+
in search and browse output. Always use the exact code from the output.
|
|
77
|
+
|
|
78
|
+
## Typical workflow
|
|
79
|
+
|
|
80
|
+
1. Search for a product: \`willys-cli search mjölk\`
|
|
81
|
+
2. Pick a product code from the results
|
|
82
|
+
3. Add it to cart: \`willys-cli add 101233933_ST 2\`
|
|
83
|
+
4. Review the cart: \`willys-cli cart\`
|
|
84
|
+
`;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
export interface Customer {
|
|
2
|
+
uid: string;
|
|
3
|
+
name: string;
|
|
4
|
+
firstName: string;
|
|
5
|
+
lastName: string;
|
|
6
|
+
email: string;
|
|
7
|
+
socialSecurityNumer: string;
|
|
8
|
+
storeId: string;
|
|
9
|
+
homeStoreId: string;
|
|
10
|
+
displayUid: string;
|
|
11
|
+
defaultBillingAddress: Address | null;
|
|
12
|
+
defaultShippingAddress: Address | null;
|
|
13
|
+
linkedAccounts: {
|
|
14
|
+
name: string;
|
|
15
|
+
isPrimary: boolean;
|
|
16
|
+
}[];
|
|
17
|
+
bonusInfo: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
export interface Address {
|
|
20
|
+
id: string;
|
|
21
|
+
firstName: string;
|
|
22
|
+
lastName: string;
|
|
23
|
+
line1: string;
|
|
24
|
+
line2: string;
|
|
25
|
+
town: string;
|
|
26
|
+
postalCode: string;
|
|
27
|
+
cellphone: string;
|
|
28
|
+
email: string;
|
|
29
|
+
country: {
|
|
30
|
+
isocode: string;
|
|
31
|
+
name: string;
|
|
32
|
+
};
|
|
33
|
+
formattedAddress: string;
|
|
34
|
+
longitude: number;
|
|
35
|
+
latitude: number;
|
|
36
|
+
}
|
|
37
|
+
export interface Product {
|
|
38
|
+
name: string;
|
|
39
|
+
code: string;
|
|
40
|
+
price: string;
|
|
41
|
+
priceValue: number;
|
|
42
|
+
priceUnit: string;
|
|
43
|
+
priceNoUnit: string;
|
|
44
|
+
comparePrice: string;
|
|
45
|
+
comparePriceUnit: string;
|
|
46
|
+
productLine2: string;
|
|
47
|
+
manufacturer: string;
|
|
48
|
+
displayVolume: string;
|
|
49
|
+
image: ProductImage | null;
|
|
50
|
+
thumbnail: ProductImage | null;
|
|
51
|
+
labels: string[];
|
|
52
|
+
outOfStock: boolean;
|
|
53
|
+
online: boolean;
|
|
54
|
+
addToCartDisabled: boolean;
|
|
55
|
+
productBasketType: {
|
|
56
|
+
code: string;
|
|
57
|
+
type: string;
|
|
58
|
+
};
|
|
59
|
+
incrementValue: number;
|
|
60
|
+
potentialPromotions: Promotion[];
|
|
61
|
+
savingsAmount: string | null;
|
|
62
|
+
depositPrice: string;
|
|
63
|
+
averageWeight: number | null;
|
|
64
|
+
}
|
|
65
|
+
export interface ProductImage {
|
|
66
|
+
imageType: string;
|
|
67
|
+
format: string;
|
|
68
|
+
url: string;
|
|
69
|
+
altText: string | null;
|
|
70
|
+
}
|
|
71
|
+
export interface Promotion {
|
|
72
|
+
code?: string;
|
|
73
|
+
description?: string;
|
|
74
|
+
[key: string]: unknown;
|
|
75
|
+
}
|
|
76
|
+
export interface Pagination {
|
|
77
|
+
pageSize: number;
|
|
78
|
+
currentPage: number;
|
|
79
|
+
sort: string | null;
|
|
80
|
+
numberOfPages: number;
|
|
81
|
+
totalNumberOfResults: number;
|
|
82
|
+
allProductsInCategoriesCount: number;
|
|
83
|
+
allProductsInSearchCount: number;
|
|
84
|
+
}
|
|
85
|
+
export interface SearchResult {
|
|
86
|
+
results: Product[];
|
|
87
|
+
pagination: Pagination;
|
|
88
|
+
facets: unknown[];
|
|
89
|
+
freeTextSearch: string | null;
|
|
90
|
+
categoryCode: string | null;
|
|
91
|
+
categoryName: string | null;
|
|
92
|
+
breadcrumbs: unknown[];
|
|
93
|
+
sorts: unknown[];
|
|
94
|
+
}
|
|
95
|
+
export interface Category {
|
|
96
|
+
id: string;
|
|
97
|
+
category: string;
|
|
98
|
+
title: string;
|
|
99
|
+
url: string;
|
|
100
|
+
valid: boolean;
|
|
101
|
+
children: Category[];
|
|
102
|
+
}
|
|
103
|
+
export interface Cart {
|
|
104
|
+
products: CartProduct[];
|
|
105
|
+
totalPrice: string;
|
|
106
|
+
totalItems: number;
|
|
107
|
+
totalUnitCount: number;
|
|
108
|
+
totalDiscount: string;
|
|
109
|
+
totalTax: string;
|
|
110
|
+
subTotalWithDiscounts: string;
|
|
111
|
+
deliveryCost: string;
|
|
112
|
+
paymentType: string;
|
|
113
|
+
appliedVouchers: unknown[];
|
|
114
|
+
taxes: Record<string, unknown>;
|
|
115
|
+
isocode: string;
|
|
116
|
+
}
|
|
117
|
+
export interface CartProduct {
|
|
118
|
+
code: string;
|
|
119
|
+
name: string;
|
|
120
|
+
price: string;
|
|
121
|
+
priceValue: number;
|
|
122
|
+
quantity: number;
|
|
123
|
+
pickQuantity: number;
|
|
124
|
+
totalPrice: string;
|
|
125
|
+
manufacturer: string;
|
|
126
|
+
displayVolume: string;
|
|
127
|
+
comparePrice: string;
|
|
128
|
+
comparePriceUnit: string;
|
|
129
|
+
image: ProductImage | null;
|
|
130
|
+
productBasketType: {
|
|
131
|
+
code: string;
|
|
132
|
+
type: string;
|
|
133
|
+
};
|
|
134
|
+
[key: string]: unknown;
|
|
135
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Customer, SearchResult, Category, Cart } from "./types.js";
|
|
2
|
+
export declare class WillysApi {
|
|
3
|
+
private cookies;
|
|
4
|
+
private csrfToken;
|
|
5
|
+
/**
|
|
6
|
+
* Make an HTTP request with cookie and CSRF token management.
|
|
7
|
+
*/
|
|
8
|
+
private request;
|
|
9
|
+
/**
|
|
10
|
+
* Parse Set-Cookie headers from a response and store them.
|
|
11
|
+
*/
|
|
12
|
+
private parseCookies;
|
|
13
|
+
/**
|
|
14
|
+
* Fetch a fresh CSRF token from the server.
|
|
15
|
+
*/
|
|
16
|
+
getCsrfToken(): Promise<string>;
|
|
17
|
+
/**
|
|
18
|
+
* Log in to Willys with username (personnummer) and password.
|
|
19
|
+
* Credentials are encrypted client-side before sending.
|
|
20
|
+
*/
|
|
21
|
+
login(username: string, password: string): Promise<Customer>;
|
|
22
|
+
/**
|
|
23
|
+
* Log out from Willys.
|
|
24
|
+
*/
|
|
25
|
+
logout(): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Get the currently logged-in customer's profile.
|
|
28
|
+
*/
|
|
29
|
+
getCustomer(): Promise<Customer>;
|
|
30
|
+
/**
|
|
31
|
+
* Search for products by text query.
|
|
32
|
+
*/
|
|
33
|
+
search(query: string, page?: number, size?: number): Promise<SearchResult>;
|
|
34
|
+
/**
|
|
35
|
+
* Get the full category tree.
|
|
36
|
+
*/
|
|
37
|
+
getCategories(storeId?: string): Promise<Category>;
|
|
38
|
+
/**
|
|
39
|
+
* Browse products in a specific category.
|
|
40
|
+
* @param categoryPath - URL path like "frukt-och-gront/frukt/citrusfrukt"
|
|
41
|
+
*/
|
|
42
|
+
browseCategory(categoryPath: string, page?: number, size?: number, sort?: string): Promise<SearchResult>;
|
|
43
|
+
/**
|
|
44
|
+
* Get the current shopping cart.
|
|
45
|
+
*/
|
|
46
|
+
getCart(): Promise<Cart>;
|
|
47
|
+
/**
|
|
48
|
+
* Add one or more products to the cart.
|
|
49
|
+
*/
|
|
50
|
+
addToCart(products: Array<{
|
|
51
|
+
code: string;
|
|
52
|
+
qty: number;
|
|
53
|
+
}>): Promise<Cart>;
|
|
54
|
+
/**
|
|
55
|
+
* Remove a product from the cart (sets quantity to 0).
|
|
56
|
+
*/
|
|
57
|
+
removeFromCart(productCode: string): Promise<Cart>;
|
|
58
|
+
/**
|
|
59
|
+
* Clear all products from the cart.
|
|
60
|
+
*/
|
|
61
|
+
clearCart(): Promise<void>;
|
|
62
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { encryptCredential } from "./crypto.js";
|
|
2
|
+
const BASE_URL = "https://www.willys.se";
|
|
3
|
+
const DEFAULT_STORE_ID = "2110";
|
|
4
|
+
export class WillysApi {
|
|
5
|
+
cookies = new Map();
|
|
6
|
+
csrfToken = null;
|
|
7
|
+
/**
|
|
8
|
+
* Make an HTTP request with cookie and CSRF token management.
|
|
9
|
+
*/
|
|
10
|
+
async request(path, options = {}) {
|
|
11
|
+
const url = path.startsWith("http") ? path : `${BASE_URL}${path}`;
|
|
12
|
+
const headers = new Headers(options.headers);
|
|
13
|
+
headers.set("Accept", "application/json");
|
|
14
|
+
// Attach cookies
|
|
15
|
+
if (this.cookies.size > 0) {
|
|
16
|
+
const cookieStr = Array.from(this.cookies.entries())
|
|
17
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
18
|
+
.join("; ");
|
|
19
|
+
headers.set("Cookie", cookieStr);
|
|
20
|
+
}
|
|
21
|
+
// Attach CSRF token for mutating requests
|
|
22
|
+
if (options.method &&
|
|
23
|
+
options.method !== "GET" &&
|
|
24
|
+
this.csrfToken) {
|
|
25
|
+
headers.set("X-CSRF-TOKEN", this.csrfToken);
|
|
26
|
+
}
|
|
27
|
+
const response = await fetch(url, {
|
|
28
|
+
...options,
|
|
29
|
+
headers,
|
|
30
|
+
redirect: "manual",
|
|
31
|
+
});
|
|
32
|
+
this.parseCookies(response);
|
|
33
|
+
return response;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parse Set-Cookie headers from a response and store them.
|
|
37
|
+
*/
|
|
38
|
+
parseCookies(response) {
|
|
39
|
+
const setCookieHeaders = response.headers.getSetCookie?.() ?? [];
|
|
40
|
+
for (const header of setCookieHeaders) {
|
|
41
|
+
const parts = header.split(";")[0];
|
|
42
|
+
const eqIdx = parts.indexOf("=");
|
|
43
|
+
if (eqIdx > 0) {
|
|
44
|
+
const name = parts.substring(0, eqIdx).trim();
|
|
45
|
+
const value = parts.substring(eqIdx + 1).trim();
|
|
46
|
+
this.cookies.set(name, value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Fetch a fresh CSRF token from the server.
|
|
52
|
+
*/
|
|
53
|
+
async getCsrfToken() {
|
|
54
|
+
// First ensure we have a session
|
|
55
|
+
if (!this.cookies.has("JSESSIONID")) {
|
|
56
|
+
await this.request("/api/config");
|
|
57
|
+
}
|
|
58
|
+
const response = await this.request("/axfood/rest/csrf-token");
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
throw new Error(`Failed to get CSRF token: ${response.status}`);
|
|
61
|
+
}
|
|
62
|
+
const token = await response.json();
|
|
63
|
+
this.csrfToken = token;
|
|
64
|
+
return token;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Log in to Willys with username (personnummer) and password.
|
|
68
|
+
* Credentials are encrypted client-side before sending.
|
|
69
|
+
*/
|
|
70
|
+
async login(username, password) {
|
|
71
|
+
// Ensure we have a CSRF token
|
|
72
|
+
await this.getCsrfToken();
|
|
73
|
+
// Encrypt credentials
|
|
74
|
+
const encryptedUsername = encryptCredential(username);
|
|
75
|
+
const encryptedPassword = encryptCredential(password);
|
|
76
|
+
const body = {
|
|
77
|
+
j_username: encryptedUsername.str,
|
|
78
|
+
j_username_key: encryptedUsername.key,
|
|
79
|
+
j_password: encryptedPassword.str,
|
|
80
|
+
j_password_key: encryptedPassword.key,
|
|
81
|
+
j_remember_me: true,
|
|
82
|
+
};
|
|
83
|
+
const response = await this.request("/login", {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify(body),
|
|
87
|
+
});
|
|
88
|
+
// Login typically returns 200 on success, may redirect
|
|
89
|
+
if (response.status >= 400) {
|
|
90
|
+
const text = await response.text();
|
|
91
|
+
throw new Error(`Login failed with status ${response.status}: ${text.substring(0, 200)}`);
|
|
92
|
+
}
|
|
93
|
+
// Follow redirect if needed
|
|
94
|
+
const location = response.headers.get("location");
|
|
95
|
+
if (location) {
|
|
96
|
+
await this.request(location);
|
|
97
|
+
}
|
|
98
|
+
// Refresh CSRF token after login
|
|
99
|
+
await this.getCsrfToken();
|
|
100
|
+
return this.getCustomer();
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Log out from Willys.
|
|
104
|
+
*/
|
|
105
|
+
async logout() {
|
|
106
|
+
await this.request("/logout");
|
|
107
|
+
this.csrfToken = null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Get the currently logged-in customer's profile.
|
|
111
|
+
*/
|
|
112
|
+
async getCustomer() {
|
|
113
|
+
const response = await this.request("/axfood/rest/customer");
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
throw new Error(`Failed to get customer: ${response.status}`);
|
|
116
|
+
}
|
|
117
|
+
return response.json();
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Search for products by text query.
|
|
121
|
+
*/
|
|
122
|
+
async search(query, page = 0, size = 30) {
|
|
123
|
+
const params = new URLSearchParams({
|
|
124
|
+
q: query,
|
|
125
|
+
size: size.toString(),
|
|
126
|
+
page: page.toString(),
|
|
127
|
+
});
|
|
128
|
+
const response = await this.request(`/search/clean?${params}`);
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
throw new Error(`Search failed: ${response.status}`);
|
|
131
|
+
}
|
|
132
|
+
return response.json();
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Get the full category tree.
|
|
136
|
+
*/
|
|
137
|
+
async getCategories(storeId = DEFAULT_STORE_ID) {
|
|
138
|
+
const params = new URLSearchParams({
|
|
139
|
+
storeId,
|
|
140
|
+
deviceType: "OTHER",
|
|
141
|
+
});
|
|
142
|
+
const response = await this.request(`/leftMenu/categorytree?${params}`);
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
throw new Error(`Failed to get categories: ${response.status}`);
|
|
145
|
+
}
|
|
146
|
+
return response.json();
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Browse products in a specific category.
|
|
150
|
+
* @param categoryPath - URL path like "frukt-och-gront/frukt/citrusfrukt"
|
|
151
|
+
*/
|
|
152
|
+
async browseCategory(categoryPath, page = 0, size = 30, sort = "") {
|
|
153
|
+
const params = new URLSearchParams({
|
|
154
|
+
page: page.toString(),
|
|
155
|
+
size: size.toString(),
|
|
156
|
+
sort,
|
|
157
|
+
});
|
|
158
|
+
const response = await this.request(`/c/${categoryPath}?${params}`);
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
throw new Error(`Browse category failed: ${response.status}`);
|
|
161
|
+
}
|
|
162
|
+
return response.json();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Get the current shopping cart.
|
|
166
|
+
*/
|
|
167
|
+
async getCart() {
|
|
168
|
+
const response = await this.request("/axfood/rest/cart");
|
|
169
|
+
if (!response.ok) {
|
|
170
|
+
throw new Error(`Failed to get cart: ${response.status}`);
|
|
171
|
+
}
|
|
172
|
+
return response.json();
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Add one or more products to the cart.
|
|
176
|
+
*/
|
|
177
|
+
async addToCart(products) {
|
|
178
|
+
if (!this.csrfToken) {
|
|
179
|
+
await this.getCsrfToken();
|
|
180
|
+
}
|
|
181
|
+
const body = {
|
|
182
|
+
products: products.map((p) => ({
|
|
183
|
+
productCodePost: p.code,
|
|
184
|
+
qty: p.qty,
|
|
185
|
+
pickUnit: "pieces",
|
|
186
|
+
hideDiscountToolTip: false,
|
|
187
|
+
noReplacementFlag: false,
|
|
188
|
+
})),
|
|
189
|
+
};
|
|
190
|
+
const response = await this.request("/axfood/rest/cart/addProducts", {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: { "Content-Type": "application/json" },
|
|
193
|
+
body: JSON.stringify(body),
|
|
194
|
+
});
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
const text = await response.text();
|
|
197
|
+
throw new Error(`Add to cart failed: ${response.status} - ${text.substring(0, 200)}`);
|
|
198
|
+
}
|
|
199
|
+
return this.getCart();
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Remove a product from the cart (sets quantity to 0).
|
|
203
|
+
*/
|
|
204
|
+
async removeFromCart(productCode) {
|
|
205
|
+
return this.addToCart([{ code: productCode, qty: 0 }]);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Clear all products from the cart.
|
|
209
|
+
*/
|
|
210
|
+
async clearCart() {
|
|
211
|
+
if (!this.csrfToken) {
|
|
212
|
+
await this.getCsrfToken();
|
|
213
|
+
}
|
|
214
|
+
const response = await this.request("/axfood/rest/cart", {
|
|
215
|
+
method: "DELETE",
|
|
216
|
+
});
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
throw new Error(`Clear cart failed: ${response.status}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "willys-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "TypeScript library and CLI for the Willys grocery store API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"willys-cli": "dist/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"!dist/test.*"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"prepublishOnly": "npm run build",
|
|
24
|
+
"start": "tsx src/cli.ts",
|
|
25
|
+
"test": "tsx src/test.ts"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"willys",
|
|
29
|
+
"grocery",
|
|
30
|
+
"sweden",
|
|
31
|
+
"shopping",
|
|
32
|
+
"cli"
|
|
33
|
+
],
|
|
34
|
+
"author": "Erik Hellman",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/ErikHellman/willys-agent"
|
|
38
|
+
},
|
|
39
|
+
"license": "ISC",
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"dotenv": "^17.3.1"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^25.5.0",
|
|
45
|
+
"tsx": "^4.21.0",
|
|
46
|
+
"typescript": "^5.9.3"
|
|
47
|
+
}
|
|
48
|
+
}
|