amazon-ads-cli 0.1.0__tar.gz → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-ads-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: CLI tool for Amazon Advertising API v3
5
5
  Home-page: https://github.com/stellaraether/amazon-ads-cli
6
6
  Author: Lunan Li
@@ -28,29 +28,49 @@ default:
28
28
 
29
29
  ```bash
30
30
  # List campaigns
31
- amz-ads campaigns list
31
+ python3 -m amazon_ads_cli.main campaigns list
32
32
 
33
33
  # Pause campaign
34
- amz-ads campaigns pause <campaign-id>
34
+ python3 -m amazon_ads_cli.main campaigns pause <campaign-id>
35
35
 
36
36
  # Enable campaign
37
- amz-ads campaigns enable <campaign-id>
37
+ python3 -m amazon_ads_cli.main campaigns enable <campaign-id>
38
38
  ```
39
39
 
40
40
  ### Keywords
41
41
 
42
42
  ```bash
43
43
  # List keywords for a campaign
44
- amz-ads keywords list <campaign-id>
44
+ python3 -m amazon_ads_cli.main keywords list <campaign-id>
45
45
  ```
46
46
 
47
47
  ### Reports
48
48
 
49
49
  ```bash
50
50
  # Get today's performance
51
- amz-ads report today
51
+ python3 -m amazon_ads_cli.main report today
52
52
  ```
53
53
 
54
+ ## Development
55
+
56
+ ```bash
57
+ # Clone repo
58
+ git clone https://github.com/stellaraether/amazon-ads-cli.git
59
+ cd amazon-ads-cli
60
+
61
+ # Install in editable mode
62
+ pip install -e .
63
+
64
+ # Run locally
65
+ python3 -m amazon_ads_cli.main campaigns list
66
+ ```
67
+
68
+ ## Requirements
69
+
70
+ - Python 3.8+
71
+ - `python-amazon-ad-api` library
72
+ - Amazon Advertising API credentials
73
+
54
74
  ## License
55
75
 
