amazon-sp-cli 0.1.4__tar.gz → 0.2.0__tar.gz

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.
Files changed (31) hide show
  1. {amazon_sp_cli-0.1.4/amazon_sp_cli.egg-info → amazon_sp_cli-0.2.0}/PKG-INFO +1 -1
  2. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/README.md +37 -0
  3. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli/auth.py +5 -1
  4. amazon_sp_cli-0.2.0/amazon_sp_cli/cli.py +92 -0
  5. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli/client.py +79 -0
  6. amazon_sp_cli-0.2.0/amazon_sp_cli/commands/__init__.py +1 -0
  7. amazon_sp_cli-0.2.0/amazon_sp_cli/commands/a_plus.py +233 -0
  8. amazon_sp_cli-0.2.0/amazon_sp_cli/commands/auth.py +135 -0
  9. amazon_sp_cli-0.2.0/amazon_sp_cli/commands/pricing.py +232 -0
  10. amazon_sp_cli-0.2.0/amazon_sp_cli/main.py +10 -0
  11. amazon_sp_cli-0.2.0/amazon_sp_cli/models/__init__.py +1 -0
  12. amazon_sp_cli-0.2.0/amazon_sp_cli/models/a_plus.py +338 -0
  13. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0/amazon_sp_cli.egg-info}/PKG-INFO +1 -1
  14. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli.egg-info/SOURCES.txt +9 -1
  15. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/setup.py +1 -1
  16. amazon_sp_cli-0.2.0/tests/test_a_plus.py +379 -0
  17. amazon_sp_cli-0.1.4/tests/test_sale_price.py → amazon_sp_cli-0.2.0/tests/test_pricing.py +11 -11
  18. amazon_sp_cli-0.1.4/amazon_sp_cli/main.py +0 -464
  19. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/LICENSE +0 -0
  20. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/MANIFEST.in +0 -0
  21. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli/__init__.py +0 -0
  22. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli/__main__.py +0 -0
  23. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli.egg-info/dependency_links.txt +0 -0
  24. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli.egg-info/entry_points.txt +0 -0
  25. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli.egg-info/requires.txt +0 -0
  26. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/amazon_sp_cli.egg-info/top_level.txt +0 -0
  27. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/pyproject.toml +0 -0
  28. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/setup.cfg +0 -0
  29. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/tests/__init__.py +0 -0
  30. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/tests/test_auth.py +0 -0
  31. {amazon_sp_cli-0.1.4 → amazon_sp_cli-0.2.0}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-sp-cli
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: CLI tool for Amazon Selling Partner API (SP-API) operations
5
5
  Home-page: https://github.com/stellaraether/amazon-sp-cli
6
6
  Author: Lunan Li
@@ -9,6 +9,7 @@ Command line interface for Amazon Selling Partner API (SP-API) operations.
9
9
  - **Catalog Lookup** — Check competitor ASINs
10
10
  - **Inventory Checks** — View FBA inventory levels
11
11
  - **Feed Submissions** — Bulk updates via feeds API
12
+ - **A+ Content** — Manage A+ Content documents and image uploads (requires Brand Registry)
12
13
 
13
14
  ## Installation
14
15
 
@@ -75,6 +76,42 @@ amz-sp inventory list
75
76
  amz-sp inventory get PAW2603190101-BLU
76
77
  ```
77
78
 
79
+ ### A+ Content
80
+
81
+ Requires Brand Registry.
82
+
83
+ ```bash
84
+ # Upload an image (returns uploadDestinationId)
85
+ amz-sp a-plus upload-image banner.jpg
86
+
87
+ # Create content from JSON
88
+ amz-sp a-plus create my-content --data content.json
89
+
90
+ # Validate without creating
91
+ amz-sp a-plus validate my-content --data content.json
92
+
93
+ # Get content details (includes approval status)
94
+ amz-sp a-plus get my-content
95
+
96
+ # List all content documents
97
+ amz-sp a-plus list
98
+
99
+ # Update content
100
+ amz-sp a-plus update my-content --data content.json
101
+
102
+ # Delete content
103
+ amz-sp a-plus delete my-content
104
+
105
+ # Associate ASINs
106
+ amz-sp a-plus asin add my-content B123456789 B987654321
107
+
108
+ # List associated ASINs
109
+ amz-sp a-plus asin list my-content
110
+
111
+ # Remove ASIN associations
112
+ amz-sp a-plus asin remove my-content B123456789
113
+ ```
114
+
78
115
  ## Development
79
116
 
80
117
  ```bash
