nexscope-cli 0.1.0__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.
Files changed (74) hide show
  1. nexscope_cli/__init__.py +4 -0
  2. nexscope_cli/api-contract.json +37 -0
  3. nexscope_cli/capabilities/__init__.py +3 -0
  4. nexscope_cli/capabilities/catalog.py +73 -0
  5. nexscope_cli/capabilities/mock.py +53 -0
  6. nexscope_cli/capabilities/plan.py +142 -0
  7. nexscope_cli/catalog.json +69 -0
  8. nexscope_cli/compat/__init__.py +4 -0
  9. nexscope_cli/compat/legacy.py +143 -0
  10. nexscope_cli/core/__init__.py +3 -0
  11. nexscope_cli/core/auth.py +81 -0
  12. nexscope_cli/core/datasets.py +305 -0
  13. nexscope_cli/core/errors.py +11 -0
  14. nexscope_cli/core/evidence.py +134 -0
  15. nexscope_cli/core/execution.py +171 -0
  16. nexscope_cli/core/remote.py +22 -0
  17. nexscope_cli/core/transport.py +61 -0
  18. nexscope_cli/dependencies.lock +282 -0
  19. nexscope_cli/dependency-lock.json +371 -0
  20. nexscope_cli/doctor.py +136 -0
  21. nexscope_cli/main.py +313 -0
  22. nexscope_cli/reports/__init__.py +3 -0
  23. nexscope_cli/reports/compare.py +135 -0
  24. nexscope_cli/reports/formats.py +99 -0
  25. nexscope_cli/resources/__init__.py +3 -0
  26. nexscope_cli/resources/fields.json +33 -0
  27. nexscope_cli/resources/mock-products.json +9 -0
  28. nexscope_cli/resources/products-sample.csv +5 -0
  29. nexscope_cli/resources/products-sample.json +6 -0
  30. nexscope_cli/resources/products-template.csv +1 -0
  31. nexscope_cli/resources/products.schema.json +182 -0
  32. nexscope_cli/resources/report.html +15 -0
  33. nexscope_cli/runner.py +43 -0
  34. nexscope_cli/skills/amazon-product-detail/SKILL.md +170 -0
  35. nexscope_cli/skills/amazon-product-detail/references/api.md +239 -0
  36. nexscope_cli/skills/amazon-product-detail/requirements.txt +1 -0
  37. nexscope_cli/skills/amazon-product-detail/scripts/amazon_product_detail.py +26 -0
  38. nexscope_cli/skills/amazon-search/SKILL.md +197 -0
  39. nexscope_cli/skills/amazon-search/references/api.md +189 -0
  40. nexscope_cli/skills/amazon-search/requirements.txt +1 -0
  41. nexscope_cli/skills/amazon-search/scripts/amazon_search.py +26 -0
  42. nexscope_cli/workflows/__init__.py +3 -0
  43. nexscope_cli/workflows/compare.py +213 -0
  44. nexscope_cli-0.1.0.dist-info/METADATA +257 -0
  45. nexscope_cli-0.1.0.dist-info/RECORD +74 -0
  46. nexscope_cli-0.1.0.dist-info/WHEEL +5 -0
  47. nexscope_cli-0.1.0.dist-info/entry_points.txt +2 -0
  48. nexscope_cli-0.1.0.dist-info/licenses/ASSET-NOTICES.md +9 -0
  49. nexscope_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  50. nexscope_cli-0.1.0.dist-info/licenses/NOTICE +20 -0
  51. nexscope_cli-0.1.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +27 -0
  52. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/backports-tarfile/LICENSE +17 -0
  53. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/certifi/LICENSE +20 -0
  54. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cffi/LICENSE +23 -0
  55. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/charset-normalizer/LICENSE +21 -0
  56. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE +3 -0
  57. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE.APACHE +202 -0
  58. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE.BSD +27 -0
  59. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/idna/LICENSE.md +31 -0
  60. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/importlib-metadata/LICENSE +73 -0
  61. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-classes/LICENSE +17 -0
  62. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-context/LICENSE +18 -0
  63. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-functools/LICENSE +18 -0
  64. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jeepney/LICENSE +21 -0
  65. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/keyring/LICENSE +18 -0
  66. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/more-itertools/LICENSE +19 -0
  67. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/pycparser/LICENSE +27 -0
  68. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/pywin32-ctypes/LICENSE.txt +28 -0
  69. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/requests/LICENSE +175 -0
  70. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/requests/NOTICE +2 -0
  71. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/secretstorage/LICENSE +25 -0
  72. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/urllib3/LICENSE.txt +21 -0
  73. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/zipp/LICENSE +18 -0
  74. nexscope_cli-0.1.0.dist-info/top_level.txt +1 -0
