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.
- nexscope_cli/__init__.py +4 -0
- nexscope_cli/api-contract.json +37 -0
- nexscope_cli/capabilities/__init__.py +3 -0
- nexscope_cli/capabilities/catalog.py +73 -0
- nexscope_cli/capabilities/mock.py +53 -0
- nexscope_cli/capabilities/plan.py +142 -0
- nexscope_cli/catalog.json +69 -0
- nexscope_cli/compat/__init__.py +4 -0
- nexscope_cli/compat/legacy.py +143 -0
- nexscope_cli/core/__init__.py +3 -0
- nexscope_cli/core/auth.py +81 -0
- nexscope_cli/core/datasets.py +305 -0
- nexscope_cli/core/errors.py +11 -0
- nexscope_cli/core/evidence.py +134 -0
- nexscope_cli/core/execution.py +171 -0
- nexscope_cli/core/remote.py +22 -0
- nexscope_cli/core/transport.py +61 -0
- nexscope_cli/dependencies.lock +282 -0
- nexscope_cli/dependency-lock.json +371 -0
- nexscope_cli/doctor.py +136 -0
- nexscope_cli/main.py +313 -0
- nexscope_cli/reports/__init__.py +3 -0
- nexscope_cli/reports/compare.py +135 -0
- nexscope_cli/reports/formats.py +99 -0
- nexscope_cli/resources/__init__.py +3 -0
- nexscope_cli/resources/fields.json +33 -0
- nexscope_cli/resources/mock-products.json +9 -0
- nexscope_cli/resources/products-sample.csv +5 -0
- nexscope_cli/resources/products-sample.json +6 -0
- nexscope_cli/resources/products-template.csv +1 -0
- nexscope_cli/resources/products.schema.json +182 -0
- nexscope_cli/resources/report.html +15 -0
- nexscope_cli/runner.py +43 -0
- nexscope_cli/skills/amazon-product-detail/SKILL.md +170 -0
- nexscope_cli/skills/amazon-product-detail/references/api.md +239 -0
- nexscope_cli/skills/amazon-product-detail/requirements.txt +1 -0
- nexscope_cli/skills/amazon-product-detail/scripts/amazon_product_detail.py +26 -0
- nexscope_cli/skills/amazon-search/SKILL.md +197 -0
- nexscope_cli/skills/amazon-search/references/api.md +189 -0
- nexscope_cli/skills/amazon-search/requirements.txt +1 -0
- nexscope_cli/skills/amazon-search/scripts/amazon_search.py +26 -0
- nexscope_cli/workflows/__init__.py +3 -0
- nexscope_cli/workflows/compare.py +213 -0
- nexscope_cli-0.1.0.dist-info/METADATA +257 -0
- nexscope_cli-0.1.0.dist-info/RECORD +74 -0
- nexscope_cli-0.1.0.dist-info/WHEEL +5 -0
- nexscope_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nexscope_cli-0.1.0.dist-info/licenses/ASSET-NOTICES.md +9 -0
- nexscope_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- nexscope_cli-0.1.0.dist-info/licenses/NOTICE +20 -0
- nexscope_cli-0.1.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +27 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/backports-tarfile/LICENSE +17 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/certifi/LICENSE +20 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cffi/LICENSE +23 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/charset-normalizer/LICENSE +21 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE +3 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE.APACHE +202 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE.BSD +27 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/idna/LICENSE.md +31 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/importlib-metadata/LICENSE +73 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-classes/LICENSE +17 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-context/LICENSE +18 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-functools/LICENSE +18 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jeepney/LICENSE +21 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/keyring/LICENSE +18 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/more-itertools/LICENSE +19 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/pycparser/LICENSE +27 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/pywin32-ctypes/LICENSE.txt +28 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/requests/LICENSE +175 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/requests/NOTICE +2 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/secretstorage/LICENSE +25 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/urllib3/LICENSE.txt +21 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/zipp/LICENSE +18 -0
- nexscope_cli-0.1.0.dist-info/top_level.txt +1 -0
nexscope_cli/__init__.py
ADDED
|
@@ -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,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,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,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.')
|