@@ -4,6 +4,7 @@ import json
4
4
  import os
5
5
  import time
6
6
  from pathlib import Path
7
+ from typing import Optional
7
8
 
8
9
  import requests
9
10
  import yaml
@@ -20,11 +21,14 @@ class SPAPIAuth:
20
21
  self.credentials = self._load_credentials(credentials_path)
21
22
  self._ensure_cache_dir()
22
23
 
23
- def _load_credentials(self, path: str = None) -> dict:
24
+ def _load_credentials(self, path: str = None) -> Optional[dict]:
24
25
  """Load credentials from YAML file."""
25
26
  if path is None:
26
27
  path = Path.home() / ".config" / "amazon-sp-cli" / "credentials.yml"
27
28
 
29
+ if not Path(path).exists():
30
+ return None
31
+
28
32
  with open(path, "r") as f:
29
33
  config = yaml.safe_load(f)
30
34
 
@@ -0,0 +1,92 @@
1
+ """Core CLI infrastructure and shared utilities."""
2
+
3
+ import functools
4
+ import os
5
+ import sys
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from .auth import SPAPIAuth
12
+ from .client import SPAPIClient
13
+
14
+ DEFAULT_CREDENTIALS_PATH = os.path.expanduser("~/.config/amazon-sp-cli/credentials.yml")
15
+
16
+
17
+ def _check_path():
18
+ """Check if the CLI is accessible in PATH and warn once per day."""
19
+ import shutil
20
+
21
+ if shutil.which("amz-sp"):
22
+ return
23
+
24
+ # Only warn once per day
25
+ flag_file = Path.home() / ".config" / "amazon-sp-cli" / ".path-warned"
26
+ flag_file.parent.mkdir(parents=True, exist_ok=True)
27
+
28
+ now = datetime.now(timezone.utc)
29
+ today = now.strftime("%Y-%m-%d")
30
+
31
+ if flag_file.exists():
32
+ last_warned = flag_file.read_text().strip()
33
+ if last_warned == today:
34
+ return
35
+
36
+ flag_file.write_text(today)
37
+
38
+ print(
39
+ "\n⚠️ Note: 'amz-sp' is not in your PATH.",
40
+ file=sys.stderr,
41
+ )
42
+ print(
43
+ " You can still use: python3 -m amazon_sp_cli",
44
+ file=sys.stderr,
45
+ )
46
+ print(
47
+ " To add to PATH, add this to your shell config:",
48
+ file=sys.stderr,
49
+ )
50
+ print(
51
+ f' export PATH="{sys.prefix}/bin:$PATH"',
52
+ file=sys.stderr,
53
+ )
54
+ print("", file=sys.stderr)
55
+
56
+
57
+ def _ensure_auth_client(ctx):
58
+ """Lazily create auth and client if not already present."""
59
+ if "client" not in ctx.obj:
60
+ auth = SPAPIAuth(ctx.obj.get("credentials_path"))
61
+ if auth.credentials is None:
62
+ click.echo("Error: No credentials found. Run 'amz-sp auth setup' first.", err=True)
63
+ raise click.Abort()
64
+ ctx.obj["auth"] = auth
65
+ ctx.obj["client"] = SPAPIClient(auth)
66
+ return ctx.obj["auth"], ctx.obj["client"]
67
+
68
+
69
+ def handle_errors(f):
70
+ """Decorator to catch exceptions and emit consistent error messages."""
71
+
72
+ @functools.wraps(f)
73
+ def wrapper(*args, **kwargs):
74
+ try:
75
+ return f(*args, **kwargs)
76
+ except click.ClickException:
77
+ raise
78
+ except Exception as e:
79
+ click.echo(f"Error: {e}", err=True)
80
+ raise click.Abort()
81
+
82
+ return wrapper
83
+
84
+
85
+ @click.group()
86
+ @click.option("--credentials", "-c", help="Path to credentials YAML file")
87
+ @click.pass_context
88
+ def cli(ctx, credentials):
89
+ """Amazon SP-API CLI - Manage listings, pricing, inventory, and more."""
90
+ _check_path()
91
+ ctx.ensure_object(dict)
92
+ ctx.obj["credentials_path"] = credentials
@@ -101,3 +101,82 @@ class SPAPIClient:
101
101
  }
