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
@@ -0,0 +1,4 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Versioned Nexscope CLI and skill catalog."""
4
+ __version__ = "0.1.0"
@@ -0,0 +1,37 @@
1
+ {
2
+ "version": "research-data-code-759d751.v1",
3
+ "backendRevision": "759d751b5cc43410c4f0b97014e0c4ddd9e366ce",
4
+ "verification": "source code and mock; no live samples",
5
+ "routes": {
6
+ "amazon-search": {
7
+ "research": "/api/v1/tools/research/amazon/search",
8
+ "skillApi": "/api/skill-api/v1/skills/amazon-search/run"
9
+ },
10
+ "amazon-product-detail": {
11
+ "research": "/api/v1/tools/research/amazon/product/detail",
12
+ "skillApi": "/api/skill-api/v1/skills/amazon-product-detail/run"
13
+ }
14
+ },
15
+ "response": {
16
+ "envelope": "ResponseData: code == 0 and data.products",
17
+ "reviewCount": "ratings",
18
+ "sourceUrl": [
19
+ "asinUrl",
20
+ "linkClean",
21
+ "link"
22
+ ],
23
+ "ratingScale": "unknown when absent",
24
+ "credits": "not response.cost; authoritative billing records",
25
+ "currency": "Three-letter codes can qualify prices; unqualified symbols are preserved as unresolved metadata, never inferred from marketplace."
26
+ },
27
+ "billing": {
28
+ "mechanism": "Server-side billing records and rules; no fixed client-side credit estimate.",
29
+ "fixedCredits": null,
30
+ "limits": "max-calls is not a monetary cap",
31
+ "searchEnrichment": "Not enabled in this data pilot."
32
+ },
33
+ "compatibility": {
34
+ "independentSkills": "NEXSCOPE_API_KEY + NEXSCOPE_PROXY_BASE unchanged",
35
+ "browserAccount": "OS keyring, no env fallback"
36
+ }
37
+ }
@@ -0,0 +1,3 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Offline descriptions and plans for explicitly registered pilot capabilities."""
@@ -0,0 +1,73 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """A1 planning subset of the pinned research routes, not a live API contract."""
4
+ from copy import deepcopy
5
+ from importlib.resources import files
6
+ import json
7
+
8
+ from ..core.errors import Failure
9
+
10
+ DOMAINS = ('amazon.com', 'amazon.co.uk', 'amazon.de', 'amazon.fr', 'amazon.it',
11
+ 'amazon.es', 'amazon.co.jp', 'amazon.ca', 'amazon.com.au', 'amazon.com.br',
12
+ 'amazon.in', 'amazon.nl', 'amazon.se', 'amazon.pl', 'amazon.sg',
13
+ 'amazon.sa', 'amazon.ae', 'amazon.com.mx', 'amazon.com.tr',
14
+ 'amazon.com.be', 'amazon.cn', 'amazon.eg')
15
+ COMMON = {
16
+ 'amazonDomain': {'type': 'string', 'enum': list(DOMAINS), 'default': 'amazon.com'},
17
+ 'language': {'type': 'string', 'minLength': 1, 'maxLength': 1000},
18
+ 'deliveryZip': {'type': 'string', 'minLength': 1, 'maxLength': 1000},
19
+ 'device': {'type': 'string', 'enum': ['desktop', 'mobile', 'tablet'], 'default': 'desktop'},
20
+ }
21
+ CAPABILITIES = {
22
+ 'amazon-search': {
23
+ 'endpoint': '/api/v1/tools/research/amazon/search',
24
+ 'script': 'scripts/amazon_search.py',
25
+ 'properties': {**COMMON,
26
+ 'keyword': {'type': 'string', 'minLength': 1, 'maxLength': 1024},
27
+ 'node': {'type': 'string', 'minLength': 1, 'maxLength': 1000},
28
+ 'page': {'type': 'integer', 'enum': [1], 'default': 1},
29
+ 'sort': {'type': 'string', 'enum': ['relevanceblender', 'price-asc-rank', 'price-desc-rank', 'review-rank', 'date-desc-rank', 'exact-aware-popularity-rank']}},
30
+ 'required': [],
31
+ 'anyOf': [{'required': ['keyword']}, {'required': ['node']}],
32
+ 'limits': {'pages': 1, 'automaticPagination': False, 'requireKeywordOrNode': True},
33
+ },
34
+ 'amazon-product-detail': {
35
+ 'endpoint': '/api/v1/tools/research/amazon/product/detail',
36
+ 'script': 'scripts/amazon_product_detail.py',
37
+ 'properties': {**COMMON,
38
+ 'asins': {'type': 'string', 'pattern': '^[A-Z0-9]+(,[A-Z0-9]+){0,2}$', 'maxLength': 4096},
39
+ 'returnBoughtTogether': {'type': 'boolean'},
40
+ 'returnRelatedProducts': {'type': 'boolean'},
41
+ 'returnAuthorsReviews': {'type': 'boolean'}},
42
+ 'required': ['asins'],
43
+ 'limits': {'maxAsins': 3, 'requestStrategy': 'one-ASIN-per-request', 'duplicateAsins': 'reject'},
44
+ },
45
+ }
46
+
47
+
48
+ def describe(name):
49
+ if name not in CAPABILITIES:
50
+ raise Failure('Unknown capability; supported: amazon-search, amazon-product-detail.')
51
+ spec = deepcopy(CAPABILITIES[name])
52
+ bundled = json.loads(files('nexscope_cli').joinpath('catalog.json').read_text(encoding='utf-8'))
53
+ schema = {'type': 'object', 'additionalProperties': False,
54
+ 'properties': spec['properties'], 'required': spec['required']}
55
+ if 'anyOf' in spec:
56
+ schema['anyOf'] = spec['anyOf']
57
+ return {
58
+ 'schemaVersion': 'nexscope.capability.v1', 'capability': name,
59
+ 'contractVersion': 'research-data-code-759d751.v1', 'status': 'EXECUTION_GATED',
60
+ 'executionAvailable': False, 'liveExecutionAvailable': True,
61
+ 'executionRequires': ['--execute', '--allow-paid'],
62
+ 'mockExecutionAvailable': True, 'method': 'POST', 'endpoint': spec['endpoint'],
63
+ 'inputSchema': schema, 'pilotLimits': spec['limits'],
64
+ 'source': {'skill': name, 'script': spec['script'],
65
+ 'repository': bundled['source'], 'revision': bundled['skills'][name].get('sourceRevision', bundled['revision']),
66
+ 'snapshot': bundled['skills'][name].get('sourceSnapshot'),
67
+ 'reference': 'references/api.md',
68
+ 'sha256': bundled['skills'][name]['files']['references/api.md']},
69
+ 'validation': 'Pinned backend source and mock verified; live samples and billing acceptance remain pending.',
70
+ 'contract': json.loads(files('nexscope_cli').joinpath('api-contract.json').read_text(encoding='utf-8')),
71
+ 'pricing': {'status': 'UNKNOWN', 'creditsPerRequest': None,
72
+ 'source': None, 'note': 'Response cost is elapsed time, not credits.'},
73
+ }
@@ -0,0 +1,53 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Deterministic, explicitly fictional fixtures. Never imports a network client."""
4
+ from copy import deepcopy
5
+ from importlib.resources import files
6
+ import json
7
+
8
+ from ..core.auth import AuthContext
9
+ from ..core.errors import Failure
10
+ from ..core.execution import Executor
11
+
12
+ SCENARIOS = ('complete', 'partial', 'empty', 'unauthorized', 'credits', 'rate-limit', 'timeout', 'malformed')
13
+
14
+
15
+ class MockTransport:
16
+ def __init__(self, scenario='complete'):
17
+ if scenario not in SCENARIOS:
18
+ raise Failure('Unknown mock scenario.')
19
+ self.scenario, self.calls = scenario, []
20
+ self.cache_namespace = 'fictional-v1:' + scenario
21
+ self.fixture = json.loads(files('nexscope_cli.resources').joinpath('mock-products.json').read_text(encoding='utf-8'))
22
+
23
+ def send(self, context, endpoint, body):
24
+ self.calls.append((endpoint, deepcopy(body)))
25
+ if self.scenario == 'timeout':
26
+ raise TimeoutError('Fixture timeout')
27
+ if self.scenario == 'unauthorized':
28
+ return 401, {'code': 12003, 'data': None}
29
+ if self.scenario == 'credits':
30
+ return 200, {'code': 13011, 'data': None}
31
+ if self.scenario == 'rate-limit':
32
+ return 429, {'code': 429, 'data': None}
33
+ if self.scenario == 'malformed':
34
+ return 200, {'code': '0', 'data': None}
35
+ if self.scenario == 'empty':
36
+ return 200, {'code': 0, 'data': {'products': []}}
37
+ rows = deepcopy(self.fixture['products'])
38
+ if endpoint.endswith('/product/detail'):
39
+ if self.scenario == 'partial' and body['asins'] == 'B000000002':
40
+ return 200, {'code': 0, 'data': {'products': []}}
41
+ index = sum(map(ord, body['asins'])) % len(rows)
42
+ row = next((row for row in rows if row['asin'] == body['asins']), rows[index])
43
+ row['asin'] = body['asins']
44
+ row['link'] = 'https://example.invalid/product/' + body['asins']
45
+ rows = [row]
46
+ return 200, {'code': 0, 'msg': None, 'data': {'products': rows}, 'cost': '12'}
47
+
48
+
49
+ def executor(mode, scenario, max_calls):
50
+ # Fake credentials are generated internally; inherited keys and keyring are
51
+ # deliberately not accessed, even when the requested mode is browser-account.
52
+ context = AuthContext(mode, 'https://mock.invalid', 'fictional-context-only')
53
+ return Executor(context, MockTransport(scenario), max_calls=max_calls)
@@ -0,0 +1,142 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Pure, bounded request planning. No credential, cache, or transport access."""
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path
7
+ import re
8
+
9
+ from .catalog import describe
10
+ from ..core.auth import DEFAULT_ORIGIN, MODES, origin
11
+ from ..core.errors import Failure
12
+
13
+ MAX_INPUT_BYTES = 64 * 1024
14
+
15
+
16
+ def read_input(path):
17
+ with Path(path).open('rb') as stream:
18
+ raw = stream.read(MAX_INPUT_BYTES + 1)
19
+ if len(raw) > MAX_INPUT_BYTES:
20
+ raise Failure('Plan input exceeds 64 KiB.')
21
+
22
+ def unique(pairs):
23
+ result = {}
24
+ for key, value in pairs:
25
+ if key in result:
26
+ raise ValueError
27
+ result[key] = value
28
+ return result
29
+
30
+ def reject_constant(value):
31
+ raise ValueError
32
+
33
+ try:
34
+ params = json.loads(raw.decode('utf-8-sig'), object_pairs_hook=unique,
35
+ parse_constant=reject_constant)
36
+ except (UnicodeError, ValueError, RecursionError):
37
+ raise Failure('Plan input must be UTF-8 JSON without duplicate keys or non-finite values.') from None
38
+ return params, hashlib.sha256(raw).hexdigest()
39
+
40
+
41
+ def normalize(params, description):
42
+ schema = description['inputSchema']
43
+ if not isinstance(params, dict):
44
+ raise Failure('Plan input must be a JSON object.')
45
+ # Do not echo unknown fields or invalid values: they may contain secrets.
46
+ if set(params) - set(schema['properties']):
47
+ raise Failure('Unsupported parameter; use capabilities describe for allowed fields.')
48
+ if any(key not in params for key in schema['required']):
49
+ raise Failure('Missing required parameters; use capabilities describe.')
50
+ result = {}
51
+ for key, rule in schema['properties'].items():
52
+ if key not in params:
53
+ if 'default' in rule:
54
+ result[key] = rule['default']
55
+ continue
56
+ value = params[key]
57
+ valid = True
58
+ if rule['type'] == 'string':
59
+ valid = isinstance(value, str) and bool(value.strip()) and not any(ord(c) < 32 for c in value)
60
+ if valid:
61
+ valid = len(value) <= rule.get('maxLength', 1000)
62
+ if 'pattern' in rule:
63
+ valid = valid and re.fullmatch(rule['pattern'], value) is not None
64
+ elif rule['type'] == 'integer':
65
+ valid = type(value) is int
66
+ elif rule['type'] == 'boolean':
67
+ valid = type(value) is bool
68
+ if not valid or ('enum' in rule and value not in rule['enum']):
69
+ raise Failure('Invalid parameter: ' + key + '; see the planning input schema.')
70
+ result[key] = value
71
+ if description['capability'] == 'amazon-search' and not any(k in result for k in ('keyword', 'node')):
72
+ raise Failure('The search pilot requires keyword or node.')
73
+ if 'asins' in result:
74
+ asins = result['asins'].split(',')
75
+ if len(set(asins)) != len(asins):
76
+ raise Failure('Duplicate ASINs are not allowed in the detail plan.')
77
+ return result
78
+
79
+
80
+ def plan(name, params, *, auth_mode='browser-account', api_base=None, max_calls=3):
81
+ description = describe(name)
82
+ if auth_mode not in MODES:
83
+ raise Failure('Choose an explicit supported authentication mode.')
84
+ if type(max_calls) is not int or max_calls < 1:
85
+ raise Failure('max-calls must be a positive integer.')
86
+ if auth_mode == 'env-api-key' and api_base is not None:
87
+ raise Failure('env-api-key resolves NEXSCOPE_PROXY_BASE at execution; omit --api-base.')
88
+ params = normalize(params, description)
89
+ bodies = [dict(params, asins=asin) for asin in params['asins'].split(',')] if name == 'amazon-product-detail' else [params]
90
+ if len(bodies) > max_calls:
91
+ raise Failure('Request count exceeds max-calls; no requests were sent.')
92
+ selected_origin = origin(api_base if api_base is not None else DEFAULT_ORIGIN) if auth_mode == 'browser-account' else None
93
+ return {
94
+ 'schemaVersion': 'nexscope.plan.v1', 'status': 'PLANNED',
95
+ 'capability': name, 'contractVersion': description['contractVersion'],
96
+ 'source': description['source'], 'executionAvailable': False,
97
+ 'auth': {'mode': auth_mode, 'origin': selected_origin,
98
+ 'originSource': '--api-base or default' if selected_origin else 'NEXSCOPE_PROXY_BASE at execution',
99
+ 'credentialsRead': False},
100
+ 'requests': [{'sequence': i + 1, 'method': 'POST', 'endpoint': description['endpoint'], 'body': body}
101
+ for i, body in enumerate(bodies)],
102
+ 'budget': {'scope': 'data-api-requests-only', 'maxCalls': max_calls, 'plannedRequests': len(bodies),
103
+ 'maximumRequests': len(bodies), 'requestsSent': 0,
104
+ 'automaticRetries': 0, 'estimatedCredits': None,
105
+ 'billingCap': None, 'pricingStatus': 'UNKNOWN', 'pricingSource': None,
106
+ 'note': 'Request bounds are not a monetary or server billing cap.'},
107
+ 'cache': {'policy': 'no-cache-read-or-write', 'assumedHits': 0},
108
+ 'warnings': ['Remote execution requires explicit --execute --allow-paid; mock never reads credentials.',
109
+ 'No automatic pagination, ASIN expansion, or fallback authentication.'],
110
+ }
111
+
112
+
113
+ def command(args):
114
+ if args.execute and not args.mock and not args.allow_paid:
115
+ raise Failure('EXECUTION_UNAVAILABLE: choose --mock or explicitly authorize remote scope with --allow-paid.')
116
+ params, digest = read_input(args.input)
117
+ result = plan(args.capability, params, auth_mode=args.auth_mode,
118
+ api_base=args.api_base, max_calls=args.max_calls)
119
+ result['inputSha256'] = digest
120
+ if args.execute:
121
+ if args.mock and args.api_base is not None:
122
+ raise Failure('Mock execution uses a fictional origin; omit --api-base.')
123
+ if not args.out:
124
+ raise Failure('Choose a new output directory with --out.')
125
+ from ..core.remote import engine_for
126
+ if args.mock and args.allow_paid:
127
+ raise Failure('Choose mock execution or paid execution, not both.')
128
+ source_type = 'mock' if args.mock else 'remote'
129
+ from ..core.evidence import Artifacts
130
+ engine = engine_for(args)
131
+ result['execution'] = {'sourceType': source_type, 'origin': engine.context.base, 'requestsSent': 0}
132
+ result['auth'].update(origin=engine.context.base, originSource='selected execution context')
133
+ artifacts = Artifacts(args.out, result)
134
+ engine.record = artifacts.record
135
+ requests = engine.execute(args.capability, params)
136
+ successes = sum(r['status'] == 'SUCCESS' for r in requests)
137
+ result = {'status': 'COMPLETE' if successes == len(requests) else ('PARTIAL' if successes else 'FAILED'),
138
+ 'sourceType': source_type, 'output': str(artifacts.root), 'requests': requests,
139
+ 'budget': engine.summary(), 'liveRequests': 0 if args.mock else engine.sent}
140
+ artifacts.write('result.json', dict(result, output='.'))
141
+ artifacts.finish()
142
+ return result
@@ -0,0 +1,69 @@
1
+ {
2
+ "source": "https://github.com/nexscope-ai/nexscope-ecommerce-skills",
3
+ "revision": "cd532d33ce21a643f9945e05ca556b5ba890813d",
4
+ "skills": {
5
+ "amazon-search": {
6
+ "source": "ecommerce-amazon-search",
7
+ "description": "Search Amazon products.",
8
+ "scripts": [
9
+ "scripts/amazon_search.py"
10
+ ],
11
+ "files": {
12
+ "SKILL.md": "90412bcf6070a0699593b028526421d396758f1f07065c897e03541175fe710c",
13
+ "references/api.md": "ac2f6764ddafbd4ab0e19cb5f3cfe37838ebd76879c604ad62c40a4dcd6e77ea",
14
+ "requirements.txt": "f2c92b444b157b26b21266f4d3ef6f5e3249771346999a7d8edd064bd215c3e9",
15
+ "scripts/amazon_search.py": "569ef7f00545dfba52bb5d7f1239c08ce8aa9ec3003ae361dea3c3d585bc3c2d"
16
+ },
17
+ "available": true,
18
+ "sourceRevision": "742ccef80fc605842b0249864051e4f690adcabb",
19
+ "sourceSnapshot": {
20
+ "state": "local-candidate-not-a-release",
21
+ "manifestSha256": "939192eb79d205008548d54397efe2be63bcfcb32811c414ddf5af68dfc83dd3",
22
+ "files": {
23
+ "SKILL.md": "d4f3cde82fcb8855d2167633764bd3c765ed3b6ad824116b9ee0a7d05254f3d7",
24
+ "references/api.md": "ac2f6764ddafbd4ab0e19cb5f3cfe37838ebd76879c604ad62c40a4dcd6e77ea",
25
+ "requirements.txt": "5d007fcd9e56e8319c5eddb055bddffe26305dac70d771526a130ac8ae17d3a8",
26
+ "scripts/amazon_search.py": "5036600ddc06ed2a333fb2e8b6b76b84d7968ba109dcedd4a3c1bd4ce46d174b"
27
+ }
28
+ },
29
+ "distributionTransforms": [
30
+ "Nexscope copyright and Apache-2.0 SPDX headers added for approved data-only distribution",
31
+ "Standalone CLI 0.1.2.dev8 adapter version and installation instructions; historical sourceSnapshot unchanged",
32
+ "Standalone CLI 0.1.2.dev11 pins; unqualified currency symbols retained as unresolved metadata without inferring an ISO code",
33
+ "Standalone CLI 0.1.0 initial independent version; paired adapter pins updated"
34
+ ]
35
+ },
36
+ "amazon-product-detail": {
37
+ "source": "ecommerce-amazon-product-detail",
38
+ "description": "Retrieve Amazon product details.",
39
+ "scripts": [
40
+ "scripts/amazon_product_detail.py"
41
+ ],
42
+ "files": {
43
+ "SKILL.md": "911982d386dfd2c63936f6e307f0dfc69e1489ff24d342310cbd4600146d17ff",
44
+ "references/api.md": "2ad537d7861e650a1ec195791e7aa488614132e1305473c53af0c7a63f4d0d59",
45
+ "requirements.txt": "f2c92b444b157b26b21266f4d3ef6f5e3249771346999a7d8edd064bd215c3e9",
46
+ "scripts/amazon_product_detail.py": "cf7eaea94865a6e7c9dc727fe5e4f8000ac91a67c8a4e4edbe618ac84b2de6e9"
47
+ },
48
+ "available": true,
49
+ "sourceRevision": "742ccef80fc605842b0249864051e4f690adcabb",
50
+ "sourceSnapshot": {
51
+ "state": "local-candidate-not-a-release",
52
+ "manifestSha256": "b688700d08cec44ab8fb1d6b9692e2a974dbb02412aad128bf49deefb493ec22",
53
+ "files": {
54
+ "SKILL.md": "69958b1b48ad00cb6061ab0084bec6f27e1ae62e242db4da6768f567efc849d9",
55
+ "references/api.md": "2ad537d7861e650a1ec195791e7aa488614132e1305473c53af0c7a63f4d0d59",
56
+ "requirements.txt": "5d007fcd9e56e8319c5eddb055bddffe26305dac70d771526a130ac8ae17d3a8",
57
+ "scripts/amazon_product_detail.py": "56ce266fbd21b9c837915d766eb3c907cdcba1708097c47c62fa45d1f3f9ffdc"
58
+ }
59
+ },
60
+ "distributionTransforms": [
61
+ "Nexscope copyright and Apache-2.0 SPDX headers added for approved data-only distribution",
62
+ "Standalone CLI 0.1.2.dev8 adapter version and installation instructions; historical sourceSnapshot unchanged",
63
+ "Standalone CLI 0.1.2.dev11 pins; unqualified currency symbols retained as unresolved metadata without inferring an ISO code",
64
+ "Standalone CLI 0.1.0 initial independent version; paired adapter pins updated"
65
+ ]
66
+ }
67
+ },
68
+ "distributionProfile": "data-only"
69
+ }
@@ -0,0 +1,4 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Versioned compatibility API for independent Skill entrypoints."""
4
+ COMPAT_API_VERSION = 1
@@ -0,0 +1,143 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Preserve standalone JSON/flags/environment access without pilot scope limits."""
4
+ from datetime import datetime, timezone
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import re
9
+ import sys
10
+ import time
11
+
12
+ from ..core.auth import resolve_auth
13
+ from ..core.errors import Failure
14
+ from ..core.execution import SessionCache, redact, validate_response
15
+ from ..core.transport import HttpTransport
16
+
17
+ CAPABILITIES = {
18
+ 'amazon-search': ('/api/v1/tools/research/amazon/search', 'ecommerce.amazon-search-amazon_search'),
19
+ 'amazon-product-detail': ('/api/v1/tools/research/amazon/product/detail', 'ecommerce.amazon-product-detail-amazon_product_detail'),
20
+ }
21
+
22
+
23
+ class LegacySession:
24
+ """A new session snapshots the explicitly selected environment credentials.
25
+
26
+ The legacy input object is sent once, unchanged. Do not apply the pilot's
27
+ one-page/three-ASIN planner, split batches, or add default request fields.
28
+ """
29
+ def __init__(self, context, transport=None, cache=None):
30
+ if context.mode != 'env-api-key':
31
+ raise Failure('Independent Skills require an explicit env-api-key context.')
32
+ self.context = context
33
+ self.transport = transport if transport is not None else HttpTransport()
34
+ self.cache = cache if cache is not None else SessionCache()
35
+
36
+ def call(self, capability, params, use_cache=True):
37
+ if capability not in CAPABILITIES or not isinstance(params, dict):
38
+ raise Failure('Use a registered legacy Skill and a JSON object.')
39
+ canonical = json.dumps(params, sort_keys=True, allow_nan=False)
40
+ key = (self.context.cache_partition, self.transport.cache_namespace,
41
+ 'legacy-envelope.v1', capability, canonical)
42
+ cached = self.cache.get(key) if use_cache else None
43
+ if cached is not None:
44
+ payload = cached['payload']
45
+ metadata = payload.get('_cache')
46
+ payload['_cache'] = {**(metadata if isinstance(metadata, dict) else {}), 'hit': True,
47
+ 'fetchedAt': cached['fetchedAt']}
48
+ return payload
49
+ status, payload = self.transport.send(self.context, CAPABILITIES[capability][0], params)
50
+ if not isinstance(payload, dict):
51
+ payload = {'error': 'HTTP ' + str(status) + ': response is not a JSON object'}
52
+ payload = redact(payload, self.context)
53
+ # Cache only the response subset already validated by the common core.
54
+ # Use the common envelope/product checks, not the pilot detail check
55
+ # (which assumes a single ASIN and would reject legacy batches).
56
+ # Unknown valid server shapes are still returned unchanged, never guessed.
57
+ error, stop = validate_response(status, payload, 'amazon-search', params)
58
+ if stop:
59
+ self.cache.invalidate(self.context.cache_partition)
60
+ if use_cache and error is None:
61
+ self.cache.put(key, {'payload': payload,
62
+ 'fetchedAt': datetime.fromtimestamp(self.cache.clock(), timezone.utc).isoformat()})
63
+ return payload
64
+
65
+
66
+ def largest_list(value, prefix=''):
67
+ if isinstance(value, list):
68
+ return prefix, len(value), value
69
+ best = (None, -1, None)
70
+ if isinstance(value, dict):
71
+ for key, child in value.items():
72
+ found = largest_list(child, prefix + '.' + key if prefix else key)
73
+ if found[1] > best[1]:
74
+ best = found
75
+ return best
76
+
77
+
78
+ def summarize(result):
79
+ print('Top-level keys: ' + str(list(result)))
80
+ for key in ('errcode', 'code', 'errmsg', 'msg', 'total', 'count', 'costToken', 'costTime'):
81
+ if isinstance(result.get(key), (int, float, bool, str)):
82
+ print(' ' + key + ': ' + str(result[key]))
83
+ path, count, rows = largest_list(result)
84
+ if path and rows:
85
+ print('\nLongest list: `' + path + '` (' + str(count) + ' items)')
86
+ print('First ' + str(min(3, count)) + ':')
87
+ print(json.dumps(rows[:3], ensure_ascii=False, indent=2))
88
+
89
+
90
+ def output_path(capability):
91
+ moment = datetime.now()
92
+ session = os.environ.get('SESSION_ID', '').strip() or moment.strftime('%H%M%S')
93
+ if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,127}', session):
94
+ raise Failure('SESSION_ID must be a safe directory name (letters, digits, dot, underscore, dash).')
95
+ root = Path.cwd().resolve()
96
+ folder = root / 'nexscope' / moment.strftime('%Y-%m-%d') / session / 'data'
97
+ if not folder.resolve().is_relative_to(root):
98
+ raise Failure('Output must remain inside the working directory.')
99
+ folder.mkdir(parents=True, exist_ok=True)
100
+ if not folder.resolve().is_relative_to(root):
101
+ raise Failure('Output must remain inside the working directory.')
102
+ return folder / (CAPABILITIES[capability][1] + '-' + str(time.time_ns() // 1000) + '.json')
103
+
104
+
105
+ def main(capability, argv=None):
106
+ args = sys.argv[1:] if argv is None else list(argv)
107
+ options = [arg for arg in args if arg not in ('--inline', '--no-cache')]
108
+ if len(options) != 1:
109
+ print("Usage: python script.py '<JSON params>' [--inline] [--no-cache]", file=sys.stderr)
110
+ return 1
111
+ target = None
112
+ created = False
113
+ try:
114
+ def reject(value):
115
+ raise ValueError('Non-finite JSON values are not supported.')
116
+ params = json.loads(options[0], parse_constant=reject)
117
+ if not isinstance(params, dict) or capability not in CAPABILITIES:
118
+ raise Failure('Params must be a JSON object for a registered Skill.')
119
+ context = resolve_auth('env-api-key')
120
+ target = output_path(capability)
121
+ # Reserve output before requesting data; never fall back to home or temp.
122
+ with target.open('x', encoding='utf-8') as stream:
123
+ created = True
124
+ session = LegacySession(context)
125
+ result = session.call(capability, params, use_cache='--no-cache' not in args)
126
+ serialized = json.dumps(result, ensure_ascii=False, allow_nan=False)
127
+ json.dump(result, stream, ensure_ascii=False, indent=2, allow_nan=False)
128
+ print('Saved full response: ' + str(target) + ' (' + str(len(serialized)) + ' bytes)')
129
+ if isinstance(result.get('_cache'), dict) and result['_cache'].get('hit'):
130
+ print('Cache hit')
131
+ if '--inline' in args or len(serialized.encode('utf-8')) <= 8000:
132
+ print(serialized)
133
+ else:
134
+ summarize(result)
135
+ # Retain legacy behavior: JSON business errors are output, not rewrapped.
136
+ # Callers must inspect outer code/error, not infer success from exit zero.
137
+ return 0
138
+ except Exception as error:
139
+ if created and target is not None and target.exists() and target.stat().st_size == 0:
140
+ target.unlink()
141
+ message = str(error) if isinstance(error, Failure) else 'Operation failed (' + type(error).__name__ + '); no automatic retry.'
142
+ print(message, file=sys.stderr)
143
+ return 1
@@ -0,0 +1,3 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Shared, credential-free dataset primitives."""
@@ -0,0 +1,81 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Explicit, immutable authentication snapshots; never discover a fallback."""
4
+ from dataclasses import dataclass, field
5
+ import os
6
+ from urllib.parse import urlsplit
7
+ import uuid
8
+
9
+ from .errors import AuthRequired, Failure
10
+
11
+ MODES = ('browser-account', 'env-api-key')
12
+ DEFAULT_ORIGIN = 'https://api.nexscope.ai'
13
+
14
+
15
+ def origin(value):
16
+ try:
17
+ if not isinstance(value, str) or any(c.isspace() or ord(c) < 32 for c in value):
18
+ raise ValueError
19
+ p = urlsplit(value)
20
+ if (p.scheme != 'https' and not (p.scheme == 'http' and p.hostname in ('localhost', '127.0.0.1'))) or not p.hostname or p.username or p.password or p.query or p.fragment or p.path not in ('', '/'):
21
+ raise ValueError
22
+ # Validate malformed ports before a request can contain a credential.
23
+ p.port
24
+ except ValueError:
25
+ raise Failure('Use an HTTPS API origin (HTTP is allowed only on loopback).') from None
26
+ return value.rstrip('/')
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class AuthContext:
31
+ mode: str
32
+ base: str
33
+ _key: str = field(repr=False)
34
+ _session: str = field(default_factory=lambda: uuid.uuid4().hex, repr=False)
35
+
36
+ def __post_init__(self):
37
+ if self.mode not in MODES:
38
+ raise Failure('Choose an explicit supported authentication mode.')
39
+ object.__setattr__(self, 'base', origin(self.base))
40
+ if not isinstance(self._key, str) or not self._key or any(c.isspace() or ord(c) < 32 for c in self._key):
41
+ raise AuthRequired('AUTH_REQUIRED: the selected context has no valid API key.')
42
+
43
+ def authorization(self):
44
+ """Secret-bearing value: only pass to the selected origin's transport."""
45
+ return 'Bearer ' + self._key
46
+
47
+ def script_environment(self, inherited=None):
48
+ """Pass credentials only to a child process, never persist this mapping."""
49
+ env = dict(os.environ if inherited is None else inherited)
50
+ env.update(NEXSCOPE_API_KEY=self._key, NEXSCOPE_PROXY_BASE=self.base)
51
+ env.pop('PYTHONPATH', None)
52
+ return env
53
+
54
+ def redact(self, text):
55
+ return text.replace(self._key, '[REDACTED]')
56
+
57
+ @property
58
+ def cache_partition(self):
59
+ # A1 has no shared persistent cache. New snapshots never share a session,
60
+ # even when keys rotate or two accounts use the same origin/profile.
61
+ return self.mode, self.base, self._session
62
+
63
+
64
+ def resolve_auth(mode, *, base=None, credential_loader=None, environment=None):
65
+ """Resolve just the selected source. Offline planning must not call this."""
66
+ if mode == 'browser-account':
67
+ selected_base = origin(base if base is not None else DEFAULT_ORIGIN)
68
+ credential = credential_loader() if credential_loader is not None else None
69
+ if not isinstance(credential, dict) or not credential.get('apiKey'):
70
+ raise AuthRequired('AUTH_REQUIRED: run nexscope auth login.')
71
+ return AuthContext(mode, selected_base, credential['apiKey'])
72
+ if mode == 'env-api-key':
73
+ if base is not None:
74
+ raise Failure('env-api-key uses NEXSCOPE_PROXY_BASE; do not supply a browser API origin.')
75
+ env = os.environ if environment is None else environment
76
+ key = env.get('NEXSCOPE_API_KEY', '').strip()
77
+ selected_base = env.get('NEXSCOPE_PROXY_BASE', '').strip()
78
+ if not key or not selected_base:
79
+ raise AuthRequired('AUTH_REQUIRED: configure NEXSCOPE_API_KEY and NEXSCOPE_PROXY_BASE.')
80
+ return AuthContext(mode, selected_base, key)
81
+ raise Failure('Choose an explicit supported authentication mode.')