@ktmcp-cli/akeneo 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/AGENT.md ADDED
@@ -0,0 +1,47 @@
1
+ # Akeneo PIM CLI - AI Agent Guide
2
+
3
+ This CLI provides programmatic access to the Akeneo Product Information Management (PIM) API.
4
+
5
+ ## Quick Start for AI Agents
6
+
7
+ ```bash
8
+ akeneo config set --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET \
9
+ --username YOUR_USERNAME --password YOUR_PASSWORD
10
+ akeneo config set --base-url https://your-instance.akeneo.com/api/rest/v1
11
+ akeneo products list
12
+ ```
13
+
14
+ ## Available Commands
15
+
16
+ ### config
17
+ - `akeneo config set --client-id <id> --client-secret <secret> --username <user> --password <pass>` - Set credentials
18
+ - `akeneo config set --base-url <url>` - Set API base URL
19
+ - `akeneo config get <key>` - Get a config value
20
+ - `akeneo config list` - List all config values
21
+
22
+ ### products
23
+ - `akeneo products list` - List products
24
+ - `akeneo products list --limit <n>` - List with limit
25
+ - `akeneo products get <identifier>` - Get product details
26
+ - `akeneo products create --identifier <sku> --family <family>` - Create a product
27
+
28
+ ### categories
29
+ - `akeneo categories list` - List categories
30
+ - `akeneo categories get <code>` - Get category details
31
+ - `akeneo categories create --code <code> --labels <json>` - Create a category
32
+
33
+ ### attributes
34
+ - `akeneo attributes list` - List attributes
35
+ - `akeneo attributes list --type <type>` - List by type
36
+ - `akeneo attributes get <code>` - Get attribute details
37
+ - `akeneo attributes create --code <code> --type <type>` - Create attribute
38
+
39
+ ## Output Format
40
+
41
+ All commands output formatted tables by default. Use `--json` flag for machine-readable JSON output.
42
+
43
+ ## Authentication
44
+
45
+ This CLI uses Akeneo's OAuth2 password grant flow. You need client credentials from your Akeneo PIM instance
46
+ (Settings > API Connections) plus a valid username and password.
47
+ Access tokens are automatically refreshed when they expire.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 KTMCP
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ > "Six months ago, everyone was talking about MCPs. And I was like, screw MCPs. Every MCP would be better as a CLI."
2
+ >
3
+ > — [Peter Steinberger](https://twitter.com/steipete), Founder of OpenClaw
4
+ > [Watch on YouTube (~2:39:00)](https://www.youtube.com/@lexfridman) | [Lex Fridman Podcast #491](https://lexfridman.com/peter-steinberger/)
5
+
6
+ # Akeneo PIM CLI
7
+
8
+ Production-ready CLI for Akeneo PIM (Product Information Management) API.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ npm install -g @ktmcp-cli/akeneo
14
+ ```
15
+
16
+ ## Configuration
17
+
18
+ ```bash
19
+ akeneo config set --client-id YOUR_CLIENT_ID \
20
+ --client-secret YOUR_CLIENT_SECRET \
21
+ --username YOUR_USERNAME \
22
+ --password YOUR_PASSWORD
23
+ akeneo config set --base-url https://your-akeneo-instance.com/api/rest/v1
24
+ ```
25
+
26
+ Get API credentials from your Akeneo PIM instance under Settings > API Connections.
27
+
28
+ ## Usage
29
+
30
+ ### Products
31
+
32
+ ```bash
33
+ # List products
34
+ akeneo products list
35
+ akeneo products list --limit 50
36
+
37
+ # Get product details
38
+ akeneo products get my-product-sku
39
+
40
+ # Create a product
41
+ akeneo products create --identifier new-sku-001 \
42
+ --family clothing \
43
+ --categories "summer,t-shirts" \
44
+ --values '{"name":[{"locale":"en_US","scope":null,"data":"My Product"}]}'
45
+ ```
46
+
47
+ ### Categories
48
+
49
+ ```bash
50
+ # List categories
51
+ akeneo categories list
52
+ akeneo categories list --limit 100
53
+
54
+ # Get category details
55
+ akeneo categories get electronics
56
+
57
+ # Create a category
58
+ akeneo categories create --code summer-collection \
59
+ --parent clothing \
60
+ --labels '{"en_US":"Summer Collection","fr_FR":"Collection Été"}'
61
+ ```
62
+
63
+ ### Attributes
64
+
65
+ ```bash
66
+ # List attributes
67
+ akeneo attributes list
68
+ akeneo attributes list --type pim_catalog_text
69
+
70
+ # Get attribute details
71
+ akeneo attributes get color
72
+
73
+ # Create an attribute
74
+ akeneo attributes create \
75
+ --code material \
76
+ --type pim_catalog_text \
77
+ --group product_info \
78
+ --localizable \
79
+ --labels '{"en_US":"Material","fr_FR":"Matériau"}'
80
+ ```
81
+
82
+ ### Configuration
83
+
84
+ ```bash
85
+ akeneo config set --client-id <id>
86
+ akeneo config get username
87
+ akeneo config list
88
+ ```
89
+
90
+ ## Attribute Types
91
+
92
+ - `pim_catalog_text` - Single-line text
93
+ - `pim_catalog_textarea` - Multi-line text
94
+ - `pim_catalog_number` - Numeric value
95
+ - `pim_catalog_boolean` - Yes/No toggle
96
+ - `pim_catalog_simpleselect` - Single choice from options
97
+ - `pim_catalog_multiselect` - Multiple choices
98
+ - `pim_catalog_date` - Date picker
99
+ - `pim_catalog_image` - Image file
100
+ - `pim_catalog_price_collection` - Price in multiple currencies
101
+ - `pim_catalog_metric` - Measurement with unit
102
+
103
+ ## JSON Output
104
+
105
+ All commands support `--json` flag for machine-readable output:
106
+
107
+ ```bash
108
+ akeneo products list --json
109
+ akeneo attributes get color --json
110
+ ```
111
+
112
+ ## License
113
+
114
+ MIT
package/bin/akeneo.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/index.js';
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@ktmcp-cli/akeneo",
3
+ "version": "1.0.0",
4
+ "description": "Production-ready CLI for Akeneo PIM (Product Information Management) API - Kill The MCP",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "akeneo": "bin/akeneo.js"
9
+ },
10
+ "keywords": ["akeneo", "pim", "cli", "api", "ktmcp", "product-management", "ecommerce"],
11
+ "author": "KTMCP",
12
+ "license": "MIT",
13
+ "dependencies": {
14
+ "commander": "^12.0.0",
15
+ "axios": "^1.6.7",
16
+ "chalk": "^5.3.0",
17
+ "ora": "^8.0.1",
18
+ "conf": "^12.0.0"
19
+ },
20
+ "engines": { "node": ">=18.0.0" },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/ktmcp-cli/akeneo.git"
24
+ },
25
+ "homepage": "https://killthemcp.com/akeneo-cli",
26
+ "bugs": { "url": "https://github.com/ktmcp-cli/akeneo/issues" }
27
+ }
package/src/api.js ADDED
@@ -0,0 +1,213 @@
1
+ import axios from 'axios';
2
+ import { getConfig, setConfig } from './config.js';
3
+
4
+ const DEFAULT_BASE_URL = 'https://demo.akeneo.com/api/rest/v1';
5
+
6
+ async function getAccessToken() {
7
+ const baseUrl = getConfig('baseUrl') || DEFAULT_BASE_URL;
8
+ const clientId = getConfig('clientId');
9
+ const clientSecret = getConfig('clientSecret');
10
+ const username = getConfig('username');
11
+ const password = getConfig('password');
12
+
13
+ // Check if we have a valid cached token
14
+ const cachedToken = getConfig('accessToken');
15
+ const tokenExpiry = getConfig('tokenExpiry');
16
+ if (cachedToken && tokenExpiry && Date.now() < tokenExpiry) {
17
+ return cachedToken;
18
+ }
19
+
20
+ // Get the base URL without /api/rest/v1
21
+ const authBaseUrl = baseUrl.replace('/api/rest/v1', '');
22
+
23
+ try {
24
+ const response = await axios.post(`${authBaseUrl}/api/oauth/v1/token`, {
25
+ grant_type: 'password',
26
+ client_id: clientId,
27
+ client_secret: clientSecret,
28
+ username,
29
+ password
30
+ }, {
31
+ headers: { 'Content-Type': 'application/json' }
32
+ });
33
+
34
+ const { access_token, expires_in } = response.data;
35
+ setConfig('accessToken', access_token);
36
+ setConfig('tokenExpiry', Date.now() + (expires_in * 1000) - 60000);
37
+ return access_token;
38
+ } catch (error) {
39
+ if (error.response) {
40
+ const msg = error.response.data?.message || JSON.stringify(error.response.data);
41
+ throw new Error(`Authentication failed: ${msg}`);
42
+ }
43
+ throw new Error(`Cannot connect to Akeneo at ${authBaseUrl}. Check your base URL.`);
44
+ }
45
+ }
46
+
47
+ async function getClient() {
48
+ const token = await getAccessToken();
49
+ const baseURL = getConfig('baseUrl') || DEFAULT_BASE_URL;
50
+
51
+ return axios.create({
52
+ baseURL,
53
+ headers: {
54
+ 'Authorization': `Bearer ${token}`,
55
+ 'Content-Type': 'application/json',
56
+ 'Accept': 'application/json'
57
+ }
58
+ });
59
+ }
60
+
61
+ function handleApiError(error) {
62
+ if (error.response) {
63
+ const status = error.response.status;
64
+ const data = error.response.data;
65
+ if (status === 401) throw new Error('Authentication failed. Run: akeneo config set --client-id ... --client-secret ... --username ... --password ...');
66
+ if (status === 403) throw new Error('Access forbidden. Check your Akeneo user permissions.');
67
+ if (status === 404) throw new Error('Resource not found in Akeneo.');
68
+ if (status === 422) throw new Error(`Validation error: ${JSON.stringify(data?.errors || data)}`);
69
+ if (status === 429) throw new Error('Rate limit exceeded. Please wait before retrying.');
70
+ const message = data?.message || JSON.stringify(data);
71
+ throw new Error(`API Error (${status}): ${message}`);
72
+ } else if (error.request) {
73
+ const baseURL = getConfig('baseUrl') || DEFAULT_BASE_URL;
74
+ throw new Error(`No response from Akeneo at ${baseURL}. Check your instance URL.`);
75
+ } else {
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ function parseAkeneoList(response) {
81
+ // Akeneo returns paginated results in HAL format
82
+ const data = response.data;
83
+ if (data._embedded && data._embedded.items) {
84
+ return data._embedded.items;
85
+ }
86
+ return Array.isArray(data) ? data : [];
87
+ }
88
+
89
+ // ============================================================
90
+ // PRODUCTS
91
+ // ============================================================
92
+
93
+ export async function listProducts({ limit = 20, page = 1, search } = {}) {
94
+ try {
95
+ const client = await getClient();
96
+ const params = { limit, page };
97
+ if (search) params.search = search;
98
+ const response = await client.get('/products', { params });
99
+ return parseAkeneoList(response);
100
+ } catch (error) {
101
+ handleApiError(error);
102
+ }
103
+ }
104
+
105
+ export async function getProduct(code) {
106
+ try {
107
+ const client = await getClient();
108
+ const response = await client.get(`/products/${code}`);
109
+ return response.data;
110
+ } catch (error) {
111
+ handleApiError(error);
112
+ }
113
+ }
114
+
115
+ export async function createProduct({ identifier, family, categories, values }) {
116
+ try {
117
+ const client = await getClient();
118
+ const body = {
119
+ identifier,
120
+ ...(family && { family }),
121
+ ...(categories && { categories }),
122
+ ...(values && { values })
123
+ };
124
+ await client.post('/products', body);
125
+ return { identifier, family, categories };
126
+ } catch (error) {
127
+ handleApiError(error);
128
+ }
129
+ }
130
+
131
+ // ============================================================
132
+ // CATEGORIES
133
+ // ============================================================
134
+
135
+ export async function listCategories({ limit = 20, page = 1 } = {}) {
136
+ try {
137
+ const client = await getClient();
138
+ const params = { limit, page };
139
+ const response = await client.get('/categories', { params });
140
+ return parseAkeneoList(response);
141
+ } catch (error) {
142
+ handleApiError(error);
143
+ }
144
+ }
145
+
146
+ export async function getCategory(code) {
147
+ try {
148
+ const client = await getClient();
149
+ const response = await client.get(`/categories/${code}`);
150
+ return response.data;
151
+ } catch (error) {
152
+ handleApiError(error);
153
+ }
154
+ }
155
+
156
+ export async function createCategory({ code, parent, labels }) {
157
+ try {
158
+ const client = await getClient();
159
+ const body = {
160
+ code,
161
+ ...(parent && { parent }),
162
+ ...(labels && { labels })
163
+ };
164
+ await client.post('/categories', body);
165
+ return { code, parent, labels };
166
+ } catch (error) {
167
+ handleApiError(error);
168
+ }
169
+ }
170
+
171
+ // ============================================================
172
+ // ATTRIBUTES
173
+ // ============================================================
174
+
175
+ export async function listAttributes({ limit = 20, page = 1, type } = {}) {
176
+ try {
177
+ const client = await getClient();
178
+ const params = { limit, page };
179
+ if (type) params.search = JSON.stringify({ type: [{ operator: '=', value: type }] });
180
+ const response = await client.get('/attributes', { params });
181
+ return parseAkeneoList(response);
182
+ } catch (error) {
183
+ handleApiError(error);
184
+ }
185
+ }
186
+
187
+ export async function getAttribute(code) {
188
+ try {
189
+ const client = await getClient();
190
+ const response = await client.get(`/attributes/${code}`);
191
+ return response.data;
192
+ } catch (error) {
193
+ handleApiError(error);
194
+ }
195
+ }
196
+
197
+ export async function createAttribute({ code, type, group, labels, scopable = false, localizable = false }) {
198
+ try {
199
+ const client = await getClient();
200
+ const body = {
201
+ code,
202
+ type,
203
+ group: group || 'other',
204
+ scopable,
205
+ localizable,
206
+ ...(labels && { labels })
207
+ };
208
+ await client.post('/attributes', body);
209
+ return { code, type, group: group || 'other' };
210
+ } catch (error) {
211
+ handleApiError(error);
212
+ }
213
+ }
package/src/config.js ADDED
@@ -0,0 +1,19 @@
1
+ import Conf from 'conf';
2
+
3
+ const config = new Conf({ projectName: '@ktmcp-cli/akeneo' });
4
+
5
+ export function getConfig(key) {
6
+ return config.get(key);
7
+ }
8
+
9
+ export function setConfig(key, value) {
10
+ config.set(key, value);
11
+ }
12
+
13
+ export function isConfigured() {
14
+ return !!config.get('clientId') && !!config.get('clientSecret') && !!config.get('username') && !!config.get('password');
15
+ }
16
+
17
+ export function getAllConfig() {
18
+ return config.store;
19
+ }
package/src/index.js ADDED
@@ -0,0 +1,430 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { getConfig, setConfig, isConfigured, getAllConfig } from './config.js';
5
+ import {
6
+ listProducts, getProduct, createProduct,
7
+ listCategories, getCategory, createCategory,
8
+ listAttributes, getAttribute, createAttribute
9
+ } from './api.js';
10
+
11
+ const program = new Command();
12
+
13
+ // ============================================================
14
+ // Helpers
15
+ // ============================================================
16
+
17
+ function printSuccess(message) {
18
+ console.log(chalk.green('✓') + ' ' + message);
19
+ }
20
+
21
+ function printError(message) {
22
+ console.error(chalk.red('✗') + ' ' + message);
23
+ }
24
+
25
+ function printTable(data, columns) {
26
+ if (!data || data.length === 0) {
27
+ console.log(chalk.yellow('No results found.'));
28
+ return;
29
+ }
30
+ const widths = {};
31
+ columns.forEach(col => {
32
+ widths[col.key] = col.label.length;
33
+ data.forEach(row => {
34
+ const val = String(col.format ? col.format(row[col.key], row) : (row[col.key] ?? ''));
35
+ if (val.length > widths[col.key]) widths[col.key] = val.length;
36
+ });
37
+ widths[col.key] = Math.min(widths[col.key], 45);
38
+ });
39
+ const header = columns.map(col => col.label.padEnd(widths[col.key])).join(' ');
40
+ console.log(chalk.bold(chalk.cyan(header)));
41
+ console.log(chalk.dim('─'.repeat(header.length)));
42
+ data.forEach(row => {
43
+ const line = columns.map(col => {
44
+ const val = String(col.format ? col.format(row[col.key], row) : (row[col.key] ?? ''));
45
+ return val.substring(0, widths[col.key]).padEnd(widths[col.key]);
46
+ }).join(' ');
47
+ console.log(line);
48
+ });
49
+ console.log(chalk.dim(`\n${data.length} result(s)`));
50
+ }
51
+
52
+ function printJson(data) {
53
+ console.log(JSON.stringify(data, null, 2));
54
+ }
55
+
56
+ async function withSpinner(message, fn) {
57
+ const spinner = ora(message).start();
58
+ try {
59
+ const result = await fn();
60
+ spinner.stop();
61
+ return result;
62
+ } catch (error) {
63
+ spinner.stop();
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ function requireAuth() {
69
+ if (!isConfigured()) {
70
+ printError('Akeneo credentials not configured.');
71
+ console.log('\nRun the following to configure:');
72
+ console.log(chalk.cyan(' akeneo config set --client-id <id> --client-secret <secret> --username <user> --password <pass>'));
73
+ console.log(chalk.cyan(' akeneo config set --base-url https://your-akeneo-instance.com/api/rest/v1'));
74
+ process.exit(1);
75
+ }
76
+ }
77
+
78
+ // ============================================================
79
+ // Program metadata
80
+ // ============================================================
81
+
82
+ program
83
+ .name('akeneo')
84
+ .description(chalk.bold('Akeneo PIM CLI') + ' - Product information management from your terminal')
85
+ .version('1.0.0');
86
+
87
+ // ============================================================
88
+ // CONFIG
89
+ // ============================================================
90
+
91
+ const configCmd = program.command('config').description('Manage CLI configuration');
92
+
93
+ configCmd
94
+ .command('set')
95
+ .description('Set configuration values')
96
+ .option('--client-id <id>', 'Akeneo OAuth2 client ID')
97
+ .option('--client-secret <secret>', 'Akeneo OAuth2 client secret')
98
+ .option('--username <user>', 'Akeneo username')
99
+ .option('--password <pass>', 'Akeneo password')
100
+ .option('--base-url <url>', 'Akeneo API base URL (default: https://demo.akeneo.com/api/rest/v1)')
101
+ .action((options) => {
102
+ if (options.clientId) { setConfig('clientId', options.clientId); printSuccess('Client ID set'); }
103
+ if (options.clientSecret) { setConfig('clientSecret', options.clientSecret); printSuccess('Client secret set'); }
104
+ if (options.username) { setConfig('username', options.username); printSuccess(`Username set: ${options.username}`); }
105
+ if (options.password) { setConfig('password', options.password); printSuccess('Password set'); }
106
+ if (options.baseUrl) { setConfig('baseUrl', options.baseUrl); printSuccess(`Base URL set: ${options.baseUrl}`); }
107
+ if (!options.clientId && !options.clientSecret && !options.username && !options.password && !options.baseUrl) {
108
+ printError('No options provided. Use --client-id, --client-secret, --username, --password, or --base-url');
109
+ }
110
+ });
111
+
112
+ configCmd
113
+ .command('get <key>')
114
+ .description('Get a configuration value')
115
+ .action((key) => {
116
+ const value = getConfig(key);
117
+ if (value === undefined) {
118
+ printError(`Key "${key}" not found`);
119
+ } else {
120
+ const sensitive = ['clientSecret', 'password', 'accessToken'];
121
+ console.log(sensitive.includes(key) ? '****' : value);
122
+ }
123
+ });
124
+
125
+ configCmd
126
+ .command('list')
127
+ .description('List all configuration values')
128
+ .action(() => {
129
+ const all = getAllConfig();
130
+ console.log(chalk.bold('\nAkeneo PIM CLI Configuration\n'));
131
+ console.log('Client ID: ', all.clientId ? chalk.green(all.clientId) : chalk.red('not set'));
132
+ console.log('Client Secret: ', all.clientSecret ? chalk.green('****') : chalk.red('not set'));
133
+ console.log('Username: ', all.username ? chalk.green(all.username) : chalk.red('not set'));
134
+ console.log('Password: ', all.password ? chalk.green('****') : chalk.red('not set'));
135
+ console.log('Base URL: ', all.baseUrl ? chalk.green(all.baseUrl) : chalk.yellow('using default: https://demo.akeneo.com/api/rest/v1'));
136
+ console.log('');
137
+ });
138
+
139
+ // ============================================================
140
+ // PRODUCTS
141
+ // ============================================================
142
+
143
+ const productsCmd = program.command('products').description('Manage PIM products');
144
+
145
+ productsCmd
146
+ .command('list')
147
+ .description('List products')
148
+ .option('--limit <n>', 'Maximum number of results', '20')
149
+ .option('--page <n>', 'Page number', '1')
150
+ .option('--search <query>', 'Search filter (JSON format)')
151
+ .option('--json', 'Output as JSON')
152
+ .action(async (options) => {
153
+ requireAuth();
154
+ try {
155
+ const products = await withSpinner('Fetching products...', () =>
156
+ listProducts({ limit: parseInt(options.limit), page: parseInt(options.page), search: options.search })
157
+ );
158
+ if (options.json) { printJson(products); return; }
159
+ printTable(products, [
160
+ { key: 'identifier', label: 'Identifier' },
161
+ { key: 'family', label: 'Family' },
162
+ { key: 'enabled', label: 'Enabled', format: (v) => v ? chalk.green('Yes') : chalk.red('No') },
163
+ { key: 'categories', label: 'Categories', format: (v) => Array.isArray(v) ? v.join(', ') : v || 'N/A' },
164
+ { key: 'created', label: 'Created', format: (v) => v ? v.substring(0, 10) : 'N/A' },
165
+ { key: 'updated', label: 'Updated', format: (v) => v ? v.substring(0, 10) : 'N/A' }
166
+ ]);
167
+ } catch (error) {
168
+ printError(error.message);
169
+ process.exit(1);
170
+ }
171
+ });
172
+
173
+ productsCmd
174
+ .command('get <identifier>')
175
+ .description('Get details of a specific product')
176
+ .option('--json', 'Output as JSON')
177
+ .action(async (identifier, options) => {
178
+ requireAuth();
179
+ try {
180
+ const product = await withSpinner('Fetching product...', () => getProduct(identifier));
181
+ if (options.json) { printJson(product); return; }
182
+ console.log(chalk.bold('\nProduct Details\n'));
183
+ console.log('Identifier: ', chalk.cyan(product.identifier));
184
+ console.log('Family: ', product.family || 'N/A');
185
+ console.log('Enabled: ', product.enabled ? chalk.green('Yes') : chalk.red('No'));
186
+ console.log('Categories: ', Array.isArray(product.categories) ? product.categories.join(', ') : 'N/A');
187
+ console.log('Groups: ', Array.isArray(product.groups) ? product.groups.join(', ') : 'N/A');
188
+ console.log('Created: ', product.created || 'N/A');
189
+ console.log('Updated: ', product.updated || 'N/A');
190
+ if (product.values && Object.keys(product.values).length > 0) {
191
+ console.log(chalk.bold('\nAttributes (first 10):\n'));
192
+ const entries = Object.entries(product.values).slice(0, 10);
193
+ entries.forEach(([attr, vals]) => {
194
+ const firstVal = vals?.[0];
195
+ const displayVal = firstVal?.data !== undefined ?
196
+ (typeof firstVal.data === 'object' ? JSON.stringify(firstVal.data) : String(firstVal.data)) : 'N/A';
197
+ console.log(` ${chalk.cyan(attr.padEnd(25))} ${displayVal.substring(0, 50)}`);
198
+ });
199
+ }
200
+ console.log('');
201
+ } catch (error) {
202
+ printError(error.message);
203
+ process.exit(1);
204
+ }
205
+ });
206
+
207
+ productsCmd
208
+ .command('create')
209
+ .description('Create a new product')
210
+ .requiredOption('--identifier <id>', 'Product identifier (SKU)')
211
+ .option('--family <family>', 'Product family code')
212
+ .option('--categories <cats>', 'Comma-separated category codes')
213
+ .option('--values <json>', 'Product attribute values as JSON')
214
+ .option('--json', 'Output as JSON')
215
+ .action(async (options) => {
216
+ requireAuth();
217
+ let values;
218
+ if (options.values) {
219
+ try { values = JSON.parse(options.values); } catch {
220
+ printError('Invalid JSON for --values'); process.exit(1);
221
+ }
222
+ }
223
+ const categories = options.categories ? options.categories.split(',').map(c => c.trim()) : undefined;
224
+ try {
225
+ const product = await withSpinner('Creating product...', () =>
226
+ createProduct({ identifier: options.identifier, family: options.family, categories, values })
227
+ );
228
+ if (options.json) { printJson(product); return; }
229
+ printSuccess(`Product created: ${chalk.bold(options.identifier)}`);
230
+ if (options.family) console.log('Family: ', options.family);
231
+ if (categories) console.log('Categories: ', categories.join(', '));
232
+ } catch (error) {
233
+ printError(error.message);
234
+ process.exit(1);
235
+ }
236
+ });
237
+
238
+ // ============================================================
239
+ // CATEGORIES
240
+ // ============================================================
241
+
242
+ const categoriesCmd = program.command('categories').description('Manage PIM categories');
243
+
244
+ categoriesCmd
245
+ .command('list')
246
+ .description('List categories')
247
+ .option('--limit <n>', 'Maximum number of results', '20')
248
+ .option('--json', 'Output as JSON')
249
+ .action(async (options) => {
250
+ requireAuth();
251
+ try {
252
+ const categories = await withSpinner('Fetching categories...', () =>
253
+ listCategories({ limit: parseInt(options.limit) })
254
+ );
255
+ if (options.json) { printJson(categories); return; }
256
+ printTable(categories, [
257
+ { key: 'code', label: 'Code' },
258
+ { key: 'parent', label: 'Parent', format: (v) => v || '(root)' },
259
+ { key: 'labels', label: 'Label (en)', format: (v) => v?.en_US || Object.values(v || {})[0] || 'N/A' }
260
+ ]);
261
+ } catch (error) {
262
+ printError(error.message);
263
+ process.exit(1);
264
+ }
265
+ });
266
+
267
+ categoriesCmd
268
+ .command('get <code>')
269
+ .description('Get details of a specific category')
270
+ .option('--json', 'Output as JSON')
271
+ .action(async (code, options) => {
272
+ requireAuth();
273
+ try {
274
+ const category = await withSpinner('Fetching category...', () => getCategory(code));
275
+ if (options.json) { printJson(category); return; }
276
+ console.log(chalk.bold('\nCategory Details\n'));
277
+ console.log('Code: ', chalk.cyan(category.code));
278
+ console.log('Parent: ', category.parent || '(root)');
279
+ if (category.labels) {
280
+ console.log('Labels:');
281
+ Object.entries(category.labels).forEach(([locale, label]) => {
282
+ console.log(` ${locale}: ${label}`);
283
+ });
284
+ }
285
+ console.log('');
286
+ } catch (error) {
287
+ printError(error.message);
288
+ process.exit(1);
289
+ }
290
+ });
291
+
292
+ categoriesCmd
293
+ .command('create')
294
+ .description('Create a new category')
295
+ .requiredOption('--code <code>', 'Category code')
296
+ .option('--parent <parent>', 'Parent category code')
297
+ .option('--labels <json>', 'Labels as JSON, e.g. \'{"en_US":"Electronics","fr_FR":"Électronique"}\'')
298
+ .option('--json', 'Output as JSON')
299
+ .action(async (options) => {
300
+ requireAuth();
301
+ let labels;
302
+ if (options.labels) {
303
+ try { labels = JSON.parse(options.labels); } catch {
304
+ printError('Invalid JSON for --labels'); process.exit(1);
305
+ }
306
+ }
307
+ try {
308
+ const category = await withSpinner('Creating category...', () =>
309
+ createCategory({ code: options.code, parent: options.parent, labels })
310
+ );
311
+ if (options.json) { printJson(category); return; }
312
+ printSuccess(`Category created: ${chalk.bold(options.code)}`);
313
+ if (options.parent) console.log('Parent: ', options.parent);
314
+ } catch (error) {
315
+ printError(error.message);
316
+ process.exit(1);
317
+ }
318
+ });
319
+
320
+ // ============================================================
321
+ // ATTRIBUTES
322
+ // ============================================================
323
+
324
+ const attributesCmd = program.command('attributes').description('Manage PIM attributes');
325
+
326
+ attributesCmd
327
+ .command('list')
328
+ .description('List attributes')
329
+ .option('--limit <n>', 'Maximum number of results', '20')
330
+ .option('--type <type>', 'Filter by attribute type (pim_catalog_text|pim_catalog_number|pim_catalog_boolean|etc)')
331
+ .option('--json', 'Output as JSON')
332
+ .action(async (options) => {
333
+ requireAuth();
334
+ try {
335
+ const attributes = await withSpinner('Fetching attributes...', () =>
336
+ listAttributes({ limit: parseInt(options.limit), type: options.type })
337
+ );
338
+ if (options.json) { printJson(attributes); return; }
339
+ printTable(attributes, [
340
+ { key: 'code', label: 'Code' },
341
+ { key: 'type', label: 'Type' },
342
+ { key: 'group', label: 'Group' },
343
+ { key: 'localizable', label: 'Localizable', format: (v) => v ? 'Yes' : 'No' },
344
+ { key: 'scopable', label: 'Scopable', format: (v) => v ? 'Yes' : 'No' },
345
+ { key: 'labels', label: 'Label (en)', format: (v) => v?.en_US || Object.values(v || {})[0] || 'N/A' }
346
+ ]);
347
+ } catch (error) {
348
+ printError(error.message);
349
+ process.exit(1);
350
+ }
351
+ });
352
+
353
+ attributesCmd
354
+ .command('get <code>')
355
+ .description('Get details of a specific attribute')
356
+ .option('--json', 'Output as JSON')
357
+ .action(async (code, options) => {
358
+ requireAuth();
359
+ try {
360
+ const attr = await withSpinner('Fetching attribute...', () => getAttribute(code));
361
+ if (options.json) { printJson(attr); return; }
362
+ console.log(chalk.bold('\nAttribute Details\n'));
363
+ console.log('Code: ', chalk.cyan(attr.code));
364
+ console.log('Type: ', chalk.bold(attr.type));
365
+ console.log('Group: ', attr.group || 'N/A');
366
+ console.log('Localizable: ', attr.localizable ? chalk.green('Yes') : 'No');
367
+ console.log('Scopable: ', attr.scopable ? chalk.green('Yes') : 'No');
368
+ console.log('Unique: ', attr.unique ? chalk.yellow('Yes') : 'No');
369
+ console.log('Required: ', attr.is_required ? chalk.yellow('Yes') : 'No');
370
+ if (attr.labels) {
371
+ console.log('Labels:');
372
+ Object.entries(attr.labels).forEach(([locale, label]) => {
373
+ console.log(` ${locale}: ${label}`);
374
+ });
375
+ }
376
+ console.log('');
377
+ } catch (error) {
378
+ printError(error.message);
379
+ process.exit(1);
380
+ }
381
+ });
382
+
383
+ attributesCmd
384
+ .command('create')
385
+ .description('Create a new attribute')
386
+ .requiredOption('--code <code>', 'Attribute code')
387
+ .requiredOption('--type <type>', 'Attribute type (pim_catalog_text|pim_catalog_number|pim_catalog_boolean|pim_catalog_textarea|pim_catalog_simpleselect|pim_catalog_multiselect|pim_catalog_date|pim_catalog_file|pim_catalog_image|pim_catalog_price_collection|pim_catalog_metric)')
388
+ .option('--group <group>', 'Attribute group code', 'other')
389
+ .option('--localizable', 'Make attribute localizable')
390
+ .option('--scopable', 'Make attribute scopable')
391
+ .option('--labels <json>', 'Labels as JSON, e.g. \'{"en_US":"Color","fr_FR":"Couleur"}\'')
392
+ .option('--json', 'Output as JSON')
393
+ .action(async (options) => {
394
+ requireAuth();
395
+ let labels;
396
+ if (options.labels) {
397
+ try { labels = JSON.parse(options.labels); } catch {
398
+ printError('Invalid JSON for --labels'); process.exit(1);
399
+ }
400
+ }
401
+ try {
402
+ const attr = await withSpinner('Creating attribute...', () =>
403
+ createAttribute({
404
+ code: options.code,
405
+ type: options.type,
406
+ group: options.group,
407
+ localizable: !!options.localizable,
408
+ scopable: !!options.scopable,
409
+ labels
410
+ })
411
+ );
412
+ if (options.json) { printJson(attr); return; }
413
+ printSuccess(`Attribute created: ${chalk.bold(options.code)}`);
414
+ console.log('Type: ', options.type);
415
+ console.log('Group: ', options.group);
416
+ } catch (error) {
417
+ printError(error.message);
418
+ process.exit(1);
419
+ }
420
+ });
421
+
422
+ // ============================================================
423
+ // Parse
424
+ // ============================================================
425
+
426
+ program.parse(process.argv);
427
+
428
+ if (process.argv.length <= 2) {
429
+ program.help();
430
+ }