nexscope_cli/main.py ADDED
@@ -0,0 +1,313 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Independent, OS-keyring-backed Nexscope CLI. Never prints credentials."""
4
+ import argparse
5
+ import csv
6
+ import json
7
+ from pathlib import Path
8
+ import subprocess
9
+ import sys
10
+ import uuid
11
+ from urllib.error import HTTPError, URLError
12
+ from urllib.request import Request, build_opener, HTTPRedirectHandler
13
+
14
+ from . import __version__
15
+ from .core.datasets import DatasetError
16
+ from .core.auth import DEFAULT_ORIGIN, MODES, origin, resolve_auth
17
+ from .core.errors import AuthRequired, Failure
18
+
19
+ ROOT = Path(__file__).parent
20
+
21
+
22
+ class NoRedirect(HTTPRedirectHandler):
23
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
24
+ raise Failure('Redirect refused; check the API origin.')
25
+
26
+
27
+ class Client:
28
+ def __init__(self, base):
29
+ self.base = origin(base)
30
+ import keyring
31
+ backend = keyring.get_keyring()
32
+ # Fail closed: do not fall back to plaintext files or inherited API keys.
33
+ module = type(backend).__module__
34
+ if module not in ('keyring.backends.macOS', 'keyring.backends.Windows', 'keyring.backends.SecretService', 'keyring.backends.kwallet'):
35
+ raise Failure('An OS credential store is required. Configure a supported keyring backend.')
36
+ self.store = backend
37
+ self.service = 'nexscope-cli:' + self.base
38
+
39
+ def load(self, name):
40
+ raw = self.store.get_password(self.service, name)
41
+ return json.loads(raw) if raw else None
42
+
43
+ def save(self, name, value):
44
+ self.store.set_password(self.service, name, json.dumps(value))
45
+
46
+ def clear(self, name):
47
+ if self.load(name) is not None:
48
+ self.store.delete_password(self.service, name)
49
+
50
+ def api(self, path, body=None, authenticated=False, context=None):
51
+ headers = {'Accept': 'application/json', 'Content-Type': 'application/json'}
52
+ if authenticated:
53
+ context = context or resolve_auth('browser-account', base=self.base, credential_loader=lambda: self.load('credential'))
54
+ if context.mode != 'browser-account' or context.base != self.base:
55
+ raise Failure('Browser API requires its own origin-bound browser-account context.')
56
+ headers['Authorization'] = context.authorization()
57
+ req = Request(self.base + '/api/cli/' + path, data=None if body is None else json.dumps(body).encode(), headers=headers)
58
+ try:
59
+ with build_opener(NoRedirect()).open(req, timeout=30) as response:
60
+ result = json.load(response)
61
+ except HTTPError as e:
62
+ e.close()
63
+ if e.code == 401 and authenticated:
64
+ raise AuthRequired('AUTH_REQUIRED: reconnect with nexscope auth login.') from None
65
+ raise Failure(f'HTTP {e.code}; request not retried.') from None
66
+ except (URLError, TimeoutError):
67
+ raise Failure('Network unavailable; request not retried.') from None
68
+ if not isinstance(result, dict):
69
+ raise Failure('Invalid API response.')
70
+ if result.get('code') in (12001, 12003):
71
+ raise AuthRequired('AUTH_REQUIRED: reconnect with nexscope auth login.')
72
+ if result.get('code') != 0:
73
+ raise Failure(f"API error {result.get('code')}; request not retried.")
74
+ return result.get('data')
75
+
76
+ def auth(self, action, attribution=None):
77
+ if action == 'login':
78
+ if self.load('credential'):
79
+ try:
80
+ return self.api('account', authenticated=True)
81
+ except AuthRequired:
82
+ self.clear('credential')
83
+ pending = self.load('pending')
84
+ import time
85
+ if not pending or int(pending['expiresAt']) <= time.time() * 1000:
86
+ pending = self.api('auth/start', {} if attribution is None else {'attribution': attribution})
87
+ self.save('pending', pending)
88
+ return {k: v for k, v in pending.items() if k != 'pollSecret'}
89
+ if action == 'poll':
90
+ pending = self.load('pending')
91
+ if not pending:
92
+ return self.api('account', authenticated=True)
93
+ body = {k: pending[k] for k in ('requestId', 'pollSecret')}
94
+ value = self.api('auth/poll', body)
95
+ if value['status'] == 'READY':
96
+ self.save('credential', value)
97
+ self.api('auth/poll', {**body, 'acknowledge': True})
98
+ self.clear('pending')
99
+ return self.api('account', authenticated=True)
100
+ if value['status'] in ('DENIED', 'AUTH_EXPIRED', 'CONNECTED'):
101
+ self.clear('pending')
102
+ return {k: v for k, v in value.items() if k != 'apiKey'}
103
+ if action == 'logout':
104
+ if self.load('credential'):
105
+ self.api('disconnect', {}, authenticated=True)
106
+ self.clear('credential')
107
+ self.clear('pending')
108
+ return {'status': 'DISCONNECTED'}
109
+ return self.api('account', authenticated=True)
110
+
111
+
112
+ def catalog():
113
+ return json.loads((ROOT / 'catalog.json').read_text())['skills']
114
+
115
+
116
+ def guide(name):
117
+ skills = catalog()
118
+ if name == 'core':
119
+ return {'version': __version__, 'skills': {k: v['description'] for k, v in skills.items() if v.get('available', True)},
120
+ 'unavailable': {k: v['unavailable'] for k, v in skills.items() if not v.get('available', True)},
121
+ 'execution': 'Read get-skills <name>; use run <name> [--script path] -- <script arguments>. Never run packaged scripts directly. Auth: login, user done, poll. Credits: account, link, user done, account.'}
122
+ if name not in skills:
123
+ raise Failure('Unknown skill; use get-skills core.')
124
+ if not skills[name].get('available', True):
125
+ return {'status': 'UNAVAILABLE', 'skill': name, **skills[name]['unavailable']}
126
+ return {'skill': name, 'resourceDirectory': str(ROOT / 'skills' / name), 'scripts': skills[name]['scripts'], 'instructions': (ROOT / 'skills' / name / 'SKILL.md').read_text(encoding='utf-8'),
127
+ 'execution': 'Replace python script.py with nexscope run ' + name + ' --script <listed script> -- . Relative input paths must be absolute because runs use isolated output directories.'}
128
+
129
+
130
+ def run(args, client):
131
+ entry = catalog().get(args.skill)
132
+ if not entry:
133
+ raise Failure('Unknown skill.')
134
+ if not entry.get('available', True):
135
+ raise Failure('UNAVAILABLE: ' + entry['unavailable']['reason'])
136
+ script = args.script or (entry['scripts'][0] if len(entry['scripts']) == 1 else None)
137
+ if script not in entry['scripts']:
138
+ raise Failure('Choose --script from get-skills output.')
139
+ context = resolve_auth('browser-account', base=client.base, credential_loader=lambda: client.load('credential'))
140
+ account = client.api('account', authenticated=True, context=context)
141
+ folder = Path(args.output).resolve() / str(uuid.uuid4())
142
+ folder.mkdir(parents=True, mode=0o700)
143
+ env = context.script_environment()
144
+ arguments = args.arguments[1:] if args.arguments[:1] == ['--'] else args.arguments
145
+ # No automatic replay: scripts may create reports, change ads, or consume credits.
146
+ result = subprocess.run([sys.executable, str(ROOT / 'runner.py'), str(ROOT / 'skills' / args.skill / script), *arguments], cwd=folder, env=env, capture_output=True, text=True)
147
+ output = context.redact(result.stdout + result.stderr)
148
+ (folder / 'output.txt').write_text(output)
149
+ files = list(folder.rglob('*.json'))
150
+ failures = []
151
+ for path in files:
152
+ try:
153
+ raw = path.read_text()
154
+ cleaned = context.redact(raw)
155
+ if raw != cleaned:
156
+ path.write_text(cleaned)
157
+ data = json.loads(cleaned)
158
+ if isinstance(data, dict) and (data.get('error') or data.get('success') is False or data.get('code', 0) not in (0, '0', None) or data.get('errcode', 0) not in (0, '0', None)):
159
+ failures.append(str(path))
160
+ except (ValueError, OSError):
161
+ pass
162
+ summary = {'status': 'REVIEW_REQUIRED' if result.returncode or failures else 'OUTPUT_READY', 'exitCode': result.returncode,
163
+ 'accountId': account['accountId'], 'output': str(folder), 'errorFiles': failures,
164
+ 'preview': output[:8000], 'rechargeUrl': account['rechargeUrl']}
165
+ (folder / 'result.json').write_text(json.dumps(summary, indent=2))
166
+ return summary
167
+
168
+
169
+ def export(args):
170
+ rows = json.loads(Path(args.input).read_text())
171
+ for key in args.path.split('.') if args.path else []:
172
+ rows = rows[int(key)] if isinstance(rows, list) else rows[key]
173
+ if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows):
174
+ raise Failure('CSV input must select a list of objects.')
175
+ fields = list(dict.fromkeys(k for row in rows for k in row))
176
+ def cell(value):
177
+ value = json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value if value is not None else '')
178
+ return "'" + value if value.lstrip().startswith(('=', '+', '-', '@')) else value
179
+ with open(args.output, 'x', newline='', encoding='utf-8-sig') as f:
180
+ writer = csv.writer(f)
181
+ writer.writerow([cell(k) for k in fields])
182
+ writer.writerows([cell(row.get(k)) for k in fields] for row in rows)
183
+ return {'status': 'EXPORTED', 'rows': len(rows), 'file': str(Path(args.output).resolve())}
184
+
185
+
186
+ def main():
187
+ from .capabilities.mock import SCENARIOS
188
+ parser = argparse.ArgumentParser(prog='nexscope')
189
+ parser.add_argument('--version', action='version', version=__version__)
190
+ parser.add_argument('--api-base', default=None)
191
+ commands = parser.add_subparsers(dest='command', required=True)
192
+ doctor = commands.add_parser('doctor', help='Local diagnostics; no network or stored credential access.')
193
+ doctor.add_argument('--profile', choices=['offline', 'independent', 'plugin'], default='offline')
194
+ doctor.add_argument('--offline', dest='profile', action='store_const', const='offline', help='Alias for --profile offline.')
195
+ doctor.add_argument('--check-output', help='Probe an existing directory with a temporary file, then remove the probe.')
196
+ doctor.add_argument('--json', action='store_true', help='JSON is the default output format.')
197
+ commands.add_parser('get-skills').add_argument('skill', nargs='?', default='core')
198
+ auth_parser = commands.add_parser('auth')
199
+ auth_parser.add_argument('action', choices=['login', 'poll', 'status', 'logout'])
200
+ auth_parser.add_argument('--source-client', choices=['python-cli', 'codex-plugin', 'claude-code'])
201
+ auth_parser.add_argument('--source-channel', choices=['github', 'marketplace', 'local', 'unknown'], default='unknown')
202
+ auth_parser.add_argument('--source-entrypoint', choices=['cli', 'plugin', 'report'], default='cli')
203
+ commands.add_parser('account')
204
+ capabilities = commands.add_parser('capabilities', help='Offline, versioned pilot capability descriptions.')
205
+ describe_capability = capabilities.add_subparsers(dest='capability_action', required=True).add_parser('describe')
206
+ describe_capability.add_argument('capability')
207
+ describe_capability.add_argument('--json', action='store_true', help='JSON is the default output format.')
208
+ data = commands.add_parser('data', help='Plan bounded data requests without network or credentials.')
209
+ data_run = data.add_subparsers(dest='data_action', required=True).add_parser('run')
210
+ data_run.add_argument('capability')
211
+ data_run.add_argument('--input', required=True)
212
+ execution = data_run.add_mutually_exclusive_group(required=True)
213
+ execution.add_argument('--dry-run', action='store_true')
214
+ execution.add_argument('--execute', action='store_true', help='Execute an explicit mock scenario or authorized remote scope.')
215
+ data_run.add_argument('--mock', choices=SCENARIOS)
216
+ data_run.add_argument('--allow-paid', action='store_true', help='Explicitly authorize bounded remote calls; max-calls is not a monetary cap.')
217
+ data_run.add_argument('--out', help='New evidence directory for mock execution.')
218
+ data_run.add_argument('--auth-mode', choices=MODES, default='browser-account')
219
+ data_run.add_argument('--max-calls', type=int, default=3)
220
+ data_run.add_argument('--json', action='store_true', help='JSON is the default output format.')
221
+ workflows = commands.add_parser('workflow', help='Product comparison workflow; remote calls require --execute --allow-paid.')
222
+ workflow = workflows.add_subparsers(dest='workflow_action', required=True).add_parser('compare')
223
+ inputs = workflow.add_mutually_exclusive_group(required=True)
224
+ inputs.add_argument('--asins')
225
+ inputs.add_argument('--keyword')
226
+ inputs.add_argument('--resume', help='Import a completed candidates directory; do not repeat its search.')
227
+ workflow.add_argument('--select-asins')
228
+ workflow.add_argument('--market', help='Defaults to amazon.com, or the saved marketplace with --resume.')
229
+ workflow.add_argument('--auth-mode', choices=MODES, help='Defaults to browser-account, or the saved mode with --resume.')
230
+ workflow.add_argument('--max-calls', type=int, help='Defaults to 4, or the original cumulative budget with --resume.')
231
+ workflow.add_argument('--out', help='New output directory; never overwrite existing evidence.')
232
+ workflow.add_argument('--format', choices=['html', 'csv', 'markdown', 'all'], default='html')
233
+ workflow.add_argument('--mock', choices=SCENARIOS)
234
+ workflow.add_argument('--allow-paid', action='store_true', help='Explicitly authorize bounded remote calls; never retries.')
235
+ workflow.add_argument('--json', action='store_true')
236
+ workflow_execution = workflow.add_mutually_exclusive_group(required=True)
237
+ workflow_execution.add_argument('--dry-run', action='store_true')
238
+ workflow_execution.add_argument('--execute', action='store_true')
239
+ r = commands.add_parser('run')
240
+ r.add_argument('skill'); r.add_argument('--script'); r.add_argument('--output', default='nexscope')
241
+ e = commands.add_parser('export')
242
+ e.add_argument('input'); e.add_argument('--path', default=''); e.add_argument('--output', required=True)
243
+ report = commands.add_parser('report', help='Offline reports from your own data; no account needed.')
244
+ reports = report.add_subparsers(dest='report_action', required=True)
245
+ describe = reports.add_parser('describe', help='Describe input fields, formats and local limits.')
246
+ describe.add_argument('--schema', action='store_true', help='Print the JSON input schema.')
247
+ template = reports.add_parser('template', help='Export a blank standard CSV template.')
248
+ template.add_argument('--format', choices=['csv'], default='csv')
249
+ template.add_argument('--out', required=True)
250
+ demo = reports.add_parser('demo', help='Export fictional sample data (not live market data).')
251
+ demo.add_argument('--format', choices=['csv', 'json'], default='csv')
252
+ demo.add_argument('--out', required=True)
253
+ compare = reports.add_parser('compare', help='Compare a user CSV, JSON array or evidence-backed dataset offline.')
254
+ compare.add_argument('--input', required=True)
255
+ compare.add_argument('--format', choices=['html', 'csv', 'markdown'], default='html')
256
+ compare.add_argument('--out', required=True)
257
+ compare.add_argument('--dry-run', action='store_true', help='Validate and describe outputs without writing.')
258
+ demos = commands.add_parser('demo', help='Run a complete fictional comparison without credentials or network.')
259
+ comparison_demo = demos.add_subparsers(dest='demo_action', required=True).add_parser('product-comparison')
260
+ comparison_demo.add_argument('--out', required=True, help='New output directory.')
261
+ # Split script arguments explicitly so option names cannot change CLI behavior.
262
+ argv = sys.argv[1:]
263
+ tail = argv.index('--') if '--' in argv else len(argv)
264
+ args = parser.parse_args(argv[:tail])
265
+ args.arguments = argv[tail + 1:]
266
+ try:
267
+ if args.command == 'doctor':
268
+ from .doctor import diagnose
269
+ value = diagnose(args.profile, args.check_output, args.api_base)
270
+ elif args.command == 'get-skills': value = guide(args.skill)
271
+ elif args.command == 'export': value = export(args)
272
+ elif args.command == 'report':
273
+ from .reports.compare import command
274
+ value = command(args)
275
+ elif args.command == 'capabilities':
276
+ from .capabilities.catalog import describe
277
+ value = describe(args.capability)
278
+ elif args.command == 'data':
279
+ from .capabilities.plan import command
280
+ value = command(args)
281
+ elif args.command == 'workflow':
282
+ from .workflows.compare import command
283
+ value = command(args)
284
+ elif args.command == 'demo':
285
+ from .capabilities.mock import executor
286
+ from .workflows.compare import compare
287
+ value = compare(executor('browser-account', 'complete', 3), args.out,
288
+ asins='B000000001,B000000002,B000000003', mock=True, format='all')
289
+ else:
290
+ client = Client(DEFAULT_ORIGIN if args.api_base is None else args.api_base)
291
+ source = None
292
+ if args.command == 'auth' and args.source_client:
293
+ if args.action != 'login':
294
+ raise Failure('Source options are supported only for auth login.')
295
+ source = {'version': 1, 'client': args.source_client, 'channel': args.source_channel,
296
+ 'campaign': 'cli-data-release', 'entrypoint': args.source_entrypoint}
297
+ value = run(args, client) if args.command == 'run' else client.auth(args.action if args.command == 'auth' else 'status', source)
298
+ print(json.dumps(value, ensure_ascii=False, indent=2))
299
+ return 1 if value.get('status') in ('REVIEW_REQUIRED', 'UNAVAILABLE', 'PARTIAL', 'FAILED', 'INSUFFICIENT_DATA') else 0
300
+ except DatasetError as e:
301
+ print(json.dumps({'status': 'INVALID_INPUT', 'message': str(e), 'issues': e.issues}))
302
+ return 1
303
+ except (Failure, OSError, ValueError, KeyError) as e:
304
+ print(json.dumps({'status': 'ERROR', 'message': str(e)}))
305
+ return 1
306
+ except Exception as e:
307
+ # Backend/keyring exceptions may embed secret-bearing request details.
308
+ print(json.dumps({'status': 'ERROR', 'message': 'Operation failed (' + type(e).__name__ + '). No automatic retry; check the credential store or saved output.'}))
309
+ return 1
310
+
311
+
312
+ if __name__ == '__main__':
313
+ sys.exit(main())
@@ -0,0 +1,3 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Offline reporting; importing this package never connects an account."""
@@ -0,0 +1,135 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Self-contained HTML reports with input evidence alongside the report."""
4
+ from collections import defaultdict
5
+ from datetime import datetime, timezone
6
+ import hashlib
7
+ from html import escape
8
+ from importlib.resources import files
9
+ import json
10
+ from pathlib import Path
11
+ import re
12
+ from string import Template
13
+ from urllib.parse import quote
14
+
15
+ from nexscope_cli.core.datasets import FIELDS, read_dataset
16
+ from nexscope_cli.core.evidence import Artifacts, decode, read_bundle
17
+ from .formats import FORMATS, render_csv, render_markdown
18
+
19
+
20
+ def resource(name):
21
+ return files('nexscope_cli.resources').joinpath(name)
22
+
23
+
24
+ def display(value):
25
+ if value is None or value == '':
26
+ return 'Not provided'
27
+ if isinstance(value, (dict, list)):
28
+ value = json.dumps(value, ensure_ascii=False)
29
+ return escape(str(value))
30
+
31
+
32
+ def render(dataset, evidence_name):
33
+ groups = defaultdict(list)
34
+ for product in dataset['products']:
35
+ groups[(product['marketplace'], product['currency'], product['rating_scale'])].append(product)
36
+ sections = []
37
+ for (market, currency, scale), products in groups.items():
38
+ title = f'Market: {display(market)} | Currency: {display(currency)} | Rating scale: {display(scale)}'
39
+ headers = ''.join(f'<th>{field}</th>' for field in (*FIELDS, 'source_row', 'extra'))
40
+ rows = []
41
+ for product in products:
42
+ cells = []
43
+ for field in (*FIELDS, 'source_row', 'extra'):
44
+ value = product[field]
45
+ content = display(value)
46
+ if field == 'source_url' and value:
47
+ content = f'<a rel="noreferrer noopener" href="{escape(value, quote=True)}">{content}</a>'
48
+ if field == 'extra' and dataset['sourceType'] in ('mock', 'remote'):
49
+ filename = value.get('evidenceFile', '')
50
+ if re.fullmatch(r'request-[0-9]+\.json', filename):
51
+ href = escape(quote(evidence_name) + '/' + filename, quote=True)
52
+ content = f'<a href="{href}">Request evidence</a><br>' + content
53
+ cells.append(f'<td>{content}</td>')
54
+ rows.append('<tr>' + ''.join(cells) + '</tr>')
55
+ sections.append(f'<section><h2>{title}</h2><p>{len(products)} products</p><div class="scroll"><table><thead><tr>{headers}</tr></thead><tbody>{"".join(rows)}</tbody></table></div></section>')
56
+ missing = sum(any(product[field] is None for field in FIELDS) for product in dataset['products'])
57
+ notice = 'Source: user_import. Data was supplied by the user and has not been refreshed or independently verified. Bundled samples are fictional.'
58
+ if dataset['sourceType'] in ('mock', 'remote'):
59
+ notice = ('MOCK / FICTIONAL DATA: no live service was queried. This report tests the workflow; it is not market research.'
60
+ if dataset['sourceType'] == 'mock' else 'REMOTE DATA: retrieved at the recorded times; missing fields are not inferred. Review the request evidence and partial failures.')
61
+ sections.insert(0, '<section><h2>Collection status: ' + display(dataset['collectionStatus']) + '</h2><p>Failed or skipped products: ' + display(dataset['errors']) + '</p><p>Request accounting: ' + display(dataset['budget']) + '</p></section>')
62
+ return Template(resource('report.html').read_text(encoding='utf-8')).substitute(
63
+ count=len(dataset['products']), missing=missing, groups=len(groups),
64
+ imported=escape(dataset['importedAt']), digest=dataset['inputSha256'],
65
+ evidence=escape(quote(evidence_name), quote=True), sections='\n'.join(sections),
66
+ source_notice=escape(notice),
67
+ )
68
+
69
+
70
+ def render_format(dataset, evidence_name, format='html'):
71
+ if format == 'html':
72
+ return render(dataset, evidence_name)
73
+ if format == 'csv':
74
+ return render_csv(dataset, evidence_name)
75
+ if format == 'markdown':
76
+ return render_markdown(dataset, evidence_name)
77
+ raise ValueError('Choose html, csv or markdown.')
78
+
79
+
80
+ def compare(input_path, output_path, dry_run=False, format='html'):
81
+ dataset, original = read_dataset(input_path)
82
+ versioned = Path(input_path).suffix.lower() == '.json' and isinstance(decode(original), dict)
83
+ bundle = read_bundle(Path(input_path).parent) if versioned else None
84
+ output = Path(output_path).resolve()
85
+ evidence = output.with_name(output.name + '.evidence')
86
+ if format not in FORMATS or output.suffix.lower() != FORMATS[format]:
87
+ raise ValueError('Report format must match its .html, .csv or .md output suffix.')
88
+ if not output.parent.is_dir():
89
+ raise NotADirectoryError('Report parent must be an existing directory.')
90
+ if output.exists() or evidence.exists():
91
+ raise FileExistsError('Choose a new report path; report or evidence already exists.')
92
+ if dry_run:
93
+ return {'status': 'PLAN_READY', 'rows': len(dataset['products']), 'networkCalls': 0,
94
+ 'output': str(output), 'evidence': str(evidence), 'sourceType': dataset['sourceType'], 'format': format}
95
+ # Exclusive creation prevents replacement of either the input or existing outputs.
96
+ artifacts = Artifacts(evidence)
97
+ created = []
98
+ try:
99
+ if bundle is not None:
100
+ for name, raw in bundle.items():
101
+ if name != 'report-input.json':
102
+ artifacts.write_bytes(name, raw)
103
+ else:
104
+ artifacts.write_bytes(dataset['inputName'], original)
105
+ artifacts.write('dataset.json', dataset)
106
+ artifacts.write('report-input.json', {'inputName': Path(input_path).name,
107
+ 'inputFile': 'dataset.json' if versioned else dataset['inputName'],
108
+ 'inputSha256': hashlib.sha256(original).hexdigest(),
109
+ 'exportedAt': datetime.now(timezone.utc).isoformat()})
110
+ artifacts.finish()
111
+ with output.open('x', encoding='utf-8', newline='') as stream:
112
+ created.append(output)
113
+ stream.write(render_format(dataset, evidence.name, format))
114
+ except Exception:
115
+ for path in reversed(created):
116
+ path.unlink(missing_ok=True)
117
+ for name in artifacts.files:
118
+ (evidence / name).unlink(missing_ok=True)
119
+ (evidence / 'manifest.json').unlink(missing_ok=True)
120
+ evidence.rmdir()
121
+ raise
122
+ return {'status': 'REPORT_READY', 'rows': len(dataset['products']), 'networkCalls': 0,
123
+ 'file': str(output), 'evidence': str(evidence), 'sourceType': dataset['sourceType'], 'format': format}
124
+
125
+
126
+ def command(args):
127
+ if args.report_action == 'compare':
128
+ return compare(args.input, args.out, args.dry_run, args.format)
129
+ if args.report_action == 'describe':
130
+ name = 'products.schema.json' if args.schema else 'fields.json'
131
+ return json.loads(resource(name).read_text(encoding='utf-8'))
132
+ name = 'products-template.csv' if args.report_action == 'template' else 'products-sample.' + args.format
133
+ with open(args.out, 'xb') as stream:
134
+ stream.write(resource(name).read_bytes())
135
+ return {'status': 'EXPORTED', 'file': str(Path(args.out).resolve()), 'networkCalls': 0}
@@ -0,0 +1,99 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Evidence-linked CSV and Markdown views of the same comparison dataset."""
4
+ from collections import defaultdict
5
+ import csv
6
+ from html import escape
7
+ import io
8
+ import json
9
+ import re
10
+ from urllib.parse import quote
11
+
12
+ from ..core.datasets import FIELDS
13
+
14
+ FORMATS = {'html': '.html', 'csv': '.csv', 'markdown': '.md'}
15
+ CONNECT = 'https://github.com/nexscope-ai/nexscope-cli#connect'
16
+
17
+
18
+ def text(value):
19
+ if value is None:
20
+ return ''
21
+ if isinstance(value, (dict, list)):
22
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
23
+ return str(value)
24
+
25
+
26
+ def csv_cell(value):
27
+ value = text(value)
28
+ if value.startswith(('\t', '\r', '\n')) or value.lstrip().startswith(('=', '+', '-', '@')):
29
+ return "'" + value
30
+ return value
31
+
32
+
33
+ def md_cell(value):
34
+ value = 'Not provided' if value is None or value == '' else text(value)
35
+ value = escape(value, quote=True)
36
+ value = re.sub(r'([\\`*_{}\[\]()#+.!|~-])', r'\\\1', value)
37
+ return value.replace('\r\n', '\n').replace('\r', '\n').replace('\n', '<br>')
38
+
39
+
40
+ def grouped(dataset):
41
+ groups = defaultdict(list)
42
+ for row in dataset['products']:
43
+ groups[(row['marketplace'], row['currency'], row['rating_scale'])].append(row)
44
+ return groups
45
+
46
+
47
+ def notice(dataset):
48
+ if dataset['sourceType'] in ('mock', 'remote'):
49
+ return ('MOCK / FICTIONAL DATA: no live service queried; not market research.' if dataset['sourceType'] == 'mock'
50
+ else 'REMOTE DATA: source and retrieval times are in request evidence; missing values are not inferred.')
51
+ return 'USER IMPORT: supplied data, not refreshed or independently verified; bundled samples are fictional.'
52
+
53
+
54
+ def render_csv(dataset, evidence_name):
55
+ stream = io.StringIO(newline='')
56
+ columns = ('comparison_group', *FIELDS, 'source_row', 'extra', 'source_type', 'collection_status',
57
+ 'fetched_at', 'cache_hit', 'missing_fields', 'evidence_index', 'input_sha256', 'imported_at',
58
+ 'errors', 'request_accounting', 'data_notice', 'comparison_policy', 'connect_instructions')
59
+ writer = csv.writer(stream, quoting=csv.QUOTE_ALL, lineterminator='\r\n')
60
+ writer.writerow(columns)
61
+ for group_id, rows in enumerate(grouped(dataset).values(), 1):
62
+ for row in rows:
63
+ extra = row['extra']
64
+ values = (group_id, *(row[field] for field in FIELDS), row['source_row'], extra,
65
+ dataset['sourceType'], dataset.get('collectionStatus', 'IMPORTED'),
66
+ extra.get('fetchedAt') if dataset['sourceType'] in ('mock', 'remote') else None,
67
+ extra.get('cacheHit') if dataset['sourceType'] in ('mock', 'remote') else None,
68
+ [field for field in FIELDS if row[field] is None],
69
+ quote(evidence_name, safe='') + '/dataset.json', dataset['inputSha256'], dataset['importedAt'],
70
+ dataset.get('errors', []), dataset.get('budget'), notice(dataset),
71
+ 'Compare only within the same market/currency/rating-scale group; no ranking or conversion.', CONNECT)
72
+ writer.writerow([csv_cell(value) for value in values])
73
+ # BOM helps spreadsheet apps identify UTF-8; exact source values remain in evidence.
74
+ return '\ufeff' + stream.getvalue()
75
+
76
+
77
+ def render_markdown(dataset, evidence_name):
78
+ groups = grouped(dataset)
79
+ lines = ['# Product comparison', '', '**' + notice(dataset) + '**', '',
80
+ f"{len(dataset['products'])} products across {len(groups)} comparable groups.",
81
+ 'Compare only within the same market, currency and rating scale. No cross-group ranking or conversion.', '',
82
+ 'Collection status: ' + md_cell(dataset.get('collectionStatus', 'IMPORTED')), '',
83
+ 'Imported at: ' + md_cell(dataset['importedAt']) + ' (not the observation time).', '',
84
+ 'Input SHA-256: ' + md_cell(dataset['inputSha256']), '',
85
+ '[Evidence dataset and exact source values](' + quote(evidence_name, safe='') + '/dataset.json)', '',
86
+ 'Failed or skipped products: ' + md_cell(dataset.get('errors', [])), '',
87
+ 'Request accounting: ' + md_cell(dataset.get('budget')), '']
88
+ fields = (*FIELDS, 'source_row', 'extra')
89
+ for index, ((market, currency, scale), rows) in enumerate(groups.items(), 1):
90
+ lines += [f'## Group {index}', '',
91
+ 'Market: ' + md_cell(market) + ' / Currency: ' + md_cell(currency) + ' / Rating scale: ' + md_cell(scale), '',
92
+ '| ' + ' | '.join(fields) + ' |', '| ' + ' | '.join('---' for _ in fields) + ' |']
93
+ for row in rows:
94
+ lines.append('| ' + ' | '.join(md_cell(row[field]) for field in fields) + ' |')
95
+ lines.append('')
96
+ lines += ['Keep this report with its evidence directory. Missing fields remain unknown.', '',
97
+ '[Need updated product data? Connect Nexscope](' + CONNECT + ').',
98
+ 'Optional instructions only: this report does not upload inputs or call data services.', '']
99
+ return '\n'.join(lines)
@@ -0,0 +1,3 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Resources shipped in the wheel, independent of a source checkout."""
@@ -0,0 +1,33 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "sourceType": "user_import",
4
+ "formats": [
5
+ "UTF-8 CSV with unique headers",
6
+ "UTF-8 JSON array of objects",
7
+ "Versioned dataset.json with its complete hash-checked evidence directory"
8
+ ],
9
+ "limits": {
10
+ "bytes": 10485760,
11
+ "products": 10000,
12
+ "requiresAccount": false,
13
+ "versionedDatasetBytes": 33554432,
14
+ "evidenceBundleBytes": 67108864
15
+ },
16
+ "fields": {
17
+ "product_id": "Required non-empty string; unique within marketplace; not restricted to ASINs.",
18
+ "title": "Required non-empty string. Never generated or inferred.",
19
+ "marketplace": "Optional market or internal catalog name. Missing is unknown.",
20
+ "price": "Optional finite non-negative number. Requires currency.",
21
+ "currency": "Optional uppercase three-letter currency code. No currency conversion.",
22
+ "rating": "Optional finite non-negative number, no greater than rating_scale.",
23
+ "rating_scale": "Positive finite number, required with rating. No implicit five-point scale.",
24
+ "review_count": "Optional non-negative integer. Missing does not mean zero.",
25
+ "source_url": "Optional HTTP(S) URL without credentials or whitespace.",
26
+ "observed_at": "Optional ISO 8601 timestamp with timezone. Import time is recorded separately."
27
+ },
28
+ "additionalFields": "Preserved as extra metadata; no automatic header mapping.",
29
+ "errors": "All invalid records are reported; no report is produced until input is corrected. CSV row numbers count logical records including the header; JSON rows are one-based array positions.",
30
+ "provenance": "Keep the report and its .evidence directory together. It contains the exact input, input SHA-256, import time, source rows and normalized dataset. Missing source/time stays unknown. Re-export preserves the original sourceType, inputName/inputSha256, retrieval times and errors; report-input.json separately records the exact selected input hash. Hashes prove consistency, not server authorship.",
31
+ "samples": "Bundled inputs are invented examples, not current market data.",
32
+ "offline": "No authentication, network, or LLM call. Reports contain user data: review them before sharing. The optional Nexscope link opens a guide; it never refreshes data automatically."
33
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "sourceType": "mock",
3
+ "notice": "Fictional test products. Not live market data.",
4
+ "products": [
5
+ {"asin": "B000000001", "title": "Fictional Alpha bottle", "extractedPrice": 20.5, "currency": "USD", "rating": 4.2, "ratingScale": 5, "reviews": 80, "link": "https://example.invalid/product/B000000001"},
6
+ {"asin": "B000000002", "title": "Fictional Beta bottle", "extractedPrice": 23, "currency": "USD", "rating": 4.5, "ratingScale": 5, "reviews": 40, "link": "https://example.invalid/product/B000000002"},
7
+ {"asin": "B000000003", "title": "Fictional Gamma bottle", "currency": "USD", "link": "https://example.invalid/product/B000000003"}
8
+ ]
9
+ }
@@ -0,0 +1,5 @@
1
+ product_id,title,marketplace,price,currency,rating,rating_scale,review_count,source_url,observed_at
2
+ SAMPLE-001,Sample insulated bottle,own-catalog,19.50,USD,4.2,5,120,https://example.com/products/1,2026-01-01T00:00:00Z
3
+ SAMPLE-002,Sample travel bottle,own-catalog,24.00,USD,4.4,5,95,https://example.com/products/2,2026-01-01T00:00:00Z
4
+ SAMPLE-003,Sample compact bottle,own-catalog,15.00,EUR,8,10,50,https://example.com/products/3,2026-01-01T00:00:00Z
5
+ SAMPLE-004,Sample bottle with missing data,own-catalog,,,,,,,
@@ -0,0 +1,6 @@
1
+ [
2
+ {"product_id":"SAMPLE-001","title":"Sample insulated bottle","marketplace":"own-catalog","price":19.50,"currency":"USD","rating":4.2,"rating_scale":5,"review_count":120,"source_url":"https://example.com/products/1","observed_at":"2026-01-01T00:00:00Z"},
3
+ {"product_id":"SAMPLE-002","title":"Sample travel bottle","marketplace":"own-catalog","price":24,"currency":"USD","rating":4.4,"rating_scale":5,"review_count":95,"source_url":"https://example.com/products/2","observed_at":"2026-01-01T00:00:00Z"},
4
+ {"product_id":"SAMPLE-003","title":"Sample compact bottle","marketplace":"own-catalog","price":15,"currency":"EUR","rating":8,"rating_scale":10,"review_count":50,"source_url":"https://example.com/products/3","observed_at":"2026-01-01T00:00:00Z"},
5
+ {"product_id":"SAMPLE-004","title":"Sample bottle with missing data","marketplace":"own-catalog"}
6
+ ]
@@ -0,0 +1 @@
1
+ product_id,title,marketplace,price,currency,rating,rating_scale,review_count,source_url,observed_at