56
76
  MIT
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env python3
2
+ """Amazon Ads CLI - Command line interface for Amazon Advertising API v3."""
3
+
4
+ import json
5
+ from datetime import datetime, timedelta
6
+
7
+ import click
8
+ from ad_api.api import reports, sponsored_products
9
+ from ad_api.base import Marketplaces
10
+
11
+
12
+ @click.group()
13
+ @click.option("--profile", "-p", default="default", help="Credential profile")
14
+ @click.pass_context
15
+ def cli(ctx, profile):
16
+ """Amazon Ads CLI - Manage campaigns, keywords, and reports."""
17
+ ctx.ensure_object(dict)
18
+ ctx.obj["profile"] = profile
19
+
20
+
21
+ @cli.group()
22
+ def campaigns():
23
+ """Campaign management commands."""
24
+ pass
25
+
26
+
27
+ @campaigns.command("list")
28
+ @click.pass_context
29
+ def list_campaigns(ctx):
30
+ """List all campaigns."""
31
+ result = sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).list_campaigns(
32
+ body={}
33
+ )
34
+ campaigns = result.payload.get("campaigns", [])
35
+
36
+ click.echo(f"\n{'Campaign':<30} {'State':<10} {'Budget':<10} {'Type'}")
37
+ click.echo("-" * 65)
38
+ for camp in campaigns:
39
+ name = camp["name"][:28]
40
+ state = camp["state"]
41
+ budget = f"${camp['budget']['budget']}"
42
+ ctype = camp.get("targetingType", "N/A")
43
+ click.echo(f"{name:<30} {state:<10} {budget:<10} {ctype}")
44
+
45
+
46
+ @campaigns.command("pause")
47
+ @click.argument("campaign-id")
48
+ @click.pass_context
49
+ def pause_campaign(ctx, campaign_id):
50
+ """Pause a campaign."""
51
+ try:
52
+ sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).edit_campaigns(
53
+ body={"campaigns": [{"campaignId": campaign_id, "state": "PAUSED"}]}
54
+ )
55
+ click.echo(f"✅ Campaign {campaign_id} paused")
56
+ except Exception as e:
57
+ click.echo(f"❌ Error: {e}")
58
+
59
+
60
+ @campaigns.command("enable")
61
+ @click.argument("campaign-id")
62
+ @click.pass_context
63
+ def enable_campaign(ctx, campaign_id):
64
+ """Enable a campaign."""
65
+ try:
66
+ sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).edit_campaigns(
67
+ body={"campaigns": [{"campaignId": campaign_id, "state": "ENABLED"}]}
68
+ )
69
+ click.echo(f"✅ Campaign {campaign_id} enabled")
70
+ except Exception as e:
71
+ click.echo(f"❌ Error: {e}")
72
+
73
+
74
+ @campaigns.command("budget")
75
+ @click.argument("campaign-id")
76
+ @click.argument("amount", type=float)
77
+ @click.pass_context
78
+ def set_budget(ctx, campaign_id, amount):
79
+ """Set campaign daily budget."""
80
+ try:
81
+ sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).edit_campaigns(
82
+ body={
83
+ "campaigns": [
84
+ {
85
+ "campaignId": campaign_id,
86
+ "budget": {"budget": amount, "budgetType": "DAILY"},
87
+ }
88
+ ]
89
+ }
90
+ )
91
+ click.echo(f"✅ Campaign {campaign_id} budget set to ${amount}/day")
92
+ except Exception as e:
93
+ click.echo(f"❌ Error: {e}")
94
+
95
+
96
+ @cli.group()
97
+ def keywords():
98
+ """Keyword management commands."""
99
+ pass
100
+
101
+
102
+ @keywords.command("list")
103
+ @click.argument("campaign-id")
104
+ @click.pass_context
105
+ def list_keywords(ctx, campaign_id):
106
+ """List keywords for a campaign."""
107
+ result = sponsored_products.KeywordsV3(marketplace=Marketplaces.NA).list_keywords(
108
+ body={}
109
+ )
110
+ keywords = [
111
+ k
112
+ for k in result.payload.get("keywords", [])
113
+ if k.get("campaignId") == campaign_id
114
+ ]
115
+
116
+ click.echo(f"\n{'Keyword':<35} {'Match':<10} {'Bid':<8} {'State'}")
117
+ click.echo("-" * 70)
118
+ for kw in keywords:
119
+ text = kw["keywordText"][:33]
120
+ match = kw["matchType"]
121
+ bid = f"${kw['bid']}"
122
+ state = kw["state"]
123
+ click.echo(f"{text:<35} {match:<10} {bid:<8} {state}")
124
+
125
+
126
+ @keywords.command("add")
127
+ @click.argument("campaign-id")
128
+ @click.argument("ad-group-id")
129
+ @click.argument("keyword-text")
130
+ @click.option("--match-type", default="EXACT", help="Match type: EXACT, PHRASE, BROAD")
131
+ @click.option("--bid", default=1.0, help="Bid amount")
132
+ @click.pass_context
133
+ def add_keyword(ctx, campaign_id, ad_group_id, keyword_text, match_type, bid):
134
+ """Add a keyword to a campaign."""
135
+ try:
136
+ sponsored_products.KeywordsV3(marketplace=Marketplaces.NA).create_keyword(
137
+ body={
138
+ "keywords": [
139
+ {
140
+ "campaignId": campaign_id,
141
+ "adGroupId": ad_group_id,
142
+ "keywordText": keyword_text,
143
+ "matchType": match_type,
144
+ "bid": bid,
145
+ "state": "ENABLED",
146
+ }
147
+ ]
148
+ }
149
+ )
150
+ click.echo(f"✅ Added keyword: {keyword_text} ({match_type}) - ${bid}")
151
+ except Exception as e:
152
+ click.echo(f"❌ Error: {e}")
153
+
154
+
155
+ @keywords.command("bid")
156
+ @click.argument("keyword-id")
157
+ @click.argument("amount", type=float)
158
+ @click.pass_context
159
+ def set_bid(ctx, keyword_id, amount):
160
+ """Update keyword bid."""
161
+ try:
162
+ sponsored_products.KeywordsV3(marketplace=Marketplaces.NA).edit_keyword(
163
+ keywordId=keyword_id,
164
+ body={"keywords": [{"keywordId": keyword_id, "bid": amount}]},
165
+ )
166
+ click.echo(f"✅ Keyword {keyword_id} bid updated to ${amount}")
167
+ except Exception as e:
168
+ click.echo(f"❌ Error: {e}")
169
+
170
+
171
+ @cli.group()
172
+ def negatives():
173
+ """Negative keyword management commands."""
174
+ pass
175
+
176
+
177
+ @negatives.command("list")
178
+ @click.argument("campaign-id")
179
+ @click.pass_context
180
+ def list_negatives(ctx, campaign_id):
181
+ """List negative keywords for a campaign."""
182
+ result = sponsored_products.NegativeKeywordsV3(
183
+ marketplace=Marketplaces.NA
184
+ ).list_negative_keywords(body={"campaignIdFilter": {"include": [campaign_id]}})
185
+ negatives = result.payload.get("negativeKeywords", [])
186
+
187
+ click.echo(f"\n{'Negative Keyword':<35} {'Match':<15}")
188
+ click.echo("-" * 55)
189
+ for neg in negatives:
190
+ text = neg["keywordText"][:33]
191
+ match = neg["matchType"]
192
+ click.echo(f"{text:<35} {match:<15}")
193
+
194
+
195
+ @negatives.command("add")
196
+ @click.argument("campaign-id")
197
+ @click.argument("keyword-text")
198
+ @click.option(
199
+ "--match-type",
200
+ default="NEGATIVE_PHRASE",
201
+ help="Match type: NEGATIVE_EXACT, NEGATIVE_PHRASE",
202
+ )
203
+ @click.pass_context
204
+ def add_negative(ctx, campaign_id, keyword_text, match_type):
205
+ """Add a negative keyword to a campaign."""
206
+ try:
207
+ sponsored_products.NegativeKeywordsV3(
208
+ marketplace=Marketplaces.NA
209
+ ).create_negative_keywords(
210
+ body={
211
+ "negativeKeywords": [
212
+ {
213
+ "campaignId": campaign_id,
214
+ "keywordText": keyword_text,
215
+ "matchType": match_type,
216
+ "state": "ENABLED",
217
+ }
218
+ ]
219
+ }
220
+ )
221
+ click.echo(f"✅ Added negative keyword: {keyword_text} ({match_type})")
222
+ except Exception as e:
223
+ click.echo(f"❌ Error: {e}")
224
+
225
+
226
+ @cli.group()
227
+ def report():
228
+ """Report commands."""
229
+ pass
230
+
231
+
232
+ @report.command("today")
233
+ @click.pass_context
234
+ def report_today(ctx):
235
+ """Get today's performance report."""
236
+ today = datetime.now().strftime("%Y-%m-%d")
237
+
238
+ click.echo(f"Requesting report for {today}...")
239
+
240
+ report_body = {
241
+ "name": f"SP_Today_{today}",
242
+ "startDate": today,
243
+ "endDate": today,
244
+ "configuration": {
245
+ "adProduct": "SPONSORED_PRODUCTS",
246
+ "columns": [
247
+ "impressions",
248
+ "clicks",
249
+ "cost",
250
+ "purchases14d",
251
+ "sales14d",
252
+ "campaignName",
253
+ "campaignId",
254
+ ],
255
+ "reportTypeId": "spCampaigns",
256
+ "format": "GZIP_JSON",
257
+ "groupBy": ["campaign"],
258
+ "timeUnit": "SUMMARY",
259
+ },
260
+ }
261
+
262
+ try:
263
+ # Submit report
264
+ result = reports.Reports(marketplace=Marketplaces.NA).post_report(
265
+ body=report_body
266
+ )
267
+ report_id = result.payload["reportId"]
268
+
269
+ click.echo(f"Report submitted: {report_id}")
270
+ click.echo("Polling for completion...")
271
+
272
+ # Poll
273
+ import time
274
+
275
+ for i in range(20):
276
+ result = reports.Reports(marketplace=Marketplaces.NA).get_report(
277
+ reportId=report_id
278
+ )
279
+ status = result.payload.get("status")
280
+
281
+ if status == "COMPLETED":
282
+ # Download
283
+ import gzip
284
+
285
+ import requests
286
+
287
+ url = result.payload.get("url")
288
+ response = requests.get(url)
289
+ data = gzip.decompress(response.content)
290
+ report_data = json.loads(data)
291
+
292
+ click.echo(
293
+ f"\n{'Campaign':<30} {'Impr':>8} {'Clicks':>7} {'Spend':>8} {'Sales':>8} {'ACOS'}"
294
+ )
295
+ click.echo("-" * 75)
296
+
297
+ for row in report_data:
298
+ camp_name = row.get("campaignName", "N/A")[:28]
299
+ impr = int(row.get("impressions", 0) or 0)
300
+ clicks = int(row.get("clicks", 0) or 0)
301
+ cost = float(row.get("cost", 0) or 0)
302
+ sales = float(row.get("sales14d", 0) or 0)
303
+ acos = (cost / sales * 100) if sales > 0 else 0
304
+
305
+ click.echo(
306
+ f"{camp_name:<30} {impr:>8} {clicks:>7} ${cost:>7.2f} ${sales:>7.2f} {acos:>5.1f}%"
307
+ )
308
+
309
+ return
310
+
311
+ elif status == "FAILED":
312
+ click.echo(f"❌ Report failed: {result.payload.get('failureReason')}")
313
+ return
314
+
315
+ time.sleep(3)
316
+
317
+ click.echo("⏳ Report still processing...")
318
+
319
+ except Exception as e:
320
+ click.echo(f"❌ Error: {e}")
321
+
322
+
323
+ @report.command("search-terms")
324
+ @click.option("--days", default=7, help="Number of days to look back")
325
+ @click.pass_context
326
+ def search_terms_report(ctx, days):
327
+ """Get search term report."""
328
+ end_date = datetime.now().strftime("%Y-%m-%d")
329
+ start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
330
+
331
+ click.echo(f"Requesting search term report: {start_date} to {end_date}...")
332
+
333
+ report_body = {
334
+ "name": f"SP_SearchTerms_{start_date}_{end_date}",
335
+ "startDate": start_date,
336
+ "endDate": end_date,
337
+ "configuration": {
338
+ "adProduct": "SPONSORED_PRODUCTS",
339
+ "columns": [
340
+ "impressions",
341
+ "clicks",
342
+ "cost",
343
+ "purchases14d",
344
+ "sales14d",
345
+ "searchTerm",
346
+ "matchType",
347
+ "campaignName",
348
+ "keyword",
349
+ ],
350
+ "reportTypeId": "spSearchTerm",
351
+ "format": "GZIP_JSON",
352
+ "groupBy": ["searchTerm"],
353
+ "timeUnit": "SUMMARY",
354
+ },
355
+ }
356
+
357
+ try:
358
+ result = reports.Reports(marketplace=Marketplaces.NA).post_report(
359
+ body=report_body
360
+ )
361
+ report_id = result.payload["reportId"]
362
+
363
+ click.echo(f"Report submitted: {report_id}")
364
+ click.echo("Polling for completion...")
365
+
366
+ import time
367
+
368
+ for i in range(30):
369
+ result = reports.Reports(marketplace=Marketplaces.NA).get_report(
370
+ reportId=report_id
371
+ )
372
+ status = result.payload.get("status")
373
+
374
+ if status == "COMPLETED":
375
+ import gzip
376
+
377
+ import requests
378
+
379
+ url = result.payload.get("url")
380
+ response = requests.get(url)
381
+ data = gzip.decompress(response.content)
382
+ report_data = json.loads(data)
383
+
384
+ # Sort by cost (highest first)
385
+ report_data.sort(
386
+ key=lambda x: float(x.get("cost", 0) or 0), reverse=True
387
+ )
388
+
389
+ click.echo(
390
+ f"\n{'Search Term':<40} {'Campaign':<20} {'Spend':>8} {'Sales':>8} {'ACOS'}"
391
+ )
392
+ click.echo("-" * 90)
393
+
394
+ for row in report_data[:20]:
395
+ term = row.get("searchTerm", "N/A")[:38]
396
+ camp = row.get("campaignName", "N/A")[:18]
397
+ cost = float(row.get("cost", 0) or 0)
398
+ sales = float(row.get("sales14d", 0) or 0)
399
+ acos = (cost / sales * 100) if sales > 0 else 0
400
+
401
+ click.echo(
402
+ f"{term:<40} {camp:<20} ${cost:>7.2f} ${sales:>7.2f} {acos:>5.1f}%"
403
+ )
404
+
405
+ return
406
+
407
+ elif status == "FAILED":
408
+ click.echo(f"❌ Report failed: {result.payload.get('failureReason')}")
409
+ return
410
+
411
+ time.sleep(5)
412
+
413
+ click.echo("⏳ Report still processing...")
414
+
415
+ except Exception as e:
416
+ click.echo(f"❌ Error: {e}")
417
+
418
+
419
+ if __name__ == "__main__":
420
+ cli()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-ads-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: CLI tool for Amazon Advertising API v3
5
5
  Home-page: https://github.com/stellaraether/amazon-ads-cli
