edapitool 0.6.3__py3-none-any.whl

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.
APITool/cli.py ADDED
@@ -0,0 +1,1419 @@
1
+ """
2
+ Command-line interface for Elite Dangerous API Tool.
3
+
4
+ Usage:
5
+ edapitool auth - Authenticate with Frontier
6
+ edapitool profile - Get commander profile
7
+ edapitool carrier - Get fleet carrier data
8
+ edapitool carrier csv - Export carrier data to CSV
9
+ """
10
+
11
+ import argparse
12
+ import sys
13
+ import json
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from .version import __version__, get_version
18
+ from .constants import CAPI_SERVER_LIVE, CAPI_SERVER_LEGACY
19
+ from .auth import FrontierAuth
20
+ from .capi import CAPIClient, CAPIError, CAPINoDataError
21
+ from .models import FleetCarrier
22
+ from .export import CSVExporter, JSONExporter
23
+ # Imported as NAMES, not through the module, and that is load-bearing:
24
+ # tests monkeypatch these on this module to stub resolution out. Calling
25
+ # settings.get_sheet_id(...) instead would leave those patches inert.
26
+ from . import settings
27
+ # The resolvers are imported as NAMES because tests monkeypatch them on this
28
+ # module; CONFIG_FILE is read through `settings` instead, because it is a
29
+ # VALUE -- importing it by name would freeze the path at import time and a
30
+ # message naming it would report a different file from the one just read.
31
+ from .settings import (
32
+ get_client_id,
33
+ get_construction_regions,
34
+ get_sheet_id,
35
+ parse_region_spec,
36
+ save,
37
+ )
38
+
39
+
40
+ def setup_auth(
41
+ client_id: str,
42
+ redirect_uri: Optional[str] = None,
43
+ manual: bool = False,
44
+ ) -> FrontierAuth:
45
+ """Set up authentication."""
46
+ auth = FrontierAuth(client_id, redirect_uri=redirect_uri)
47
+ if not auth.is_authenticated:
48
+ print("Not authenticated. Starting authorization flow...")
49
+ print()
50
+ if not manual:
51
+ print("You will be redirected to Frontier's login page.")
52
+ print("After logging in, authorize this application.")
53
+ print()
54
+ if auth.authorize(manual=manual):
55
+ print("Authentication successful!")
56
+ else:
57
+ print("Authentication failed.")
58
+ sys.exit(1)
59
+ return auth
60
+
61
+
62
+ def save_client_id(client_id: str) -> bool:
63
+ """Save client ID to config file for future use."""
64
+ return save("client_id", client_id)
65
+
66
+
67
+ def cmd_auth(args: argparse.Namespace) -> int:
68
+ """Handle auth command."""
69
+ client_id = args.client_id or get_client_id()
70
+ if not client_id:
71
+ print("Error: No client ID provided.")
72
+ print()
73
+ print("To authenticate, you need a Frontier API client ID.")
74
+ print("Register your application at: https://auth.frontierstore.net/client/signup")
75
+ print()
76
+ print("Then either:")
77
+ print(" 1. Set ED_CLIENT_ID environment variable")
78
+ print(" 2. Create ~/.ed_capi_config.json with: {\"client_id\": \"your_id\"}")
79
+ print(" 3. Use --client-id argument")
80
+ return 1
81
+
82
+ redirect_uri = getattr(args, 'redirect_uri', None)
83
+ manual = getattr(args, 'manual_auth', False)
84
+
85
+ auth = setup_auth(client_id, redirect_uri=redirect_uri, manual=manual)
86
+
87
+ # Save client ID to config file for future use
88
+ if auth.is_authenticated and args.client_id:
89
+ if save_client_id(client_id):
90
+ print(f"Client ID saved to ~/.ed_capi_config.json")
91
+
92
+ print(f"Authenticated: {auth.is_authenticated}")
93
+ return 0
94
+
95
+
96
+ def cmd_profile(args: argparse.Namespace) -> int:
97
+ """Handle profile command."""
98
+ client_id = args.client_id or get_client_id()
99
+ if not client_id:
100
+ print("Error: No client ID. Run 'edapitool auth' first.")
101
+ return 1
102
+
103
+ redirect_uri = getattr(args, 'redirect_uri', None)
104
+ manual = getattr(args, 'manual_auth', False)
105
+
106
+ auth = setup_auth(client_id, redirect_uri=redirect_uri, manual=manual)
107
+ client = CAPIClient(auth)
108
+
109
+ try:
110
+ profile = client.get_profile()
111
+
112
+ if args.json:
113
+ print(json.dumps(profile, indent=2))
114
+ else:
115
+ commander = profile.get("commander", {})
116
+ print(f"Commander: {commander.get('name', 'Unknown')}")
117
+ print(f"Credits: {commander.get('credits', 0):,}")
118
+ print(f"Current Ship: {profile.get('ship', {}).get('name', 'Unknown')}")
119
+
120
+ except CAPIError as e:
121
+ print(f"Error: {e}")
122
+ return 1
123
+
124
+ return 0
125
+
126
+
127
+ def cmd_carrier(args: argparse.Namespace) -> int:
128
+ """Handle carrier command."""
129
+ client_id = args.client_id or get_client_id()
130
+ if not client_id:
131
+ print("Error: No client ID. Run 'edapitool auth' first.")
132
+ return 1
133
+
134
+ redirect_uri = getattr(args, 'redirect_uri', None)
135
+ manual = getattr(args, 'manual_auth', False)
136
+
137
+ # Parse include flags
138
+ include_flags = [f.strip().lower() for f in args.include.split(",") if f.strip()]
139
+ include_stolen = "stolen" in include_flags
140
+ include_mission = "mission" in include_flags
141
+
142
+ # Parse export formats (comma-separated)
143
+ export_formats = []
144
+ if args.export:
145
+ export_formats = [f.strip().lower() for f in args.export.split(",") if f.strip()]
146
+
147
+ # Resolved the same way every other verb resolves it. This used to read
148
+ # args.sheet_id raw, so a sheet id sitting in the config file or in
149
+ # ED_SHEET_ID was ignored here and honoured everywhere else -- nobody
150
+ # chose that, it is what four independent call sites drift into.
151
+ sheet_id = get_sheet_id(args)
152
+ if "google" in export_formats and not sheet_id:
153
+ print("Error: --export google needs a spreadsheet. Pass --sheet-id,")
154
+ print(' set ED_SHEET_ID, or add "sheet_id" to '
155
+ "~/.ed_capi_config.json.")
156
+ return 1
157
+
158
+ auth = setup_auth(client_id, redirect_uri=redirect_uri, manual=manual)
159
+ server = CAPI_SERVER_LEGACY if args.legacy else CAPI_SERVER_LIVE
160
+ client = CAPIClient(auth, server=server)
161
+
162
+ try:
163
+ print("Fetching fleet carrier data...", file=sys.stderr)
164
+ print("(This may take up to 60 seconds for large inventories)", file=sys.stderr)
165
+ print(file=sys.stderr)
166
+
167
+ raw_data = client.get_fleet_carrier()
168
+
169
+ # Output raw JSON if requested (before parsing to avoid errors)
170
+ if args.json:
171
+ print(json.dumps(raw_data, indent=2))
172
+ return 0
173
+
174
+ # Parse into model
175
+ carrier = FleetCarrier.from_capi(raw_data)
176
+
177
+ output_dir = Path(args.output) if args.output else Path.cwd()
178
+ exported_files = {}
179
+
180
+ # Handle each export format
181
+ for fmt in export_formats:
182
+ if fmt == "csv":
183
+ exporter = CSVExporter(output_dir)
184
+ files = exporter.export_all(carrier)
185
+ exported_files["csv"] = files
186
+
187
+ elif fmt == "json":
188
+ exporter = JSONExporter(output_dir)
189
+ filepath = exporter.export_carrier(carrier, include_raw=args.raw)
190
+ exported_files["json"] = filepath
191
+
192
+ elif fmt == "gsheet":
193
+ exporter = CSVExporter(output_dir)
194
+ filepath = exporter.export_cargo_gsheet(
195
+ carrier,
196
+ include_stolen=include_stolen,
197
+ include_mission=include_mission,
198
+ )
199
+ exported_files["gsheet"] = filepath
200
+
201
+ elif fmt == "google":
202
+ # Direct Google Sheets export
203
+ try:
204
+ from .google import GoogleSheetsExporter
205
+ gs_exporter = GoogleSheetsExporter()
206
+ gs_exporter.export_cargo(
207
+ carrier,
208
+ sheet_id=sheet_id,
209
+ include_stolen=include_stolen,
210
+ include_mission=include_mission,
211
+ )
212
+ exported_files["google"] = f"Sheet ID: {sheet_id}"
213
+ except ImportError:
214
+ print("Error: Google Sheets support not installed.")
215
+ print("Install with: pip install edapitool[gsheets]")
216
+ return 1
217
+
218
+ else:
219
+ print(f"Warning: Unknown export format '{fmt}', skipping.")
220
+
221
+ # Print results
222
+ if exported_files:
223
+ print(f"Fleet Carrier: {carrier.identity.display_name} ({carrier.identity.callsign})")
224
+ print(f"Location: {carrier.location.system}")
225
+ print()
226
+ print("Exported:")
227
+ for fmt, result in exported_files.items():
228
+ if isinstance(result, dict):
229
+ for export_type, filepath in result.items():
230
+ print(f" {fmt}/{export_type}: {filepath}")
231
+ else:
232
+ print(f" {fmt}: {result}")
233
+ else:
234
+ # Default: print summary
235
+ print(f"Fleet Carrier: {carrier.identity.display_name}")
236
+ print(f"Callsign: {carrier.identity.callsign}")
237
+ print(f"Location: {carrier.location.system}")
238
+ print(f"State: {carrier.location.state}")
239
+ print(f"Fuel: {carrier.fuel} t")
240
+ print(f"Cargo Items: {len(carrier.cargo)}")
241
+ print()
242
+ print(f"Bank Balance: {carrier.finance.balance:,} CR")
243
+ print(f"Weekly Upkeep: {carrier.finance.weekly_upkeep:,} CR")
244
+ print()
245
+ print(f"Commodity Orders: {len(carrier.all_commodities)}")
246
+ print(f" Sell: {len(carrier.commodity_sales)}")
247
+ print(f" Buy: {len(carrier.commodity_purchases)}")
248
+ print()
249
+ print(f"Microresource Orders: {len(carrier.all_microresources)}")
250
+ print(f"Locker Items: {len(carrier.locker_items)}")
251
+ print()
252
+ print(f"Enabled Services: {', '.join(carrier.enabled_services)}")
253
+
254
+ except CAPINoDataError:
255
+ print("You don't appear to own a fleet carrier.")
256
+ return 1
257
+ except CAPIError as e:
258
+ print(f"Error: {e}")
259
+ return 1
260
+
261
+ return 0
262
+
263
+
264
+
265
+
266
+ def cmd_market(args: argparse.Namespace) -> int:
267
+ """
268
+ Compare the current station's market against the spreadsheet's
269
+ outstanding requirements, and optionally mark them in the sheet.
270
+ """
271
+ if getattr(args, "show_formula", False):
272
+ from .workbook.markers import marker_formula_help
273
+
274
+ print(marker_formula_help())
275
+ return 0
276
+
277
+ from .service import MarketRefreshService, format_table
278
+ from .workbook.markers import (
279
+ MARKER_EMPTY_DOTTED,
280
+ MARKER_EMPTY_SMALL,
281
+ MARKER_ENOUGH,
282
+ MARKER_PARTIAL,
283
+ )
284
+ from .sheets import (
285
+ SIGN_NEGATIVE,
286
+ SIGN_POSITIVE,
287
+ SheetLayout,
288
+ WriteRefused,
289
+ )
290
+ from .matcher import MatchState
291
+
292
+ # Only override the glyph family when a non-default empty marker is asked
293
+ # for; the default keeps the graded quarter/half/three-quarter partial
294
+ # scale, which a wholesale override collapses to a single glyph.
295
+ markers = None
296
+ if args.empty_marker != "hollow":
297
+ empty = MARKER_EMPTY_SMALL if args.empty_marker == "small" else MARKER_EMPTY_DOTTED
298
+ markers = {
299
+ MatchState.ENOUGH: MARKER_ENOUGH,
300
+ MatchState.PARTIAL: MARKER_PARTIAL,
301
+ MatchState.EMPTY: empty,
302
+ }
303
+
304
+ layout = SheetLayout(
305
+ totals_tab=args.totals_tab,
306
+ need_header=args.need_header,
307
+ need_sign=SIGN_NEGATIVE if args.need_sign == "negative" else SIGN_POSITIVE,
308
+ marker_column=args.marker_column,
309
+ markers=markers,
310
+ )
311
+
312
+ capi_client = None
313
+ if args.use_capi:
314
+ client_id = args.client_id or get_client_id()
315
+ if not client_id:
316
+ print("Error: --use-capi needs a client ID. Run 'edapitool auth' first.")
317
+ return 1
318
+ auth = setup_auth(client_id)
319
+ capi_client = CAPIClient(auth)
320
+
321
+ service = MarketRefreshService(
322
+ journal_dir=Path(args.journal_dir) if args.journal_dir else None,
323
+ layout=layout,
324
+ capi_client=capi_client,
325
+ )
326
+
327
+ # Parsed here rather than beside its use below, because whether a
328
+ # spreadsheet is required depends on which export was asked for.
329
+ formats = [f.strip().lower() for f in (args.export or "").split(",") if f.strip()]
330
+
331
+ worksheet = None
332
+ sheet_id = None
333
+ no_comparison = None
334
+ # A spreadsheet is required for the COMPARISON, not for the MARKET. Where
335
+ # the station is, what it sells, at what price and how fresh the data is
336
+ # all come from the journal or CAPI. Only "what do I still need" lives in
337
+ # the sheet -- so an absent spreadsheet should cost the comparison, not the
338
+ # whole command. Writing is different: --update-sheet and --export
339
+ # market-tab have nothing to do without one.
340
+ needs_sheet = args.update_sheet or "market-tab" in formats
341
+ if not args.no_sheet:
342
+ sheet_id = get_sheet_id(args)
343
+ if not sheet_id:
344
+ if needs_sheet:
345
+ print("Error: no spreadsheet id. Pass --sheet-id, set ED_SHEET_ID,")
346
+ print(" or add \"sheet_id\" to ~/.ed_capi_config.json.")
347
+ print(" (Use --no-sheet to inspect the market without a"
348
+ " spreadsheet.)")
349
+ return 1
350
+ # Absent configuration, not broken configuration. Carry on and say
351
+ # so; a sheet id that IS set and fails to open still errors below,
352
+ # because hiding that would hide a real misconfiguration.
353
+ no_comparison = "no spreadsheet configured"
354
+
355
+ if sheet_id:
356
+ try:
357
+ from .google import GoogleSheetsExporter
358
+
359
+ worksheet = GoogleSheetsExporter().worksheet(sheet_id, layout.totals_tab)
360
+ except ImportError:
361
+ print("Error: Google Sheets support not installed.")
362
+ print("Install with: pip install edapitool[gsheets]")
363
+ return 1
364
+ except Exception as exc:
365
+ # A sheet id was configured and could not be opened. That is a
366
+ # broken setup, not an absent one, and it stays an error --
367
+ # degrading here would hide a typo'd id or a revoked credential
368
+ # behind a silently missing comparison.
369
+ print(f"Error opening spreadsheet: {exc}")
370
+ return 1
371
+ else:
372
+ no_comparison = "--no-sheet"
373
+
374
+ try:
375
+ result = service.refresh(
376
+ worksheet=worksheet,
377
+ # --no-markers shapes the PLAN, it does not veto the write. Vetoing
378
+ # left the location cells stale too -- everything travels in one
379
+ # batch -- so the one flag meant for a formula-driven sheet was the
380
+ # one flag that stopped it being told where you are.
381
+ write=args.update_sheet and not args.dry_run,
382
+ write_header=args.write_marker_header,
383
+ show_covered=not args.no_show_covered,
384
+ apply_colour=not args.no_colour,
385
+ include_markers=not args.no_markers,
386
+ )
387
+ except WriteRefused as exc:
388
+ print(f"Refused to write: {exc}")
389
+ return 1
390
+ except Exception as exc:
391
+ print(f"Error: {exc}")
392
+ return 1
393
+
394
+ # Emitting the market as data is deliberately independent of the marker
395
+ # rendering: it runs whether or not the comparison succeeded, and needs no
396
+ # spreadsheet for the csv/json forms.
397
+ exported: list[str] = []
398
+ if formats:
399
+ try:
400
+ exported = _export_market(args, result, formats, sheet_id)
401
+ except Exception as exc:
402
+ print(f"Error exporting market: {exc}")
403
+ return 1
404
+
405
+ if args.json:
406
+ payload = _market_result_json(result)
407
+ payload["exported"] = exported
408
+ # Stated rather than implied: a caller parsing this needs to tell
409
+ # "nothing was outstanding here" from "nobody asked the spreadsheet".
410
+ payload["comparison_skipped"] = no_comparison
411
+ print(json.dumps(payload, indent=2))
412
+ # `ok` reports whether a market reading was obtained -- not_docked,
413
+ # stale_market, no_journal and the rest. None of its reasons involve a
414
+ # spreadsheet, so a missing sheet never makes this exit 2, and a
415
+ # commander who is not docked still should.
416
+ return 0 if result.ok else 2
417
+
418
+ print(f"Commander : {result.location.commander or 'unknown'}")
419
+ print(f"System : {result.system or 'unknown'}")
420
+ print(f"Station : {result.station}")
421
+ if result.market is not None:
422
+ age = result.market.age_seconds
423
+ age_text = f", {age/60:.0f} min old" if age is not None else ""
424
+ print(
425
+ f"Market : {len(result.market)} commodities "
426
+ f"via {result.market.source}{age_text}"
427
+ )
428
+ print()
429
+
430
+ if not result.ok:
431
+ print(f"No comparison: {result.advice()}")
432
+ elif no_comparison:
433
+ # Without a requirements source there is no comparison to render, and
434
+ # an empty one must not be rendered as an answer: format_table([])
435
+ # prints "(nothing outstanding)" and the summary prints zeroes, which
436
+ # together say "you need nothing here" when the truth is that nobody
437
+ # was asked. Same shape as the not-ok branch above, for the same reason.
438
+ print(f"No comparison: {no_comparison}")
439
+ else:
440
+ print(format_table(result.matches))
441
+ print()
442
+ print(f"Summary : {result.summary.describe()}")
443
+
444
+ if result.snapshot and result.snapshot.unparsed_rows:
445
+ print()
446
+ print("Warning: rows whose quantity could not be read (formula recalculating?):")
447
+ for row, name, raw in result.snapshot.unparsed_rows:
448
+ print(f" row {row}: {name} = {raw!r}")
449
+
450
+ unknown = result.summary.unknown
451
+ if unknown:
452
+ print()
453
+ print("Warning: commodity names not recognized (add to name_aliases.json):")
454
+ for match in unknown:
455
+ print(f" row {match.row}: {match.name!r}")
456
+
457
+ if result.plan is not None:
458
+ print()
459
+ if result.written:
460
+ print(f"Wrote {len(result.plan.updates)} ranges to '{layout.totals_tab}'.")
461
+ print(f"Marked rows: {result.plan.marked_rows or '(none)'}")
462
+ elif args.update_sheet:
463
+ print("DRY RUN - would write:")
464
+ for update in result.plan.updates:
465
+ preview = update["values"]
466
+ if len(preview) > 3:
467
+ preview = f"{len(preview)} rows"
468
+ print(f" {layout.totals_tab}!{update['range']} = {preview}")
469
+ print(f" marked rows: {result.plan.marked_rows or '(none)'}")
470
+ else:
471
+ print("(read-only; pass --update-sheet to write markers)")
472
+
473
+ return 0 if result.ok else 2
474
+
475
+
476
+ def _site_recency(site):
477
+ """Sort key for 'most recently updated site', with a floor for no stamp."""
478
+ from datetime import datetime, timezone
479
+
480
+ return site.timestamp or datetime.min.replace(tzinfo=timezone.utc)
481
+
482
+
483
+ def cmd_construction(args: argparse.Namespace) -> int:
484
+ """
485
+ Report what a colony construction site still needs.
486
+
487
+ No spreadsheet, no Frontier login, no cooldown: the whole state comes from
488
+ a `ColonisationConstructionDepot` event in the commander's own journal.
489
+ Following `ship`'s rule rather than the one `market` originally had -- see
490
+ issue #10 -- every output here works with nothing configured at all.
491
+ """
492
+ import json as _json
493
+ from pathlib import Path as _Path
494
+
495
+ from .catalog import load_catalog, normalize
496
+ from .construction import locations_from_events, sites_from_events, station_key
497
+ from .export import construction_payload
498
+ from .journal import JournalReader, iter_events
499
+
500
+ formats = {f.strip().lower() for f in (args.export or "").split(",") if f.strip()}
501
+ unknown = formats - {"csv", "json"}
502
+ if unknown:
503
+ print(f"Error: unknown export format(s): {', '.join(sorted(unknown))}")
504
+ print(" Valid: csv, json")
505
+ return 1
506
+
507
+ reader = JournalReader(_Path(args.journal_dir) if args.journal_dir else None)
508
+ if not reader.exists():
509
+ print("Error: no Elite Dangerous journal directory found.")
510
+ print(" Set ED_JOURNAL_DIR if your Saved Games folder is elsewhere.")
511
+ return 1
512
+
513
+ # Scan several files: a site visited earlier in the session leaves no event
514
+ # in the newest log, and reporting "no sites" because of that would be a
515
+ # lie about the world rather than about the scan.
516
+ all_files = reader.journal_files()
517
+ # `--scan-files` with no number arrives as 0 and means "every file". The
518
+ # default is 120 because a shallower scan silently hides sites: on a real
519
+ # 621-file journal, 6 files showed 3 of 6 builds and left a fourth without
520
+ # a name, which reads as a gap in the data rather than a gap in the scan.
521
+ depth = len(all_files) if args.scan_files == 0 else max(1, args.scan_files)
522
+ events = []
523
+ for path in all_files[-depth:]:
524
+ events.extend(iter_events(path))
525
+ sites = sites_from_events(events, load_catalog())
526
+ places = locations_from_events(events, market_ids=sites.keys())
527
+
528
+ if not sites:
529
+ print("No construction sites found in the journal.")
530
+ print("Dock at one to record what it needs; nothing else is required.")
531
+ return 0
532
+
533
+ if args.list:
534
+ scanned = (f"all {depth}" if args.scan_files == 0 else f"the last {depth}")
535
+ rows = sorted(sites.items(), key=lambda kv: -kv[1].progress)
536
+ statuses = {m: s.status(stale_after_days=args.stale_after) for m, s in rows}
537
+ hidden = [m for m, st in statuses.items() if st != "active"]
538
+ if not args.all:
539
+ rows = [(m, s) for m, s in rows if statuses[m] == "active"]
540
+
541
+ if args.all:
542
+ print(f"{len(rows)} construction site(s) in {scanned} journal files:")
543
+ else:
544
+ print(f"{len(rows)} active of {len(sites)} construction site(s) "
545
+ f"in {scanned} journal files:")
546
+ print()
547
+
548
+ unnamed = 0
549
+ for market_id, site in rows:
550
+ where = places.get(market_id)
551
+ status = statuses[market_id]
552
+ age = site.age_days()
553
+ state = f"{site.progress*100:.1f}%" if status == "active" else status
554
+ name = where.short_station if where else "(name unknown)"
555
+ system = where.system if where else ""
556
+ seen = f"{age}d ago" if age is not None else ""
557
+ # Former names go last and in parentheses: a rename mid-build is
558
+ # real, and a reader whose notes say the old name needs to see the
559
+ # connection rather than conclude the site vanished.
560
+ former = ""
561
+ if where and where.former_names:
562
+ former = " (was " + ", ".join(where.former_names) + ")"
563
+ print(f" {state:>8} {name:<30} {system:<26} {seen:>9} {market_id}{former}")
564
+ unnamed += 0 if where else 1
565
+ print()
566
+ if hidden and not args.all:
567
+ kinds = {}
568
+ for market_id in hidden:
569
+ kinds[statuses[market_id]] = kinds.get(statuses[market_id], 0) + 1
570
+ summary = ", ".join(f"{n} {k}" for k, n in sorted(kinds.items()))
571
+ print(f"{len(hidden)} site(s) not shown ({summary}). Use --all to include them.")
572
+ if "stale" in kinds:
573
+ # Say what "stale" actually means, because the game never marks
574
+ # a lapsed build failed -- this is our inference, not its verdict.
575
+ print(f"A site is called stale after {args.stale_after} days without the game")
576
+ print("mentioning it. Elite Dangerous does not report an expired build as")
577
+ print("failed, so time is the only signal there is.")
578
+ print()
579
+ # Said ONCE, however many sites lack a name. The advice does not get
580
+ # truer by repetition, and a hint under every row buries the listing
581
+ # it is meant to annotate.
582
+ if unnamed:
583
+ print(f"{unnamed} site(s) have no name: the journal records what they need but")
584
+ print("no dock there, so only the MarketID identifies them. Dock once to fix,")
585
+ print("or widen the scan with --scan-files and no number.")
586
+ print()
587
+ print("Select one with --site NAME (add --system when two share a name), or --site MARKETID.")
588
+ return 0
589
+
590
+ # Which site? An explicit --site wins; otherwise wherever the commander is
591
+ # standing; otherwise the most recently updated one.
592
+ chosen = None
593
+ if args.site:
594
+ wanted = str(args.site).strip()
595
+ if wanted.isdigit() and int(wanted) in sites:
596
+ chosen = sites[int(wanted)]
597
+ else:
598
+ key = station_key(wanted)
599
+ system_key = normalize(args.system) if args.system else ""
600
+ matches = []
601
+ for market_id, site in sites.items():
602
+ where = places.get(market_id)
603
+ if not (key and where and where.answers_to(wanted)):
604
+ continue
605
+ if system_key and normalize(where.system) != system_key:
606
+ continue
607
+ matches.append((market_id, site, where))
608
+ if len(matches) > 1:
609
+ # Two builds can share a name, and picking the first silently
610
+ # would report one site's shortfall as another's.
611
+ print(f"Error: {args.site!r} matches {len(matches)} sites. Add --system:")
612
+ for market_id, _, where in matches:
613
+ print(f" --system {where.system!r} ({market_id})")
614
+ return 1
615
+ if matches:
616
+ chosen = matches[0][1]
617
+ if chosen is None:
618
+ print(f"Error: no construction site matching {args.site!r}.")
619
+ for market_id in sites:
620
+ where = places.get(market_id)
621
+ print(f" {market_id} {where.describe if where else '(name unknown -- dock there once)'}")
622
+ return 1
623
+ else:
624
+ here = reader.read_state(scan_files=min(depth, 6))
625
+ chosen = sites.get(here.market_id) if here.market_id else None
626
+ if chosen is None:
627
+ chosen = max(sites.values(), key=_site_recency)
628
+
629
+ payload = construction_payload(chosen)
630
+
631
+ if args.publish_to:
632
+ if not args.region:
633
+ print("Error: --publish-to needs --region, e.g. --region R1:AD60.")
634
+ print(" The region is declared rather than inferred because")
635
+ print(" publishing clears it: without knowing where the block")
636
+ print(" ends, a site that shrinks would leave the tail of its")
637
+ print(" last report sitting there looking current.")
638
+ return 1
639
+ sheet_id = get_sheet_id(args)
640
+ if not sheet_id:
641
+ print("Error: --publish-to needs a spreadsheet. Pass --sheet-id,")
642
+ print(' set ED_SHEET_ID, or add "sheet_id" to '
643
+ "~/.ed_capi_config.json.")
644
+ return 1
645
+
646
+ from .export import (CONSTRUCTION_REGION_TABLE_ROW,
647
+ construction_region_rows)
648
+ from .google import GoogleSheetsExporter
649
+ from .sheets import Destination, WriteGuard, WriteRefused
650
+
651
+ try:
652
+ destination = Destination.region(args.publish_to, args.region)
653
+ except ValueError as exc:
654
+ print(f"Error: {exc}")
655
+ return 1
656
+
657
+ grid = construction_region_rows(chosen, places.get(chosen.market_id))
658
+ # The person naming a region on the command line IS the authorization;
659
+ # the guard still runs, so a grid that outgrew its reserve is refused
660
+ # rather than quietly spilling into the columns beside it.
661
+ guard = WriteGuard.build({destination.tab: [args.region]})
662
+ try:
663
+ GoogleSheetsExporter(region_guard=guard).export_grid(
664
+ grid, sheet_id=sheet_id, tab_name=destination,
665
+ )
666
+ except WriteRefused as exc:
667
+ print(f"Error: {exc}")
668
+ return 1
669
+ leading = CONSTRUCTION_REGION_TABLE_ROW - 1
670
+ print(f"Published {len(grid) - leading} commodities to "
671
+ f"{destination.describe()}")
672
+ return 0
673
+
674
+ if args.json:
675
+ if args.all_sites:
676
+ payload = {"sites": [construction_payload(s) for s in sites.values()]}
677
+ print(_json.dumps(payload, indent=2))
678
+ return 0
679
+
680
+ if formats:
681
+ from .export import ConstructionExporter
682
+
683
+ out_dir = _Path(args.output) if args.output else None
684
+ exporter = ConstructionExporter(out_dir)
685
+ for fmt in sorted(formats):
686
+ if fmt == "csv":
687
+ print(f"Exported csv: {exporter.export_csv(chosen)}")
688
+ else:
689
+ print(f"Exported json: {exporter.export_json(chosen)}")
690
+
691
+ site = chosen
692
+ state = "complete" if site.complete else ("FAILED" if site.failed else "in progress")
693
+ where = places.get(site.market_id)
694
+ if where and where.short_station:
695
+ print(f"Site : {where.short_station} ({state})")
696
+ print(f"System : {where.system or 'unknown'}")
697
+ else:
698
+ # Never docked there in the scanned window, so the journal has the
699
+ # contents but not the name. Say which, rather than printing a bare id.
700
+ print(f"Site : {site.market_id} ({state})")
701
+ print("System : unknown -- dock at the site once to record its name")
702
+ if site.timestamp:
703
+ print(f"Updated : {site.timestamp.isoformat()}")
704
+ print(f"Progress : {site.progress * 100:.1f}% "
705
+ f"({site.total_provided:,} of {site.total_required:,} t)")
706
+ print()
707
+ outstanding = site.outstanding
708
+ if not outstanding:
709
+ print("Nothing outstanding -- every commodity is delivered.")
710
+ return 0
711
+
712
+ width = max(len(r.name) for r in outstanding)
713
+ print(f" {'commodity':<{width}} {'still need':>10} {'delivered':>10} {'pays':>8}")
714
+ print(" " + "-" * (width + 34))
715
+ for r in outstanding:
716
+ print(f" {r.name:<{width}} {r.remaining:>10,} {r.provided:>10,} {r.payment:>8,}")
717
+ print()
718
+ print(f" {len(outstanding)} commodities outstanding, "
719
+ f"{site.total_remaining:,} t to deliver")
720
+ return 0
721
+
722
+
723
+ def cmd_serve(args: argparse.Namespace) -> int:
724
+ """Keep MarketData and ShipCargo current while the game runs."""
725
+ from . import daemon as daemon_mod
726
+ from .sheets import SheetLayout
727
+
728
+ sheet_id = get_sheet_id(args)
729
+ if not sheet_id:
730
+ print("Error: serve needs a spreadsheet id. Pass --sheet-id,")
731
+ print(" set ED_SHEET_ID, or add \"sheet_id\" to ~/.ed_capi_config.json.")
732
+ return 1
733
+
734
+ journal_dir = Path(args.journal_dir) if args.journal_dir else None
735
+
736
+ try:
737
+ regions = get_construction_regions(args)
738
+ except ValueError as exc:
739
+ print(f"Error: {exc}")
740
+ # Only say where it came from when it came from the config file; a
741
+ # bad value typed on the command line is already in front of you.
742
+ # The guard is an `if` rather than a ternary inside the print,
743
+ # because that form still printed an empty line for the flag case.
744
+ if not args.construction_region:
745
+ print(f" (from {settings.CONFIG_FILE})")
746
+ return 1
747
+
748
+ try:
749
+ worker = daemon_mod.build(
750
+ sheet_id=sheet_id,
751
+ journal_dir=journal_dir,
752
+ layout=SheetLayout(totals_tab=args.totals_tab),
753
+ ship_tab=args.ship_tab,
754
+ write_location=args.write_location,
755
+ construction_regions=regions,
756
+ interval=args.interval,
757
+ debounce=args.debounce,
758
+ )
759
+ except ImportError:
760
+ print("Error: Google Sheets support not installed.")
761
+ print("Install with: pip install edapitool[gsheets]")
762
+ return 1
763
+ except Exception as exc:
764
+ # build() opens the Totals Tab, which raises ValueError for a tab name
765
+ # that is not there and gspread's own errors for a bad id or revoked
766
+ # credential -- none of them ImportError. `market` already reports these
767
+ # in one friendly line; without this, `serve` differed only by showing
768
+ # the user a traceback.
769
+ print(f"Error opening spreadsheet: {exc}")
770
+ return 1
771
+
772
+ if args.once:
773
+ targets = worker.publishers()
774
+ print(f"Publishing {len(targets)} target(s) once.")
775
+ for target in targets:
776
+ try:
777
+ print(daemon_mod.PublishResult.of(target.publish()).message)
778
+ except Exception as exc:
779
+ print(f" ! {target.name} failed: {exc}")
780
+ return 1
781
+ return 0
782
+
783
+ # Prime before the loop so the backlog of a session already in progress
784
+ # does not fire a publish for every dock that has already happened.
785
+ state = worker.watcher.prime()
786
+ where = state.station_display or state.system or "unknown"
787
+ print(f"Watching the journal from {where}.")
788
+ print(worker.describe_targets())
789
+ for gap in worker.describe_gaps():
790
+ print(f" {gap}")
791
+ if not regions:
792
+ print(" NOT published: any construction region -- declare one with "
793
+ "--construction-region 'Tab!R1:AC60=Site Name'")
794
+ print(f"Poll {args.interval}s, debounce {args.debounce}s. Ctrl+C to stop.")
795
+ print()
796
+
797
+ # Publish once before watching. Priming establishes position WITHOUT
798
+ # replaying the backlog, which is right -- but it means the tabs keep
799
+ # whatever they held when the last run stopped, and nothing corrects them
800
+ # until the game happens to emit an event. A restart is exactly when the
801
+ # sheet is most likely to be wrong, and "correct only once something
802
+ # happens" is indistinguishable from "correct" to anyone reading it.
803
+ print("Catching up:")
804
+ for target in worker.publishers():
805
+ try:
806
+ print(daemon_mod.PublishResult.of(target.publish()).message)
807
+ except Exception as exc:
808
+ print(f" ! {target.name} catch-up failed: {exc}")
809
+ print()
810
+
811
+ try:
812
+ worker.run()
813
+ except KeyboardInterrupt:
814
+ stats = worker.stats
815
+ print()
816
+ # Every target, not just the two that predate regions -- a summary
817
+ # that names a subset is the same failure as a banner that does.
818
+ done = ", ".join(f"{n} x{c}" for n, c in stats.by_target.items()) or "nothing"
819
+ print(
820
+ f"Stopped. {stats.polls} polls, {stats.events} events, "
821
+ f"{stats.errors} errors. Published: {done}."
822
+ )
823
+ return 0
824
+
825
+
826
+ def _export_market(args, result, formats: list[str], sheet_id) -> list[str]:
827
+ """
828
+ Emit the station market as data. Returns human-readable descriptions.
829
+
830
+ csv/json need no spreadsheet and no Google credentials -- that is the point
831
+ of them. market-tab writes a generated tab the spreadsheet can VLOOKUP.
832
+ """
833
+ from .export import MarketExporter
834
+
835
+ done: list[str] = []
836
+ output_dir = Path(args.output) if args.output else None
837
+
838
+ if result.market is None and ("csv" in formats or "json" in formats):
839
+ print("No current market to export; skipping csv/json.")
840
+
841
+ if result.market is not None:
842
+ exporter = MarketExporter(output_dir)
843
+ if "csv" in formats:
844
+ done.append(f"csv: {exporter.export_csv(result.market)}")
845
+ if "json" in formats:
846
+ done.append(f"json: {exporter.export_json(result.market)}")
847
+
848
+ if "market-tab" in formats:
849
+ if not sheet_id:
850
+ raise ValueError("--export market-tab needs a spreadsheet id")
851
+ from .google import GoogleSheetsExporter
852
+ from .service import market_data_rows
853
+
854
+ # Writes the deliberately-empty grid when there is no current market,
855
+ # so the tab is actively cleared rather than left holding the previous
856
+ # station's prices under a fresh-looking header.
857
+ rows = GoogleSheetsExporter().export_grid(
858
+ market_data_rows(result), sheet_id=sheet_id
859
+ )
860
+ done.append(f"market-tab: {rows} commodity rows -> MarketData")
861
+
862
+ for line in done:
863
+ print(f"Exported {line}")
864
+ return done
865
+
866
+
867
+ def _market_result_json(result) -> dict:
868
+ """Machine-readable form of a refresh, for scripts and the HTTP API."""
869
+ return {
870
+ "ok": result.ok,
871
+ "reason": result.reason,
872
+ "advice": result.advice(),
873
+ "system": result.system,
874
+ "station": result.station,
875
+ "docked": result.location.docked,
876
+ "market_id": result.location.market_id,
877
+ "market": None
878
+ if result.market is None
879
+ else {
880
+ "station": result.market.station,
881
+ "source": result.market.source,
882
+ "items": len(result.market),
883
+ "timestamp": result.market.timestamp.isoformat()
884
+ if result.market.timestamp
885
+ else None,
886
+ "age_seconds": result.market.age_seconds,
887
+ },
888
+ "matches": [
889
+ {
890
+ "row": m.row,
891
+ "commodity": m.name,
892
+ "need": m.need,
893
+ "state": m.state.value,
894
+ "stock": m.stock,
895
+ "unit_price": m.unit_price,
896
+ "buyable_qty": m.buyable_qty,
897
+ "estimated_cost": m.estimated_cost,
898
+ "mark": m.should_mark,
899
+ }
900
+ for m in sorted(result.matches, key=lambda x: x.row)
901
+ ],
902
+ "marked_rows": result.plan.marked_rows if result.plan else [],
903
+ "written": result.written,
904
+ "summary": result.summary.describe() if result.ok else result.advice(),
905
+ }
906
+
907
+
908
+ def cmd_ship(args: argparse.Namespace) -> int:
909
+ """
910
+ Report the current ship's cargo hold.
911
+
912
+ A spreadsheet is required for exactly one thing here -- writing the
913
+ generated ShipCargo tab -- and for nothing else. Reading the hold, printing
914
+ it, and writing CSV or JSON all work with no Google credentials configured
915
+ and no sheet id anywhere. That asymmetry is deliberate: see issue #10,
916
+ where the market verb got this wrong and demanded a spreadsheet before it
917
+ would emit JSON.
918
+ """
919
+ import json as _json
920
+ from pathlib import Path as _Path
921
+
922
+ from .catalog import load_catalog
923
+ from .export import ShipCargoExporter, ship_payload
924
+ from .journal import JournalReader
925
+ from . import ship as ship_mod
926
+
927
+ formats = {f.strip().lower() for f in (args.export or "").split(",") if f.strip()}
928
+ unknown = formats - {"csv", "json", "ship-tab"}
929
+ if unknown:
930
+ print(f"Error: unknown export format(s): {', '.join(sorted(unknown))}")
931
+ print(" Valid: csv, json, ship-tab")
932
+ return 1
933
+
934
+ reader = JournalReader(_Path(args.journal_dir) if args.journal_dir else None)
935
+ if not reader.exists():
936
+ print("Error: no Elite Dangerous journal directory found.")
937
+ print(" Set ED_JOURNAL_DIR if your Saved Games folder is elsewhere.")
938
+ return 1
939
+
940
+ raw = reader.read_cargo_json()
941
+ if raw is None:
942
+ print("No Cargo.json found. The game writes it when your hold changes;")
943
+ print("it appears once you have loaded or unloaded something.")
944
+ return 1
945
+
946
+ cargo = ship_mod.from_journal(raw, load_catalog())
947
+
948
+ if not cargo.is_ship:
949
+ # The SRV writes to the same file. Reporting its hold as the ship's
950
+ # would feed a real number about the wrong vessel into column M.
951
+ print(f"Cargo.json currently describes the {cargo.vessel or 'unknown vessel'},")
952
+ print("not your ship. Board your ship so the game rewrites it.")
953
+ return 1
954
+
955
+ if args.json:
956
+ print(_json.dumps(ship_payload(cargo), indent=2, ensure_ascii=False))
957
+ else:
958
+ stamp = cargo.timestamp.isoformat() if cargo.timestamp else "unknown"
959
+ print(f"Ship cargo as of {stamp}")
960
+ print(f" {cargo.total:,} t across {len(cargo)} commodit"
961
+ f"{'y' if len(cargo) == 1 else 'ies'}")
962
+ if cargo.items:
963
+ width = max(len(i.name) for i in cargo.items)
964
+ print()
965
+ for item in sorted(cargo.items, key=lambda i: i.key):
966
+ stolen = f" ({item.stolen} stolen)" if item.stolen else ""
967
+ print(f" {item.name:<{width}} {item.count:>7,}{stolen}")
968
+ if cargo.total != cargo.count:
969
+ print()
970
+ print(f" Note: the file reports {cargo.count:,} t total but itemises "
971
+ f"{cargo.total:,} t.")
972
+
973
+ written = []
974
+ if formats & {"csv", "json"}:
975
+ exporter = ShipCargoExporter(_Path(args.output) if args.output else None)
976
+ if "csv" in formats:
977
+ written.append(f"csv: {exporter.export_csv(cargo)}")
978
+ if "json" in formats:
979
+ written.append(f"json: {exporter.export_json(cargo)}")
980
+
981
+ if "ship-tab" in formats:
982
+ sheet_id = get_sheet_id(args)
983
+ if not sheet_id:
984
+ print()
985
+ print("Error: --export ship-tab needs a spreadsheet id. Pass --sheet-id,")
986
+ print(" set ED_SHEET_ID, or add \"sheet_id\" to ~/.ed_capi_config.json.")
987
+ return 1
988
+ grid = ship_mod.sheet_grid(cargo)
989
+ if args.dry_run:
990
+ print()
991
+ print(f"Would write {len(grid)} rows to '{args.ship_tab}':")
992
+ for row in grid[:6]:
993
+ print(f" {row}")
994
+ if len(grid) > 6:
995
+ print(f" ... {len(grid) - 6} more")
996
+ else:
997
+ try:
998
+ from .google import GoogleSheetsExporter
999
+ except ImportError:
1000
+ print("Error: Google Sheets support not installed.")
1001
+ print("Install with: pip install edapitool[gsheets]")
1002
+ return 1
1003
+ try:
1004
+ GoogleSheetsExporter().export_grid(grid, sheet_id, args.ship_tab)
1005
+ except Exception as exc:
1006
+ print(f"Error writing '{args.ship_tab}': {exc}")
1007
+ return 1
1008
+ written.append(f"tab: {args.ship_tab} ({len(grid) - 3} commodities)")
1009
+
1010
+ if written:
1011
+ print()
1012
+ for line in written:
1013
+ print(f" {line}")
1014
+ return 0
1015
+
1016
+
1017
+ def cmd_version(args: argparse.Namespace) -> int:
1018
+ """Handle version command."""
1019
+ print(f"ED API Tool {get_version()}")
1020
+ return 0
1021
+
1022
+
1023
+ def main(argv: Optional[list[str]] = None) -> int:
1024
+ """Main entry point."""
1025
+ # Create parent parser with common arguments
1026
+ parent_parser = argparse.ArgumentParser(add_help=False)
1027
+ parent_parser.add_argument(
1028
+ "--client-id",
1029
+ help="Frontier API client ID",
1030
+ )
1031
+ parent_parser.add_argument(
1032
+ "--redirect-uri",
1033
+ help="Custom OAuth redirect URI (for manual auth flow)",
1034
+ )
1035
+ parent_parser.add_argument(
1036
+ "--manual-auth",
1037
+ action="store_true",
1038
+ help="Use manual authorization (copy/paste code from browser)",
1039
+ )
1040
+
1041
+ # Main parser
1042
+ parser = argparse.ArgumentParser(
1043
+ prog="edapitool",
1044
+ description="Elite Dangerous API Tool - Extract game data from CAPI",
1045
+ parents=[parent_parser],
1046
+ )
1047
+ parser.add_argument(
1048
+ "--version", "-V",
1049
+ action="store_true",
1050
+ help="Show version and exit",
1051
+ )
1052
+
1053
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
1054
+
1055
+ # Auth command
1056
+ auth_parser = subparsers.add_parser(
1057
+ "auth",
1058
+ help="Authenticate with Frontier",
1059
+ parents=[parent_parser],
1060
+ )
1061
+
1062
+ # Profile command
1063
+ profile_parser = subparsers.add_parser(
1064
+ "profile",
1065
+ help="Get commander profile",
1066
+ parents=[parent_parser],
1067
+ )
1068
+ profile_parser.add_argument("--json", action="store_true", help="Output raw JSON")
1069
+
1070
+ # Carrier command
1071
+ carrier_parser = subparsers.add_parser(
1072
+ "carrier",
1073
+ help="Get fleet carrier data",
1074
+ parents=[parent_parser],
1075
+ )
1076
+ carrier_parser.add_argument("--json", action="store_true", help="Output raw JSON")
1077
+ carrier_parser.add_argument(
1078
+ "--export", "-e",
1079
+ type=str,
1080
+ help="Export formats: csv,gsheet,google,json (comma-separated for multiple)",
1081
+ )
1082
+ carrier_parser.add_argument(
1083
+ "--output", "-o",
1084
+ help="Output directory for exports",
1085
+ )
1086
+ carrier_parser.add_argument(
1087
+ "--include",
1088
+ type=str,
1089
+ default="",
1090
+ help="Include cargo types: stolen,mission (comma-separated). Default excludes both.",
1091
+ )
1092
+ carrier_parser.add_argument(
1093
+ "--sheet-id",
1094
+ type=str,
1095
+ help="Google Sheet ID for direct export (required with --export google)",
1096
+ )
1097
+ carrier_parser.add_argument(
1098
+ "--legacy",
1099
+ action="store_true",
1100
+ help="Use Legacy galaxy server instead of Live",
1101
+ )
1102
+ carrier_parser.add_argument(
1103
+ "--raw",
1104
+ action="store_true",
1105
+ help="Include raw CAPI response in JSON export",
1106
+ )
1107
+
1108
+ # Market command
1109
+ market_parser = subparsers.add_parser(
1110
+ "market",
1111
+ help="Compare the current station's market against the spreadsheet",
1112
+ parents=[parent_parser],
1113
+ )
1114
+ market_parser.add_argument(
1115
+ "--sheet-id",
1116
+ help="Google Sheet ID (or set ED_SHEET_ID, or sheet_id in ~/.ed_capi_config.json)",
1117
+ )
1118
+ market_parser.add_argument(
1119
+ "--update-sheet",
1120
+ action="store_true",
1121
+ help="Write location cells and column markers to the spreadsheet",
1122
+ )
1123
+ market_parser.add_argument(
1124
+ "--dry-run",
1125
+ action="store_true",
1126
+ help="With --update-sheet, show exactly what would be written and write nothing",
1127
+ )
1128
+ market_parser.add_argument(
1129
+ "--no-sheet",
1130
+ action="store_true",
1131
+ help="Inspect location and market without opening the spreadsheet",
1132
+ )
1133
+ market_parser.add_argument(
1134
+ "--use-capi",
1135
+ action="store_true",
1136
+ help="Also query the Frontier CAPI market (live stock; needs authentication)",
1137
+ )
1138
+ market_parser.add_argument(
1139
+ "--journal-dir",
1140
+ help="Elite Dangerous journal directory (default: Saved Games location)",
1141
+ )
1142
+ market_parser.add_argument(
1143
+ "--totals-tab",
1144
+ default="Totals Tab",
1145
+ help="Name of the roll-up tab (default: 'Totals Tab')",
1146
+ )
1147
+ market_parser.add_argument(
1148
+ "--need-header",
1149
+ default="Left to buy",
1150
+ help="Header text of the outstanding-quantity column (default: 'Left to buy')",
1151
+ )
1152
+ market_parser.add_argument(
1153
+ "--need-sign",
1154
+ choices=["positive", "negative"],
1155
+ default="positive",
1156
+ help="Which sign means 'still to buy' (use 'negative' for a combined "
1157
+ "signed column where -229 means buy 229)",
1158
+ )
1159
+ market_parser.add_argument(
1160
+ "--marker-column",
1161
+ default="L",
1162
+ help="Column to write markers into (default: L)",
1163
+ )
1164
+ market_parser.add_argument(
1165
+ "--export", "-e",
1166
+ type=str,
1167
+ help="Emit the station market as data: csv,json (files), market-tab (a "
1168
+ "generated MarketData tab in the spreadsheet). Comma-separated. None "
1169
+ "of these depend on our marker formatting.",
1170
+ )
1171
+ market_parser.add_argument(
1172
+ "--output", "-o",
1173
+ help="Output directory for --export csv/json",
1174
+ )
1175
+ market_parser.add_argument(
1176
+ "--no-markers",
1177
+ action="store_true",
1178
+ help="Do not write the marker column. Pair with '--export market-tab' to let "
1179
+ "the spreadsheet render markers from the data using its own formulas.",
1180
+ )
1181
+ market_parser.add_argument(
1182
+ "--show-formula",
1183
+ action="store_true",
1184
+ help="Print the spreadsheet formula that reproduces the marker column from a "
1185
+ "MarketData tab, then exit",
1186
+ )
1187
+ market_parser.add_argument(
1188
+ "--no-show-covered",
1189
+ action="store_true",
1190
+ help="Do not mark commodities the station sells that you already have enough of "
1191
+ "(they are shown greyed out by default, so a blank cell means 'not sold here')",
1192
+ )
1193
+ market_parser.add_argument(
1194
+ "--no-colour", "--no-color",
1195
+ dest="no_colour",
1196
+ action="store_true",
1197
+ help="Write only the glyphs, leaving cell background and font colour alone",
1198
+ )
1199
+ market_parser.add_argument(
1200
+ "--write-marker-header",
1201
+ action="store_true",
1202
+ help="Also label the cell above the markers (default: leave it alone, "
1203
+ "it is yours)",
1204
+ )
1205
+ market_parser.add_argument(
1206
+ "--empty-marker",
1207
+ choices=["hollow", "small", "dotted"],
1208
+ default="hollow",
1209
+ help="Glyph for 'station sells it but has none right now': "
1210
+ "hollow circle (default, matches the filled/half-filled family), "
1211
+ "small white bullet, or dotted circle",
1212
+ )
1213
+ market_parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
1214
+
1215
+ # Version command
1216
+ ship_parser = subparsers.add_parser(
1217
+ "ship",
1218
+ help="Report the current ship's cargo hold",
1219
+ parents=[parent_parser],
1220
+ )
1221
+ ship_parser.add_argument(
1222
+ "--json", action="store_true", help="Output the hold as JSON on stdout"
1223
+ )
1224
+ ship_parser.add_argument(
1225
+ "--export", "-e",
1226
+ help="Comma-separated: csv, json, ship-tab (only ship-tab needs a spreadsheet)",
1227
+ )
1228
+ ship_parser.add_argument(
1229
+ "--output", "-o",
1230
+ help="Output directory for --export csv/json",
1231
+ )
1232
+ ship_parser.add_argument(
1233
+ "--sheet-id",
1234
+ help="Google Sheet ID; required only for --export ship-tab",
1235
+ )
1236
+ ship_parser.add_argument(
1237
+ "--ship-tab",
1238
+ default="ShipCargo",
1239
+ help="Name of the generated tab (default: 'ShipCargo')",
1240
+ )
1241
+ ship_parser.add_argument(
1242
+ "--dry-run",
1243
+ action="store_true",
1244
+ help="With --export ship-tab, show what would be written and write nothing",
1245
+ )
1246
+ ship_parser.add_argument(
1247
+ "--journal-dir",
1248
+ help="Elite Dangerous journal directory (default: Saved Games location)",
1249
+ )
1250
+
1251
+ construction_parser = subparsers.add_parser(
1252
+ "construction",
1253
+ help="What a colony construction site still needs",
1254
+ # Deliberately NOT parents=[parent_parser]. That parent carries only
1255
+ # Frontier OAuth options -- client id, redirect uri, manual auth -- and
1256
+ # this verb never authenticates: it reads the commander's own journal.
1257
+ # Advertising three credential flags on the one verb whose selling
1258
+ # point is "no login required" tells the reader the opposite of the
1259
+ # truth about what it needs.
1260
+ )
1261
+ construction_parser.add_argument(
1262
+ "--json", action="store_true", help="Output the site as JSON on stdout"
1263
+ )
1264
+ construction_parser.add_argument(
1265
+ "--export", "-e", help="Comma-separated: csv, json (neither needs a spreadsheet)"
1266
+ )
1267
+ construction_parser.add_argument(
1268
+ "--output", "-o", help="Output directory for --export"
1269
+ )
1270
+ construction_parser.add_argument(
1271
+ "--site",
1272
+ help="Which site: a MarketID, or the station name as you would type it "
1273
+ "(the game's 'Planetary Construction Site: ' prefix is optional). "
1274
+ "Defaults to wherever you are docked, else the most recent",
1275
+ )
1276
+ construction_parser.add_argument(
1277
+ "--system",
1278
+ help="Disambiguate --site when two construction sites share a station name",
1279
+ )
1280
+ construction_parser.add_argument(
1281
+ "--all", action="store_true",
1282
+ help="Include completed, failed and stale sites (default: active only)",
1283
+ )
1284
+ construction_parser.add_argument(
1285
+ "--stale-after", type=int, default=60, metavar="DAYS",
1286
+ help="Days without the game mentioning a site before it counts as "
1287
+ "stale (default: 60). The game never marks a lapsed build failed, "
1288
+ "so elapsed time is the only available signal",
1289
+ )
1290
+ construction_parser.add_argument(
1291
+ "--list", action="store_true",
1292
+ help="List every construction site the journal knows about, and exit",
1293
+ )
1294
+ construction_parser.add_argument(
1295
+ "--all-sites", action="store_true",
1296
+ help="With --json, emit every known site rather than one",
1297
+ )
1298
+ construction_parser.add_argument(
1299
+ "--scan-files", type=int, nargs="?", default=120, const=0,
1300
+ metavar="N",
1301
+ help="How many journal files back to read (default: 120). Pass the "
1302
+ "flag with no number to read ALL of them. Measured on a 621-file "
1303
+ "journal: 6 files found 3 sites in 0.09s, 120 found all 6 in "
1304
+ "1.0s, and every file took 4.4s to find no more",
1305
+ )
1306
+ construction_parser.add_argument(
1307
+ "--journal-dir", help="Elite Dangerous journal directory"
1308
+ )
1309
+ construction_parser.add_argument(
1310
+ "--publish-to", metavar="TAB",
1311
+ help="Publish the site as a data block into a REGION of this tab, "
1312
+ "beside whatever else the tab already holds. Requires --region "
1313
+ "and --sheet-id",
1314
+ )
1315
+ construction_parser.add_argument(
1316
+ "--region", metavar="A1:B2",
1317
+ help="The rectangle on --publish-to that this block owns. Declared, "
1318
+ "never guessed: everything inside it is cleared on every publish, "
1319
+ "so a shrinking site leaves nothing behind, and everything "
1320
+ "outside it is never touched. Reserve more than you need -- "
1321
+ "growing inside the reserve costs nothing",
1322
+ )
1323
+ construction_parser.add_argument(
1324
+ "--sheet-id", help="Google Sheet ID (or ED_SHEET_ID, or the config file)"
1325
+ )
1326
+
1327
+ serve_parser = subparsers.add_parser(
1328
+ "serve",
1329
+ help="Keep the generated tabs current while the game runs",
1330
+ parents=[parent_parser],
1331
+ )
1332
+ serve_parser.add_argument(
1333
+ "--construction-region", action="append", metavar="TAB!RANGE[=SITE]",
1334
+ help="Also keep a construction block current in a region of a tab "
1335
+ "this tool does not own, e.g. "
1336
+ "\"Agri Lrg. (ex)!R1:AC60=Badeaux Nutrition Centre\". The site "
1337
+ "may be named, named by a name it USED to have, or given as a "
1338
+ "market id; omit it and the block follows whichever site you "
1339
+ "are docked at. Repeat the flag for more than one region. To "
1340
+ "set this once instead of typing it each session, put a "
1341
+ "\"construction_regions\" list in the config file; this flag "
1342
+ "then overrides it outright rather than adding to it",
1343
+ )
1344
+ serve_parser.add_argument(
1345
+ "--sheet-id",
1346
+ help='Google Sheet ID (or ED_SHEET_ID, or "sheet_id" in the config '
1347
+ 'file)',
1348
+ )
1349
+ serve_parser.add_argument(
1350
+ "--journal-dir", help="Elite Dangerous journal directory"
1351
+ )
1352
+ serve_parser.add_argument(
1353
+ "--ship-tab", default="ShipCargo", help="Tab for the ship's hold"
1354
+ )
1355
+ serve_parser.add_argument(
1356
+ "--totals-tab", default="Totals Tab",
1357
+ help="Name of the roll-up tab whose location cells are refreshed "
1358
+ "with --write-location (default: 'Totals Tab')",
1359
+ )
1360
+ serve_parser.add_argument(
1361
+ "--interval", type=float, default=2.0,
1362
+ help="Seconds between journal polls (default: 2)",
1363
+ )
1364
+ serve_parser.add_argument(
1365
+ "--debounce", type=float, default=5.0,
1366
+ help="Seconds of quiet before publishing. One play session emitted 19 "
1367
+ "Market events; without this each would be a separate write "
1368
+ "(default: 5)",
1369
+ )
1370
+ serve_parser.add_argument(
1371
+ "--once", action="store_true",
1372
+ help="Publish both tabs once and exit, instead of watching",
1373
+ )
1374
+ serve_parser.add_argument(
1375
+ "--write-location", action="store_true",
1376
+ help="Write the current system and station into the roll-up tab's "
1377
+ "location cells. Off by default: those cells are better as "
1378
+ "formulas reading the generated MarketData tab, and writing "
1379
+ "literals would overwrite them. Use only for a sheet that still "
1380
+ "expects the tool to paint them",
1381
+ )
1382
+
1383
+ version_parser = subparsers.add_parser("version", help="Show version")
1384
+
1385
+ args = parser.parse_args(argv)
1386
+
1387
+ if args.version:
1388
+ return cmd_version(args)
1389
+
1390
+ if args.command == "auth":
1391
+ return cmd_auth(args)
1392
+ elif args.command == "profile":
1393
+ return cmd_profile(args)
1394
+ elif args.command == "carrier":
1395
+ return cmd_carrier(args)
1396
+ elif args.command == "market":
1397
+ return cmd_market(args)
1398
+ elif args.command == "ship":
1399
+ return cmd_ship(args)
1400
+ elif args.command == "construction":
1401
+ return cmd_construction(args)
1402
+ elif args.command == "serve":
1403
+ return cmd_serve(args)
1404
+ elif args.command == "version":
1405
+ return cmd_version(args)
1406
+ else:
1407
+ parser.print_help()
1408
+ return 0
1409
+
1410
+
1411
+ def carrier_main(argv: Optional[list[str]] = None) -> int:
1412
+ """Shortcut entry point for carrier command."""
1413
+ if argv is None:
1414
+ argv = sys.argv[1:]
1415
+ return main(["carrier"] + list(argv))
1416
+
1417
+
1418
+ if __name__ == "__main__":
1419
+ sys.exit(main())