@asobacloud/nehanda 0.0.0-stage → 0.2.0

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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.md +57 -0
  3. package/README.md +404 -2
  4. package/bin/agent.mjs +1260 -0
  5. package/bin/nehanda-ui.mjs +824 -0
  6. package/lib/anthropicHeaders.mjs +8 -0
  7. package/lib/app.mjs +313 -0
  8. package/lib/auth.mjs +255 -0
  9. package/lib/bashguard.mjs +159 -0
  10. package/lib/compact.mjs +306 -0
  11. package/lib/energy-middleware.mjs +318 -0
  12. package/lib/hookShapeOrder.mjs +30 -0
  13. package/lib/hookplane.mjs +228 -0
  14. package/lib/instructions.mjs +23 -0
  15. package/lib/jev_read.mjs +216 -0
  16. package/lib/jev_write.mjs +253 -0
  17. package/lib/laya.mjs +296 -0
  18. package/lib/laya_sidecar.mjs +198 -0
  19. package/lib/mcp-config.mjs +189 -0
  20. package/lib/mcp.mjs +169 -0
  21. package/lib/modelConfig.mjs +152 -0
  22. package/lib/modelDiscovery.mjs +205 -0
  23. package/lib/openaiCompat.mjs +147 -0
  24. package/lib/orchestrate.mjs +1163 -0
  25. package/lib/paths.mjs +20 -0
  26. package/lib/permissions.mjs +44 -0
  27. package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
  28. package/lib/scripts/audit-code-integrity.py +437 -0
  29. package/lib/scripts/ensure-laya-env.sh +55 -0
  30. package/lib/scripts/js-safety-checker.cjs +308 -0
  31. package/lib/scripts/laya_worker.py +171 -0
  32. package/lib/scripts/odse-transform.py +97 -0
  33. package/lib/scripts/python-safety-checker.py +197 -0
  34. package/lib/scripts/shell-safety-checker.sh +306 -0
  35. package/lib/settings.mjs +147 -0
  36. package/lib/store.mjs +278 -0
  37. package/lib/team.mjs +69 -0
  38. package/lib/testgen.mjs +146 -0
  39. package/lib/tools/AuditCodeIntegrity.json +30 -0
  40. package/lib/tools/JsSafetyChecker.json +29 -0
  41. package/lib/tools/PythonSafetyChecker.json +29 -0
  42. package/lib/tools/ShellSafetyChecker.json +29 -0
  43. package/lib/tools.mjs +907 -0
  44. package/lib/transcript.mjs +223 -0
  45. package/lib/ui.mjs +286 -0
  46. package/lib/workflow.mjs +91 -0
  47. package/lib/worktree.mjs +56 -0
  48. package/package.json +50 -4
  49. package/schema.sql +181 -0
  50. package/scripts/postinstall.mjs +62 -0
  51. package/scripts/sdlc-acceptance.sh +477 -0
  52. package/scripts/verify-sdlc-hook-order.mjs +26 -0
  53. package/templates/test_e2e.sh +32 -0
  54. package/templates/test_file.sh +14 -0
  55. package/templates/test_hook.sh +27 -0
  56. package/templates/test_http.sh +17 -0
  57. package/templates/test_phase.sh +23 -0
  58. package/templates/test_process.sh +14 -0
  59. package/templates/test_tool.sh +24 -0
  60. package/templates/validate_test.sh +55 -0