6
6
  Author: Lunan Li
@@ -1,8 +1,8 @@
1
- from setuptools import setup, find_packages
1
+ from setuptools import find_packages, setup
2
2
 
3
3
  setup(
4
4
  name="amazon-ads-cli",
5
- version="0.1.0",
5
+ version="0.1.2",
6
6
  description="CLI tool for Amazon Advertising API v3",
7
7
  author="Lunan Li",
8
8
  author_email="lunan@stellaraether.com",
@@ -1,179 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Amazon Ads CLI - Command line interface for Amazon Advertising API v3."""
3
-
4
- import click
5
- import json
6
- from datetime import datetime, timedelta
7
- from ad_api.api import sponsored_products, reports
8
- from ad_api.base import Marketplaces
9
-
10
-
11
- @click.group()
12
- @click.option('--profile', '-p', default='default', help='Credential profile')
13
- @click.pass_context
14
- def cli(ctx, profile):
15
- """Amazon Ads CLI - Manage campaigns, keywords, and reports."""
16
- ctx.ensure_object(dict)
17
- ctx.obj['profile'] = profile
18
-
19
-
20
- @cli.group()
21
- def campaigns():
22
- """Campaign management commands."""
23
- pass
24
-
25
-
26
- @campaigns.command('list')
27
- @click.pass_context
28
- def list_campaigns(ctx):
29
- """List all campaigns."""
30
- result = sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).list_campaigns(body={})
31
- campaigns = result.payload.get('campaigns', [])
32
-
33
- click.echo(f"\n{'Campaign':<30} {'State':<10} {'Budget':<10} {'Type'}")
34
- click.echo("-" * 65)
35
- for camp in campaigns:
36
- name = camp['name'][:28]
37
- state = camp['state']
38
- budget = f"${camp['budget']['budget']}"
39
- ctype = camp.get('targetingType', 'N/A')
40
- click.echo(f"{name:<30} {state:<10} {budget:<10} {ctype}")
41
-
42
-
43
- @campaigns.command('pause')
44
- @click.argument('campaign-id')
45
- @click.pass_context
46
- def pause_campaign(ctx, campaign_id):
47
- """Pause a campaign."""
48
- try:
49
- result = sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).edit_campaigns(
50
- body={"campaigns": [{"campaignId": campaign_id, "state": "PAUSED"}]}
51
- )
52
- click.echo(f"✅ Campaign {campaign_id} paused")
53
- except Exception as e:
54
- click.echo(f"❌ Error: {e}")
55
-
56
-
57
- @campaigns.command('enable')
58
- @click.argument('campaign-id')
59
- @click.pass_context
60
- def enable_campaign(ctx, campaign_id):
61
- """Enable a campaign."""
62
- try:
63
- result = sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).edit_campaigns(
64
- body={"campaigns": [{"campaignId": campaign_id, "state": "ENABLED"}]}
65
- )
66
- click.echo(f"✅ Campaign {campaign_id} enabled")
67
- except Exception as e:
68
- click.echo(f"❌ Error: {e}")
69
-
70
-
71
- @cli.group()
72
- def keywords():
73
- """Keyword management commands."""
74
- pass
75
-
76
-
77
- @keywords.command('list')
78
- @click.argument('campaign-id')
79
- @click.pass_context
80
- def list_keywords(ctx, campaign_id):
81
- """List keywords for a campaign."""
82
- result = sponsored_products.KeywordsV3(marketplace=Marketplaces.NA).list_keywords(body={})
83
- keywords = [k for k in result.payload.get('keywords', []) if k.get('campaignId') == campaign_id]
84
-
85
- click.echo(f"\n{'Keyword':<35} {'Match':<10} {'Bid':<8} {'State'}")
86
- click.echo("-" * 70)
87
- for kw in keywords:
88
- text = kw['keywordText'][:33]
89
- match = kw['matchType']
90
- bid = f"${kw['bid']}"
91
- state = kw['state']
92
- click.echo(f"{text:<35} {match:<10} {bid:<8} {state}")
93
-
94
-
95
- @cli.group()
96
- def report():
97
- """Report commands."""
98
- pass
99
-
100
-
101
- @report.command('today')
102
- @click.pass_context
103
- def report_today(ctx):
104
- """Get today's performance report."""
105
- today = datetime.now().strftime('%Y-%m-%d')
106
-
107
- click.echo(f"Requesting report for {today}...")
108
-
109
- report_body = {
110
- "name": f"SP_Today_{today}",
111
- "startDate": today,
112
- "endDate": today,
113
- "configuration": {
114
- "adProduct": "SPONSORED_PRODUCTS",
115
- "columns": [
116
- "impressions", "clicks", "cost",
117
- "purchases14d", "sales14d",
118
- "campaignName", "campaignId"
119
- ],
120
- "reportTypeId": "spCampaigns",
121
- "format": "GZIP_JSON",
122
- "groupBy": ["campaign"],
123
- "timeUnit": "SUMMARY"
124
- }
125
- }
126
-
127
- try:
128
- # Submit report
129
- result = reports.Reports(marketplace=Marketplaces.NA).post_report(body=report_body)
130
- report_id = result.payload['reportId']
131
-
132
- click.echo(f"Report submitted: {report_id}")
133
- click.echo("Polling for completion...")
134
-
135
- # Poll
136
- import time
137
- for i in range(20):
138
- result = reports.Reports(marketplace=Marketplaces.NA).get_report(reportId=report_id)
139
- status = result.payload.get('status')
140
-
141
- if status == 'COMPLETED':
142
- # Download
143
- import requests
144
- import gzip
145
-
146
- url = result.payload.get('url')
147
- response = requests.get(url)
148
- data = gzip.decompress(response.content)
149
- report_data = json.loads(data)
150
-
151
- click.echo(f"\n{'Campaign':<30} {'Impr':>8} {'Clicks':>7} {'Spend':>8} {'Sales':>8} {'ACOS'}")
152
- click.echo("-" * 75)
153
-
154
- for row in report_data:
155
- camp_name = row.get('campaignName', 'N/A')[:28]
156
- impr = int(row.get('impressions', 0) or 0)
157
- clicks = int(row.get('clicks', 0) or 0)
158
- cost = float(row.get('cost', 0) or 0)
159
- sales = float(row.get('sales14d', 0) or 0)
160
- acos = (cost / sales * 100) if sales > 0 else 0
161
-
162
- click.echo(f"{camp_name:<30} {impr:>8} {clicks:>7} ${cost:>7.2f} ${sales:>7.2f} {acos:>5.1f}%")
163
-
164
- return
165
-
166
- elif status == 'FAILED':
167
- click.echo(f"❌ Report failed: {result.payload.get('failureReason')}")
168
- return
169
-
170
- time.sleep(3)
171
-
172
- click.echo("⏳ Report still processing...")
173
-
174
- except Exception as e:
175
- click.echo(f"❌ Error: {e}")
176
-
177
-
178
- if __name__ == '__main__':
179
- cli()
File without changes