102
102
  path += "?" + urlencode(params)
103
103
  return self.request("GET", path)
104
+
105
+ # --- A+ Content API ---
106
+
107
+ def create_a_plus_content(self, content_data: dict) -> dict:
108
+ """Create A+ Content document."""
109
+ path = "/aplus/2020-11-01/contentDocuments"
110
+ data = {"contentDocument": content_data}
111
+ return self.request("POST", path, data)
112
+
113
+ def validate_a_plus_content(self, content_data: dict) -> dict:
114
+ """Validate A+ Content without creating."""
115
+ path = "/aplus/2020-11-01/contentDocuments"
116
+ params = {"mode": "VALIDATION_PREVIEW"}
117
+ path += "?" + urlencode(params)
118
+ data = {"contentDocument": content_data}
119
+ return self.request("POST", path, data)
120
+
121
+ def update_a_plus_content(self, content_name: str, content_data: dict) -> dict:
122
+ """Update existing A+ Content document."""
123
+ path = f"/aplus/2020-11-01/contentDocuments/{content_name}"
124
+ data = {"contentDocument": content_data}
125
+ return self.request("POST", path, data)
126
+
127
+ def get_a_plus_content(self, content_name: str) -> dict:
128
+ """Get A+ Content document by name."""
129
+ path = f"/aplus/2020-11-01/contentDocuments/{content_name}"
130
+ return self.request("GET", path)
131
+
132
+ def list_a_plus_content(self, **filters) -> dict:
133
+ """List A+ Content documents."""
134
+ path = "/aplus/2020-11-01/contentDocuments"
135
+ if filters:
136
+ path += "?" + urlencode(filters)
137
+ return self.request("GET", path)
138
+
139
+ def delete_a_plus_content(self, content_name: str) -> dict:
140
+ """Delete A+ Content document."""
141
+ path = f"/aplus/2020-11-01/contentDocuments/{content_name}"
142
+ return self.request("DELETE", path)
143
+
144
+ def get_a_plus_content_asin_relations(self, content_name: str) -> dict:
145
+ """Get ASIN relations for a content document."""
146
+ path = f"/aplus/2020-11-01/contentDocuments/{content_name}/asins"
147
+ return self.request("GET", path)
148
+
149
+ def post_a_plus_content_asin_relations(self, content_name: str, asin_set: list) -> dict:
150
+ """Associate ASINs with A+ Content document."""
151
+ path = "/aplus/2020-11-01/contentAsinRelations"
152
+ data = {"contentDocumentName": content_name, "asinSet": asin_set}
153
+ return self.request("POST", path, data)
154
+
155
+ def delete_a_plus_content_asin_relations(self, content_name: str, asin_set: list) -> dict:
156
+ """Remove ASIN associations from A+ Content document."""
157
+ path = "/aplus/2020-11-01/contentAsinRelations"
158
+ params = {"contentDocumentName": content_name}
159
+ path += "?" + urlencode(params)
160
+ data = {"asinSet": asin_set}
161
+ return self.request("DELETE", path, data)
162
+
163
+ def create_upload_destination(
164
+ self,
165
+ marketplace_id: str,
166
+ content_md5: str,
167
+ content_type: str,
168
+ file_name: str = None,
169
+ resource: str = None,
170
+ ) -> dict:
171
+ """Create an upload destination for a file."""
172
+ path = f"/uploads/2020-11-01/uploadDestinations/{marketplace_id}"
173
+ params = {
174
+ "contentMD5": content_md5,
175
+ "contentType": content_type,
176
+ }
177
+ if file_name:
178
+ params["fileName"] = file_name
179
+ if resource:
180
+ params["resource"] = resource
181
+ path += "?" + urlencode(params)
182
+ return self.request("POST", path)
@@ -0,0 +1 @@
1
+ """CLI command modules."""
@@ -0,0 +1,233 @@
1
+ """A+ Content CLI commands."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+
8
+ import click
9
+ import requests
10
+
11
+ from ..cli import handle_errors
12
+ from ..models.a_plus import build_content_from_json
13
+
14
+
15
+ def register_a_plus_commands(cli_group, ensure_auth_client):
16
+ """Register A+ Content CLI commands."""
17
+
18
+ @cli_group.group("a-plus")
19
+ def a_plus():
20
+ """A+ Content management (requires Brand Registry)."""
21
+ pass
22
+
23
+ @a_plus.command("create")
24
+ @click.argument("content-name")
25
+ @click.option("--data", "-d", type=click.Path(exists=True), required=True, help="JSON file with content data")
26
+ @click.option("--dry-run", is_flag=True, help="Validate without creating")
27
+ @click.pass_context
28
+ @handle_errors
29
+ def create_content(ctx, content_name, data, dry_run):
30
+ """Create A+ Content document from JSON file."""
31
+ _, client = ensure_auth_client(ctx)
32
+
33
+ with open(data, "r") as f:
34
+ content_data = json.load(f)
35
+
36
+ content = build_content_from_json(content_name, content_data)
37
+
38
+ issues = content.validate()
39
+ if issues:
40
+ click.echo("Validation issues:", err=True)
41
+ for issue in issues:
42
+ click.echo(f" - {issue}", err=True)
43
+ raise click.Abort()
44
+
45
+ if dry_run:
46
+ click.echo("Content is valid (dry run)")
47
+ click.echo(json.dumps(content.to_dict(), indent=2))
48
+ return
49
+
50
+ response = client.create_a_plus_content(content.to_dict())
51
+ click.echo(f"A+ Content created: {content_name}")
52
+ click.echo(f"Modules: {len(content.content_module_list)}")
53
+ if response:
54
+ click.echo(json.dumps(response, indent=2))
55
+
56
+ @a_plus.command("validate")
57
+ @click.argument("content-name")
58
+ @click.option("--data", "-d", type=click.Path(exists=True), required=True, help="JSON file with content data")
59
+ @click.pass_context
60
+ @handle_errors
61
+ def validate_content(ctx, content_name, data):
62
+ """Validate A+ Content without creating."""
63
+ _, client = ensure_auth_client(ctx)
64
+
65
+ with open(data, "r") as f:
66
+ content_data = json.load(f)
67
+
68
+ content = build_content_from_json(content_name, content_data)
69
+
70
+ issues = content.validate()
71
+ if issues:
72
+ click.echo("Validation issues:", err=True)
73
+ for issue in issues:
74
+ click.echo(f" - {issue}", err=True)
75
+ raise click.Abort()
76
+
77
+ response = client.validate_a_plus_content(content.to_dict())
78
+ click.echo("API validation passed")
79
+ if response:
80
+ click.echo(json.dumps(response, indent=2))
81
+
82
+ @a_plus.command("get")
83
+ @click.argument("content-name")
84
+ @click.pass_context
85
+ @handle_errors
86
+ def get_content(ctx, content_name):
87
+ """Get A+ Content document."""
88
+ _, client = ensure_auth_client(ctx)
89
+ response = client.get_a_plus_content(content_name)
90
+ click.echo(json.dumps(response, indent=2))
91
+
92
+ @a_plus.command("list")
93
+ @click.pass_context
94
+ @handle_errors
95
+ def list_content(ctx):
96
+ """List A+ Content documents."""
97
+ _, client = ensure_auth_client(ctx)
98
+ response = client.list_a_plus_content(marketplaceId=client.marketplace_id)
99
+ documents = response.get("contentDocumentList", [])
100
+ click.echo(f"\nA+ Content Documents ({len(documents)} found):\n")
101
+ for doc in documents:
102
+ click.echo(f" Name: {doc.get('name')}")
103
+ click.echo(f" Status: {doc.get('status', 'N/A')}")
104
+ click.echo(f" Locale: {doc.get('locale', 'N/A')}")
105
+ asins = doc.get("asinSet", [])
106
+ if asins:
107
+ click.echo(f" ASINs: {', '.join(asins)}")
108
+ click.echo()
109
+
110
+ @a_plus.command("update")
111
+ @click.argument("content-name")
112
+ @click.option("--data", "-d", type=click.Path(exists=True), required=True, help="JSON file with updated content")
113
+ @click.pass_context
114
+ @handle_errors
115
+ def update_content(ctx, content_name, data):
116
+ """Update existing A+ Content document."""
117
+ _, client = ensure_auth_client(ctx)
118
+
119
+ with open(data, "r") as f:
120
+ content_data = json.load(f)
121
+
122
+ content = build_content_from_json(content_name, content_data)
123
+
124
+ issues = content.validate()
125
+ if issues:
126
+ click.echo("Validation issues:", err=True)
127
+ for issue in issues:
128
+ click.echo(f" - {issue}", err=True)
129
+ raise click.Abort()
130
+
131
+ response = client.update_a_plus_content(content_name, content.to_dict())
132
+ click.echo(f"A+ Content updated: {content_name}")
133
+ if response:
134
+ click.echo(json.dumps(response, indent=2))
135
+
136
+ @a_plus.command("delete")
137
+ @click.argument("content-name")
138
+ @click.confirmation_option(prompt="Delete this A+ Content?")
139
+ @click.pass_context
140
+ @handle_errors
141
+ def delete_content(ctx, content_name):
142
+ """Delete A+ Content document."""
143
+ _, client = ensure_auth_client(ctx)
144
+ client.delete_a_plus_content(content_name)
145
+ click.echo(f"A+ Content deleted: {content_name}")
146
+
147
+ @a_plus.command("upload-image")
148
+ @click.argument("file-path", type=click.Path(exists=True))
149
+ @click.option("--content-type", default="image/jpeg", help="MIME type of the image")
150
+ @click.option("--resource", default="aplus", help="Resource type for upload destination")
151
+ @click.pass_context
152
+ @handle_errors
153
+ def upload_image(ctx, file_path, content_type, resource):
154
+ """Upload an image and return an uploadDestinationId for A+ Content."""
155
+ _, client = ensure_auth_client(ctx)
156
+
157
+ with open(file_path, "rb") as f:
158
+ file_data = f.read()
159
+
160
+ md5_hash = hashlib.md5(file_data).digest()
161
+ content_md5 = base64.b64encode(md5_hash).decode("ascii")
162
+ file_name = os.path.basename(file_path)
163
+
164
+ response = client.create_upload_destination(
165
+ marketplace_id=client.marketplace_id,
166
+ content_md5=content_md5,
167
+ content_type=content_type,
168
+ file_name=file_name,
169
+ resource=resource,
170
+ )
171
+
172
+ payload = response.get("payload", response)
173
+ upload_destination_id = payload.get("uploadDestinationId")
174
+ upload_url = payload.get("url")
175
+ headers = payload.get("headers", [])
176
+
177
+ if not upload_url:
178
+ click.echo("Error: No upload URL returned", err=True)
179
+ raise click.Abort()
180
+
181
+ upload_headers = {h["name"]: h["value"] for h in headers}
182
+ upload_headers.setdefault("Content-Type", content_type)
183
+ upload_headers.setdefault("Content-MD5", content_md5)
184
+
185
+ put_response = requests.put(upload_url, data=file_data, headers=upload_headers)
186
+ put_response.raise_for_status()
187
+
188
+ click.echo(f"Upload successful: {file_name}")
189
+ click.echo(f"uploadDestinationId: {upload_destination_id}")
190
+
191
+ @a_plus.group("asin")
192
+ def asin():
193
+ """ASIN association commands."""
194
+ pass
195
+
196
+ @asin.command("add")
197
+ @click.argument("content-name")
198
+ @click.argument("asins", nargs=-1, required=True)
199
+ @click.pass_context
200
+ @handle_errors
201
+ def add_asins(ctx, content_name, asins):
202
+ """Associate ASINs with A+ Content."""
203
+ _, client = ensure_auth_client(ctx)
204
+ response = client.post_a_plus_content_asin_relations(content_name, list(asins))
205
+ click.echo(f"Associated {len(asins)} ASIN(s) with {content_name}")
206
+ if response:
207
+ click.echo(json.dumps(response, indent=2))
208
+
209
+ @asin.command("remove")
210
+ @click.argument("content-name")
211
+ @click.argument("asins", nargs=-1, required=True)
212
+ @click.pass_context
213
+ @handle_errors
214
+ def remove_asins(ctx, content_name, asins):
215
+ """Remove ASIN associations from A+ Content."""
216
+ _, client = ensure_auth_client(ctx)
217
+ response = client.delete_a_plus_content_asin_relations(content_name, list(asins))
218
+ click.echo(f"Removed {len(asins)} ASIN(s) from {content_name}")
219
+ if response:
220
+ click.echo(json.dumps(response, indent=2))
221
+
222
+ @asin.command("list")
223
+ @click.argument("content-name")
224
+ @click.pass_context
225
+ @handle_errors
226
+ def list_asins(ctx, content_name):
227
+ """List ASINs associated with A+ Content."""
228
+ _, client = ensure_auth_client(ctx)
229
+ response = client.get_a_plus_content_asin_relations(content_name)
230
+ asins = response.get("asinSet", [])
231
+ click.echo(f"\nASINs for '{content_name}' ({len(asins)} found):\n")
232
+ for asin in asins:
233
+ click.echo(f" {asin}")
@@ -0,0 +1,135 @@
1
+ """Authentication commands."""
2
+
3
+ import os
4
+
5
+ import click
6
+ import yaml
7
+
8
+ from ..cli import DEFAULT_CREDENTIALS_PATH, handle_errors
9
+
10
+
11
+ def register_auth_commands(cli_group):
12
+ """Register authentication CLI commands."""
13
+
14
+ @cli_group.group()
15
+ def auth():
16
+ """Authentication commands."""
17
+ pass
18
+
19
+ @auth.command("setup")
20
+ @click.option("--path", default=DEFAULT_CREDENTIALS_PATH, help="Path to save credentials")
21
+ @click.option("--profile", default="default", help="Credential profile name")
22
+ @click.option("--refresh-token", help="Refresh token")
23
+ @click.option("--client-id", help="Client ID")
24
+ @click.option("--client-secret", help="Client secret")
25
+ @click.option("--aws-access-key-id", help="AWS Access Key ID")
26
+ @click.option("--aws-secret-access-key", help="AWS Secret Access Key")
27
+ @click.option("--seller-id", default="A2GKV2AN9F8YG3", help="Seller ID")
28
+ @click.option("--marketplace-id", default="ATVPDKIKX0DER", help="Marketplace ID")
29
+ @click.pass_context
30
+ def auth_setup(
31
+ ctx,
32
+ path,
33
+ profile,
34
+ refresh_token,
35
+ client_id,
36
+ client_secret,
37
+ aws_access_key_id,
38
+ aws_secret_access_key,
39
+ seller_id,
40
+ marketplace_id,
41
+ ):
42
+ """Set up Amazon SP-API credentials.
43
+
44
+ When flags are omitted, falls back to interactive prompts.
45
+ """
46
+ click.echo("🔐 Amazon SP-API Credential Setup")
47
+ click.echo("=" * 50)
48
+ click.echo()
49
+
50
+ interactive = not all([refresh_token, client_id, client_secret, aws_access_key_id, aws_secret_access_key])
51
+ if interactive:
52
+ click.echo("You'll need the following from your Amazon Developer account:")
53
+ click.echo(" 1. Refresh Token (from LWA authorization)")
54
+ click.echo(" 2. Client ID (from your app registration)")
55
+ click.echo(" 3. Client Secret (from your app registration)")
56
+ click.echo(" 4. AWS Access Key ID")
57
+ click.echo(" 5. AWS Secret Access Key")
58
+ click.echo()
59
+
60
+ profile = profile or click.prompt("Profile name", default="default")
61
+ refresh_token = refresh_token or click.prompt("Refresh token", hide_input=True)
62
+ client_id = client_id or click.prompt("Client ID")
63
+ client_secret = client_secret or click.prompt("Client secret", hide_input=True)
64
+ aws_access_key_id = aws_access_key_id or click.prompt("AWS Access Key ID")
65
+ aws_secret_access_key = aws_secret_access_key or click.prompt("AWS Secret Access Key", hide_input=True)
66
+
67
+ credentials = {
68
+ "version": "1.0",
69
+ profile: {
70
+ "refresh_token": refresh_token,
71
+ "client_id": client_id,
72
+ "client_secret": client_secret,
73
+ "aws_access_key_id": aws_access_key_id,
74
+ "aws_secret_access_key": aws_secret_access_key,
75
+ "seller_id": seller_id,
76
+ "marketplace_id": marketplace_id,
77
+ },
78
+ }
79
+
80
+ # Merge with existing if present
81
+ if os.path.exists(path):
82
+ try:
83
+ with open(path, "r") as f:
84
+ existing = yaml.safe_load(f) or {}
85
+ existing[profile] = credentials[profile]
86
+ credentials = existing
87
+ click.echo(f"\n📝 Merged with existing credentials at {path}")
88
+ except Exception as e:
89
+ click.echo(f"⚠️ Could not read existing file: {e}")
90
+
91
+ os.makedirs(os.path.dirname(path), exist_ok=True)
92
+ with open(path, "w") as f:
93
+ yaml.dump(credentials, f, default_flow_style=False, sort_keys=False)
94
+
95
+ click.echo(f"✅ Credentials saved to {path}")
96
+ click.echo(f" Profile: {profile}")
97
+ click.echo(f" Seller ID: {seller_id}")
98
+ click.echo(f" Marketplace ID: {marketplace_id}")
99
+ click.echo()
100
+ click.echo("You can now use: python -m amazon_sp_cli.main --profile {profile} get-price <sku>")
101
+
102
+ @auth.command("show")
103
+ @click.option("--path", default=DEFAULT_CREDENTIALS_PATH, help="Path to credentials file")
104
+ @click.pass_context
105
+ def auth_show(ctx, path):
106
+ """Show configured profiles (without secrets)."""
107
+ if not os.path.exists(path):
108
+ click.echo(f"❌ No credentials file found at {path}")
109
+ click.echo("Run: python -m amazon_sp_cli.main auth setup")
110
+ return
111
+
112
+ with open(path, "r") as f:
113
+ creds = yaml.safe_load(f) or {}
114
+
115
+ click.echo(f"\n📄 Credentials file: {path}")
116
+ click.echo("-" * 40)
117
+
118
+ for profile, data in creds.items():
119
+ if profile == "version":
120
+ continue
121
+ click.echo(f"Profile: {profile}")
122
+ click.echo(f" Client ID: {data.get('client_id', 'N/A')[:20]}...")
123
+ click.echo(f" Seller ID: {data.get('seller_id', 'N/A')}")
124
+ click.echo(f" Marketplace ID: {data.get('marketplace_id', 'N/A')}")
125
+ click.echo()
126
+
127
+ @cli_group.command()
128
+ @click.pass_context
129
+ @handle_errors
130
+ def invalidate(ctx):
131
+ """Invalidate cached access token."""
132
+ from ..cli import _ensure_auth_client
133
+
134
+ auth, _ = _ensure_auth_client(ctx)
135
+ auth.invalidate()