@@ -0,0 +1,308 @@
1
+ const fs = require('fs');
2
+ const vm = require('vm');
3
+ const path = require('path');
4
+ const esprima = require('esprima');
5
+
6
+ function checkJSFile(filePath, fileContent) {
7
+ const issues = [];
8
+
9
+ // 1. JAVASCRIPT SYNTAX VALIDATION
10
+ try {
11
+ const scriptMatches = fileContent.match(/<script[^>]*>([\s\S]*?)<\/script>/g) || [];
12
+ // Only match actual JavaScript event handlers in HTML markup.
13
+ // Skip .js files entirely (they don't have real HTML inline handlers).
14
+ // For .html files, strip <script> blocks first so we don't match onclick
15
+ // inside JS string concatenation or template literals.
16
+ let inlineScripts = [];
17
+ if (filePath.endsWith('.html')) {
18
+ const htmlOnly = fileContent.replace(/<script[^>]*>[\s\S]*?<\/script>/g, '');
19
+ inlineScripts = htmlOnly.match(/\s(onclick|onload|onchange|onsubmit|onmouseover|onmouseout|onfocus|onblur|onkeydown|onkeyup)="[^"]*"/g) || [];
20
+ }
21
+
22
+ scriptMatches.forEach((scriptBlock, index) => {
23
+ const scriptContent = scriptBlock.replace(/<\/?script[^>]*>/g, '');
24
+ if (scriptContent.trim()) {
25
+ try {
26
+ vm.createScript(scriptContent, `script-block-${index}`);
27
+ } catch (error) {
28
+ const lineNumber = getLineNumber(fileContent, scriptBlock);
29
+ issues.push(`JavaScript syntax error in script block ${index + 1} at line ${lineNumber}: ${error.message}`);
30
+ }
31
+ }
32
+ });
33
+
34
+ inlineScripts.forEach((inlineScript, index) => {
35
+ const jsCodeMatch = inlineScript.match(/="([^"]*)"/);
36
+ if (jsCodeMatch && jsCodeMatch[1]) {
37
+ const jsCode = jsCodeMatch[1];
38
+ if (jsCode.trim()) {
39
+ try {
40
+ // Wrap in function context to allow return statements
41
+ vm.createScript(`(function(){${jsCode}})`, `inline-script-${index}`);
42
+ } catch (error) {
43
+ const lineNumber = getLineNumber(fileContent, inlineScript);
44
+ issues.push(`JavaScript syntax error in inline script at line ${lineNumber}: ${error.message}`);
45
+ }
46
+ }
47
+ }
48
+ });
49
+
50
+ } catch (error) {
51
+ issues.push(`JavaScript parsing failed: ${error.message}`);
52
+ }
53
+
54
+ // 2. DUPLICATE DEFINITIONS - Use AST-based detection for accuracy
55
+ const scriptBlocks = fileContent.match(/<script[^>]*>([\s\S]*?)<\/script>/g) || [];
56
+ const allFunctionNames = [];
57
+
58
+ scriptBlocks.forEach(scriptBlock => {
59
+ const scriptContent = scriptBlock.replace(/<\/?script[^>]*>/g, '');
60
+ try {
61
+ const ast = esprima.parseScript(scriptContent, { tolerant: true });
62
+ function findFunctions(node) {
63
+ if (!node) return;
64
+ if (node.type === 'FunctionDeclaration' && node.id) {
65
+ allFunctionNames.push(node.id.name);
66
+ }
67
+ for (const key in node) {
68
+ if (node.hasOwnProperty(key) && typeof node[key] === 'object' && node[key] !== null) {
69
+ if (Array.isArray(node[key])) {
70
+ node[key].forEach(findFunctions);
71
+ } else {
72
+ findFunctions(node[key]);
73
+ }
74
+ }
75
+ }
76
+ }
77
+ findFunctions(ast);
78
+ } catch (e) {
79
+ // If AST parsing fails, fall back to regex but be more careful
80
+ const functionDefinitions = scriptContent.match(/^\s*function\s+(\w+)/gm) || [];
81
+ functionDefinitions.forEach(f => {
82
+ const match = f.match(/function\s+(\w+)/);
83
+ if (match) allFunctionNames.push(match[1]);
84
+ });
85
+ }
86
+ });
87
+
88
+ const duplicateFunctions = allFunctionNames.filter((func, index) => allFunctionNames.indexOf(func) !== index);
89
+ if (duplicateFunctions.length > 0) {
90
+ issues.push(`Duplicate function definitions: ${duplicateFunctions.join(', ')}`);
91
+ }
92
+
93
+ // Only check for duplicate IDs in static HTML, ignore dynamic IDs with concatenation
94
+ // Match id="..." but exclude patterns with string concatenation (+ or ${})
95
+ const idMatches = fileContent.match(/id="([^"]+)"/g) || [];
96
+ const ids = idMatches
97
+ .map(match => match.match(/id="([^"]+)"/)[1])
98
+ .filter(id => !id.includes("' +") && !id.includes("+ '") && !id.includes('${') && !id.includes('}'));
99
+ const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index);
100
+ if (duplicateIds.length > 0) {
101
+ issues.push(`Duplicate IDs: ${duplicateIds.join(', ')}`);
102
+ }
103
+
104
+ return issues;
105
+ }
106
+
107
+ function getLineNumber(content, searchString) {
108
+ const index = content.indexOf(searchString);
109
+ return index !== -1 ? content.substring(0, index).split('\n').length : 0;
110
+ }
111
+
112
+ function findFiles(dir, ext, callback) {
113
+ fs.readdir(dir, (err, files) => {
114
+ if (err) return callback(err);
115
+
116
+ files.forEach(file => {
117
+ const filePath = path.join(dir, file);
118
+ fs.stat(filePath, (err, stat) => {
119
+ if (err) return callback(err);
120
+
121
+ if (stat.isDirectory()) {
122
+ findFiles(filePath, ext, callback);
123
+ } else if (path.extname(filePath) === ext) {
124
+ callback(null, filePath);
125
+ }
126
+ });
127
+ });
128
+ });
129
+ }
130
+
131
+ function analyzeJS(jsContent) {
132
+ const declaredFunctions = new Set();
133
+ const declaredClasses = new Set();
134
+ const calledFunctions = new Set();
135
+
136
+ try {
137
+ const ast = esprima.parseScript(jsContent, { tolerant: true });
138
+
139
+ function traverse(node) {
140
+ if (!node) return;
141
+
142
+ // Track function declarations
143
+ if (node.type === 'FunctionDeclaration' && node.id) {
144
+ declaredFunctions.add(node.id.name);
145
+ }
146
+
147
+ // Track arrow function and function expression assignments:
148
+ // const/let/var name = () => {} or const/let/var name = function() {}
149
+ if (node.type === 'VariableDeclaration') {
150
+ node.declarations.forEach(declarator => {
151
+ if (declarator.id && declarator.id.type === 'Identifier' && declarator.init &&
152
+ (declarator.init.type === 'ArrowFunctionExpression' ||
153
+ declarator.init.type === 'FunctionExpression')) {
154
+ declaredFunctions.add(declarator.id.name);
155
+ }
156
+ });
157
+ }
158
+
159
+ // Track class declarations
160
+ if (node.type === 'ClassDeclaration' && node.id) {
161
+ declaredClasses.add(node.id.name);
162
+ }
163
+
164
+ // Track function calls (e.g., myFunction())
165
+ if (node.type === 'CallExpression' && node.callee.type === 'Identifier') {
166
+ calledFunctions.add(node.callee.name);
167
+ }
168
+
169
+ // Track constructor calls (e.g., new MyClass())
170
+ if (node.type === 'NewExpression' && node.callee.type === 'Identifier') {
171
+ calledFunctions.add(node.callee.name);
172
+ }
173
+
174
+ for (const key in node) {
175
+ if (node.hasOwnProperty(key)) {
176
+ const child = node[key];
177
+ if (typeof child === 'object' && child !== null) {
178
+ if (Array.isArray(child)) {
179
+ child.forEach(traverse);
180
+ } else {
181
+ traverse(child);
182
+ }
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ traverse(ast);
189
+ } catch (e) {
190
+ // Ignore parsing errors, they are handled by the vm module
191
+ }
192
+
193
+ // Combine declared functions and classes for checking
194
+ const allDeclared = new Set([...declaredFunctions, ...declaredClasses]);
195
+ return { declaredFunctions: allDeclared, calledFunctions };
196
+ }
197
+
198
+ function main() {
199
+ const uiDir = process.argv[2] ? path.resolve(process.argv[2]) : __dirname;
200
+ let allIssues = [];
201
+ let filesToProcess = [];
202
+ const extensions = ['.html', '.js'];
203
+
204
+ // Files and directories to exclude from safety checking
205
+ const excludePatterns = [
206
+ 'js-safety-checker.js', // Don't scan the safety checker itself
207
+ 'node_modules', // Exclude dependencies
208
+ 'dist', // Exclude build outputs
209
+ 'build', // Exclude build outputs
210
+ '.git', // Exclude git directory
211
+ 'tests' // Exclude test files (Jest globals are not defined in production)
212
+ ];
213
+
214
+ const fileFinder = (dir, ext) => {
215
+ if (!fs.existsSync(dir)) return;
216
+ const stat = fs.statSync(dir);
217
+ if (stat.isFile()) {
218
+ if (path.extname(dir) === ext) {
219
+ filesToProcess.push(dir);
220
+ }
221
+ return;
222
+ }
223
+
224
+ const files = fs.readdirSync(dir);
225
+ files.forEach(file => {
226
+ const filePath = path.join(dir, file);
227
+
228
+ // Skip excluded files and directories
229
+ const shouldExclude = excludePatterns.some(pattern =>
230
+ file.includes(pattern) || filePath.includes(pattern)
231
+ );
232
+ if (shouldExclude) {
233
+ return;
234
+ }
235
+
236
+ const fileStat = fs.statSync(filePath);
237
+ if (fileStat.isDirectory()) {
238
+ fileFinder(filePath, ext);
239
+ } else if (path.extname(filePath) === ext) {
240
+ filesToProcess.push(filePath);
241
+ }
242
+ });
243
+ };
244
+
245
+ extensions.forEach(ext => fileFinder(uiDir, ext));
246
+
247
+ const allDeclaredFunctions = new Set();
248
+ const allCalledFunctions = new Set();
249
+
250
+ filesToProcess.forEach(filePath => {
251
+ const fileContent = fs.readFileSync(filePath, 'utf8');
252
+ const issues = checkJSFile(filePath, fileContent);
253
+ if (issues.length > 0) {
254
+ allIssues.push({ filePath, issues });
255
+ }
256
+
257
+ const { declaredFunctions, calledFunctions } = analyzeJS(fileContent);
258
+ declaredFunctions.forEach(f => allDeclaredFunctions.add(f));
259
+ calledFunctions.forEach(f => allCalledFunctions.add(f));
260
+ });
261
+
262
+ // Browser and Node.js global functions that should not be flagged as undefined
263
+ const knownGlobals = new Set([
264
+ 'fetch', 'alert', 'confirm', 'prompt', 'console', 'setTimeout', 'setInterval',
265
+ 'clearTimeout', 'clearInterval', 'document', 'window', 'location', 'localStorage',
266
+ 'sessionStorage', 'JSON', 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'encodeURIComponent',
267
+ 'decodeURIComponent', 'encodeURI', 'decodeURI', 'escape', 'unescape', 'Date', 'Math',
268
+ 'Array', 'Object', 'String', 'Number', 'Boolean', 'RegExp', 'Error', 'require',
269
+ 'requestAnimationFrame', 'cancelAnimationFrame', 'requestIdleCallback', 'cancelIdleCallback',
270
+ 'Promise', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Symbol', 'Proxy', 'Reflect',
271
+ 'CustomEvent', 'Event', 'EventTarget', 'AbortController', 'AbortSignal',
272
+ 'FormData', 'URLSearchParams', 'URL', 'Blob', 'File', 'FileReader',
273
+ 'XMLHttpRequest', 'WebSocket', 'MutationObserver', 'IntersectionObserver',
274
+ 'ResizeObserver', 'PerformanceObserver', 'atob', 'btoa',
275
+ 'EventSource', 'TextDecoder', 'TextEncoder'
276
+ ]);
277
+
278
+ // Common callback parameter names that should not be flagged as undefined
279
+ const commonCallbackNames = new Set([
280
+ 'callback', 'cb', 'done', 'next', 'fn', 'handler', 'onSuccess', 'onError',
281
+ 'onConfirm', 'onCancel', 'resolve', 'reject', 'complete', 'unsubscribe',
282
+ 'listener', 'subscriber', 'observer',
283
+ 'onComplete', 'onProgress', 'onChunk', 'onDone'
284
+ ]);
285
+
286
+ const undefinedFunctions = [...allCalledFunctions].filter(f =>
287
+ !allDeclaredFunctions.has(f) && !knownGlobals.has(f) && !commonCallbackNames.has(f)
288
+ );
289
+
290
+ if (undefinedFunctions.length > 0) {
291
+ allIssues.push({ filePath: 'Global', issues: [`Undefined function calls: ${undefinedFunctions.join(', ')}`] });
292
+ }
293
+
294
+ if (allIssues.length > 0) {
295
+ console.error('JavaScript Safety Check Failed:');
296
+ allIssues.forEach(fileIssues => {
297
+ console.error(`\n❌ ${fileIssues.filePath}`);
298
+ fileIssues.issues.forEach(issue => {
299
+ console.error(` - ${issue}`);
300
+ });
301
+ });
302
+ process.exit(1);
303
+ } else {
304
+ console.log(`✅ All ${filesToProcess.length} files checked: No issues found`);
305
+ }
306
+ }
307
+
308
+ main();
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env python3
2
+ """JSON-lines stdio worker for Laya System-1 decisions.
3
+
4
+ Loads the real convaiinnovations/laya checkpoint on `load` / first predict.
5
+ Never silently invents answers — if Laya cannot load, every request returns ok:false.
6
+
7
+ Protocol (one JSON object per line):
8
+ {"id":1,"method":"ping"}
9
+ {"id":2,"method":"load"} # download + load checkpoint into memory
10
+ {"id":3,"method":"predict","params":{"state":"...","questions":{...}}}
11
+ {"id":4,"method":"shutdown"}
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sys
17
+ import traceback
18
+ import time
19
+
20
+ _router = None
21
+ _agent = None
22
+ _loaded = False
23
+ _load_error = None
24
+
25
+
26
+ def _ensure_loaded():
27
+ global _router, _agent, _loaded, _load_error
28
+ if _loaded:
29
+ return
30
+ if _load_error is not None:
31
+ raise RuntimeError(_load_error)
32
+ t0 = time.time()
33
+ try:
34
+ from laya import Router
35
+ _router = Router()
36
+ # Force English checkpoint into memory so warm predict hits ≤50ms path.
37
+ # Router downloads on first predict; prime with a tiny call.
38
+ _router.predict(
39
+ "nehanda system-1 warm start",
40
+ {
41
+ "ready": {
42
+ "type": "noul",
43
+ "instructions": "Is the control plane ready?",
44
+ }
45
+ },
46
+ model="english",
47
+ )
48
+ _loaded = True
49
+ ms = int((time.time() - t0) * 1000)
50
+ print(f"[laya-worker] loaded english checkpoint in {ms}ms", file=sys.stderr)
51
+ except TypeError:
52
+ # Older Router.predict may not accept model= kwarg
53
+ try:
54
+ from laya import Router
55
+ _router = Router()
56
+ _router.predict(
57
+ "nehanda system-1 warm start",
58
+ {"ready": {"type": "noul", "instructions": "Is the control plane ready?"}},
59
+ )
60
+ _loaded = True
61
+ ms = int((time.time() - t0) * 1000)
62
+ print(f"[laya-worker] loaded via Router in {ms}ms", file=sys.stderr)
63
+ except Exception:
64
+ try:
65
+ import laya
66
+ _agent = laya.load("convaiinnovations/laya")
67
+ _agent.predict(
68
+ "nehanda system-1 warm start",
69
+ {"ready": {"type": "noul", "instructions": "Is the control plane ready?"}},
70
+ )
71
+ _loaded = True
72
+ ms = int((time.time() - t0) * 1000)
73
+ print(f"[laya-worker] loaded laya.load() in {ms}ms", file=sys.stderr)
74
+ except Exception as e2:
75
+ _load_error = f"laya load failed: {e2}"
76
+ raise RuntimeError(_load_error) from e2
77
+ except Exception as e:
78
+ _load_error = f"laya load failed: {e}"
79
+ raise RuntimeError(_load_error) from e
80
+
81
+
82
+ def predict(state, questions):
83
+ _ensure_loaded()
84
+ if _router is not None:
85
+ try:
86
+ return _router.predict(state, questions, model="english")
87
+ except TypeError:
88
+ return _router.predict(state, questions)
89
+ return _agent.predict(state, questions)
90
+
91
+
92
+ def _to_dict(result):
93
+ if result is None:
94
+ return {}
95
+ if isinstance(result, dict):
96
+ # answers may contain nested objects
97
+ out = {}
98
+ for k, v in result.items():
99
+ if hasattr(v, "keys") and not isinstance(v, dict):
100
+ try:
101
+ out[k] = dict(v)
102
+ continue
103
+ except Exception:
104
+ pass
105
+ if isinstance(v, dict):
106
+ nested = {}
107
+ for nk, nv in v.items():
108
+ if hasattr(nv, "keys") and not isinstance(nv, dict):
109
+ try:
110
+ nested[nk] = dict(nv)
111
+ continue
112
+ except Exception:
113
+ pass
114
+ nested[nk] = nv
115
+ out[k] = nested
116
+ else:
117
+ out[k] = v
118
+ return out
119
+ if hasattr(result, "keys"):
120
+ return dict(result)
121
+ return {"raw": str(result)}
122
+
123
+
124
+ def handle(msg):
125
+ method = msg.get("method")
126
+ mid = msg.get("id")
127
+ params = msg.get("params") or {}
128
+ if method == "ping":
129
+ return {"id": mid, "ok": True, "result": {"pong": True, "loaded": _loaded}}
130
+ if method == "load":
131
+ _ensure_loaded()
132
+ return {"id": mid, "ok": True, "result": {"loaded": True}}
133
+ if method == "shutdown":
134
+ return {"id": mid, "ok": True, "result": {"bye": True}, "_exit": True}
135
+ if method == "predict":
136
+ result = predict(params.get("state", ""), params.get("questions") or {})
137
+ return {"id": mid, "ok": True, "result": _to_dict(result)}
138
+ return {"id": mid, "ok": False, "error": f"unknown method: {method}"}
139
+
140
+
141
+ def main():
142
+ for line in sys.stdin:
143
+ line = line.strip()
144
+ if not line:
145
+ continue
146
+ try:
147
+ msg = json.loads(line)
148
+ except json.JSONDecodeError as e:
149
+ sys.stdout.write(json.dumps({"ok": False, "error": f"bad json: {e}"}) + "\n")
150
+ sys.stdout.flush()
151
+ continue
152
+ try:
153
+ resp = handle(msg)
154
+ do_exit = resp.pop("_exit", False)
155
+ sys.stdout.write(json.dumps(resp, default=str) + "\n")
156
+ sys.stdout.flush()
157
+ if do_exit:
158
+ return 0
159
+ except Exception as e:
160
+ traceback.print_exc(file=sys.stderr)
161
+ sys.stdout.write(json.dumps({
162
+ "id": msg.get("id"),
163
+ "ok": False,
164
+ "error": str(e),
165
+ }) + "\n")
166
+ sys.stdout.flush()
167
+ return 0
168
+
169
+
170
+ if __name__ == "__main__":
171
+ raise SystemExit(main())
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ odse-transform.py — ODSE energy normalization bridge for nehanda-cli.
4
+
5
+ Reads a raw OEM energy payload from stdin, transforms it to ODS-E
6
+ normalized JSON using the pip-installed `odse` package, and writes
7
+ the result to stdout as a JSON array.
8
+
9
+ Usage:
10
+ echo '<payload>' | python3 odse-transform.py --source huawei
11
+ echo '<payload>' | python3 odse-transform.py --source solaredge --asset-id SITE-001
12
+
13
+ Exit codes:
14
+ 0 — success, normalized JSON written to stdout
15
+ 1 — unknown source / transform error (caller should pass payload through)
16
+ 2 — usage / import error
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import sys
22
+
23
+
24
+ def main() -> int:
25
+ parser = argparse.ArgumentParser(description="Transform OEM energy data to ODS-E")
26
+ parser.add_argument(
27
+ "--source",
28
+ required=True,
29
+ help="OEM source key (e.g. huawei, solaredge, enphase, sungrow)",
30
+ )
31
+ parser.add_argument(
32
+ "--asset-id",
33
+ default=None,
34
+ dest="asset_id",
35
+ help="Optional asset identifier to embed in output records",
36
+ )
37
+ parser.add_argument(
38
+ "--timezone",
39
+ default=None,
40
+ help="Optional timezone for timestamp conversion (e.g. Africa/Johannesburg)",
41
+ )
42
+ args = parser.parse_args()
43
+
44
+ # Read full payload from stdin
45
+ try:
46
+ payload = sys.stdin.read()
47
+ except Exception as exc:
48
+ print(f"[odse-transform] stdin read error: {exc}", file=sys.stderr)
49
+ return 2
50
+
51
+ if not payload.strip():
52
+ print("[odse-transform] empty payload", file=sys.stderr)
53
+ return 1
54
+
55
+ # Import odse — fail fast with a clear message if not installed
56
+ try:
57
+ from odse.transformer import transform # noqa: PLC0415
58
+ except ImportError:
59
+ print(
60
+ "[odse-transform] odse package not found. Run: pip install odse",
61
+ file=sys.stderr,
62
+ )
63
+ return 2
64
+
65
+ # Attempt transformation
66
+ try:
67
+ kwargs: dict = {}
68
+ if args.timezone:
69
+ kwargs["timezone"] = args.timezone
70
+
71
+ records = transform(
72
+ payload,
73
+ source=args.source,
74
+ asset_id=args.asset_id,
75
+ **kwargs,
76
+ )
77
+ except ValueError as exc:
78
+ # Unknown source key — caller interprets exit 1 as "pass through"
79
+ print(f"[odse-transform] unknown source '{args.source}': {exc}", file=sys.stderr)
80
+ return 1
81
+ except Exception as exc: # noqa: BLE001
82
+ # Transform failed (malformed payload, etc.) — pass through
83
+ print(f"[odse-transform] transform error: {exc}", file=sys.stderr)
84
+ return 1
85
+
86
+ # Write normalized records as JSON array to stdout
87
+ try:
88
+ print(json.dumps(records, default=str))
89
+ except Exception as exc:
90
+ print(f"[odse-transform] serialization error: {exc}", file=sys.stderr)
91
+ return 1
92
+
93
+ return 0
94
+
95
+
96
+ if __name__ == "__main__":
97
+ sys.exit(main())