systemlink-cli 1.3.1__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. slcli/__init__.py +1 -0
  2. slcli/__main__.py +23 -0
  3. slcli/_version.py +4 -0
  4. slcli/asset_click.py +1289 -0
  5. slcli/cli_formatters.py +218 -0
  6. slcli/cli_utils.py +504 -0
  7. slcli/comment_click.py +602 -0
  8. slcli/completion_click.py +418 -0
  9. slcli/config.py +81 -0
  10. slcli/config_click.py +498 -0
  11. slcli/dff_click.py +979 -0
  12. slcli/dff_decorators.py +24 -0
  13. slcli/example_click.py +404 -0
  14. slcli/example_loader.py +274 -0
  15. slcli/example_provisioner.py +2777 -0
  16. slcli/examples/README.md +134 -0
  17. slcli/examples/_schema/schema-v1.0.json +169 -0
  18. slcli/examples/demo-complete-workflow/README.md +323 -0
  19. slcli/examples/demo-complete-workflow/config.yaml +638 -0
  20. slcli/examples/demo-test-plans/README.md +132 -0
  21. slcli/examples/demo-test-plans/config.yaml +154 -0
  22. slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
  23. slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
  24. slcli/examples/exercise-7-1-test-plans/README.md +93 -0
  25. slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
  26. slcli/examples/spec-compliance-notebooks/README.md +140 -0
  27. slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
  28. slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
  29. slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
  30. slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
  31. slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  32. slcli/feed_click.py +892 -0
  33. slcli/file_click.py +932 -0
  34. slcli/function_click.py +1400 -0
  35. slcli/function_templates.py +85 -0
  36. slcli/main.py +406 -0
  37. slcli/mcp_click.py +269 -0
  38. slcli/mcp_server.py +748 -0
  39. slcli/notebook_click.py +1770 -0
  40. slcli/platform.py +345 -0
  41. slcli/policy_click.py +679 -0
  42. slcli/policy_utils.py +411 -0
  43. slcli/profiles.py +411 -0
  44. slcli/response_handlers.py +359 -0
  45. slcli/routine_click.py +763 -0
  46. slcli/skill_click.py +253 -0
  47. slcli/skills/slcli/SKILL.md +713 -0
  48. slcli/skills/slcli/references/analysis-recipes.md +474 -0
  49. slcli/skills/slcli/references/filtering.md +236 -0
  50. slcli/skills/systemlink-webapp/SKILL.md +744 -0
  51. slcli/skills/systemlink-webapp/references/deployment.md +123 -0
  52. slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
  53. slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
  54. slcli/ssl_trust.py +93 -0
  55. slcli/system_click.py +2216 -0
  56. slcli/table_utils.py +124 -0
  57. slcli/tag_click.py +794 -0
  58. slcli/templates_click.py +599 -0
  59. slcli/testmonitor_click.py +1667 -0
  60. slcli/universal_handlers.py +305 -0
  61. slcli/user_click.py +1218 -0
  62. slcli/utils.py +832 -0
  63. slcli/web_editor.py +295 -0
  64. slcli/webapp_click.py +981 -0
  65. slcli/workflow_preview.py +287 -0
  66. slcli/workflows_click.py +988 -0
  67. slcli/workitem_click.py +2258 -0
  68. slcli/workspace_click.py +576 -0
  69. slcli/workspace_utils.py +206 -0
  70. systemlink_cli-1.3.1.dist-info/METADATA +20 -0
  71. systemlink_cli-1.3.1.dist-info/RECORD +74 -0
  72. systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
  73. systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
  74. systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/web_editor.py ADDED
@@ -0,0 +1,295 @@
1
+ """Web editor utilities for custom fields configuration editing."""
2
+
3
+ import http.server
4
+ import json
5
+ import secrets
6
+ import socketserver
7
+ import sys
8
+ import threading
9
+ import urllib.parse
10
+ import webbrowser
11
+ from pathlib import Path
12
+ from typing import Optional, Any
13
+
14
+ import click
15
+ import requests
16
+
17
+ from .utils import ExitCodes, get_base_url, get_headers, get_ssl_verify
18
+
19
+
20
+ class DFFWebEditor:
21
+ """Web-based editor for custom fields configurations."""
22
+
23
+ def __init__(self, port: int = 8080):
24
+ """Initialize the DFF web editor.
25
+
26
+ Args:
27
+ port: Port number for the HTTP server
28
+ """
29
+ self.port = port
30
+ self._editor_dir = self._resolve_editor_directory()
31
+
32
+ def launch(self, file: Optional[str] = None, open_browser: bool = True) -> None:
33
+ """Launch the web editor with optional file loading.
34
+
35
+ Args:
36
+ file: Optional JSON file to load initially
37
+ open_browser: Whether to automatically open browser
38
+ """
39
+ try:
40
+ import tempfile
41
+
42
+ # Create a per-session temp directory for runtime files (config, uploaded files)
43
+ # Keep it alive for the server lifetime
44
+ self._temp_dir = tempfile.TemporaryDirectory(prefix="slcli-dff-")
45
+ self._temp_path = Path(self._temp_dir.name)
46
+
47
+ # Generate per-session secret for proxy auth
48
+ self._secret = secrets.token_urlsafe(24)
49
+ self._write_editor_config(file)
50
+ self._start_server(open_browser)
51
+ except Exception as exc:
52
+ click.echo(f"✗ Error starting editor: {exc}", err=True)
53
+ sys.exit(ExitCodes.GENERAL_ERROR)
54
+ finally:
55
+ # Clean up temp directory on exit
56
+ if hasattr(self, "_temp_dir"):
57
+ self._temp_dir.cleanup()
58
+
59
+ def _resolve_editor_directory(self) -> Path:
60
+ """Resolve the editor directory from the install location.
61
+
62
+ Priority:
63
+ 1) PyInstaller MEIPASS (onefile extraction dir)
64
+ 2) Frozen onedir next to the executable
65
+ 3) Site-packages root containing dff-editor (pip/Poetry install)
66
+ 4) Source tree relative to this file (development)
67
+
68
+ Returns:
69
+ Path to the dff-editor directory
70
+
71
+ Raises:
72
+ FileNotFoundError: If dff-editor directory cannot be found
73
+ """
74
+ candidates = []
75
+
76
+ # PyInstaller onefile mode
77
+ meipass = getattr(sys, "_MEIPASS", None)
78
+ if meipass:
79
+ candidates.append(Path(meipass) / "dff-editor")
80
+
81
+ # Frozen onedir layout
82
+ if getattr(sys, "frozen", False):
83
+ candidates.append(Path(sys.executable).resolve().parent / "dff-editor")
84
+
85
+ # pip/Poetry install: dff-editor alongside slcli package
86
+ site_packages_dir = Path(__file__).resolve().parent.parent
87
+ candidates.append(site_packages_dir / "dff-editor")
88
+
89
+ # Development/source tree
90
+ candidates.append(Path(__file__).resolve().parent.parent / "dff-editor")
91
+
92
+ for candidate in candidates:
93
+ if candidate.exists() and (candidate / "index.html").exists():
94
+ return candidate
95
+
96
+ # No editor directory found
97
+ raise FileNotFoundError(
98
+ "DFF editor assets not found. Please ensure the installation is complete. "
99
+ "Try reinstalling: pip install --force-reinstall slcli"
100
+ )
101
+
102
+ def _write_editor_config(self, file: Optional[str]) -> None:
103
+ """Write the editor configuration consumed by the frontend.
104
+
105
+ Args:
106
+ file: Optional initial file to load
107
+ """
108
+ config: dict[str, Any] = {
109
+ "serverUrl": get_base_url().rstrip("/"),
110
+ "secret": getattr(self, "_secret", None),
111
+ }
112
+
113
+ # If a file was provided, copy it to the temp directory for the editor to load
114
+ if file:
115
+ import shutil
116
+
117
+ source_path = Path(file)
118
+ if source_path.exists():
119
+ dest_path = self._temp_path / "config.json"
120
+ shutil.copy(source_path, dest_path)
121
+ config["configFile"] = "config.json"
122
+
123
+ config_path = self._temp_path / "slcli-config.json"
124
+ config_path.write_text(json.dumps(config, indent=2))
125
+
126
+ def _start_server(self, open_browser: bool) -> None:
127
+ """Start the HTTP server and optionally open browser.
128
+
129
+ Args:
130
+ open_browser: Whether to automatically open browser
131
+ """
132
+ editor_dir = self._editor_dir # Capture for closure
133
+ temp_path = self._temp_path # Capture for closure
134
+ api_base = get_base_url().rstrip("/")
135
+ default_headers = get_headers()
136
+ ssl_verify = get_ssl_verify()
137
+ secret = self._secret
138
+
139
+ class EditorTCPServer(socketserver.TCPServer):
140
+ allow_reuse_address = True
141
+
142
+ class Handler(http.server.SimpleHTTPRequestHandler):
143
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
144
+ super().__init__(*args, directory=str(editor_dir), **kwargs)
145
+
146
+ def log_message(self, format: str, *args: Any) -> None:
147
+ # Suppress server logs
148
+ pass
149
+
150
+ def _proxy_request(self, method: str) -> bool:
151
+ parsed = urllib.parse.urlparse(self.path)
152
+
153
+ # Serve slcli-config.json from temp directory
154
+ if parsed.path == "/slcli-config.json" and method == "GET":
155
+ config_file = temp_path / "slcli-config.json"
156
+ if config_file.exists():
157
+ self.send_response(200)
158
+ self.send_header("Content-Type", "application/json")
159
+ self.end_headers()
160
+ self.wfile.write(config_file.read_bytes())
161
+ return True
162
+ else:
163
+ self.send_error(404, "Config not found")
164
+ return True
165
+
166
+ # Serve config.json (the DFF configuration) from temp directory
167
+ if parsed.path == "/config.json" and method == "GET":
168
+ config_file = temp_path / "config.json"
169
+ if config_file.exists():
170
+ self.send_response(200)
171
+ self.send_header("Content-Type", "application/json")
172
+ self.end_headers()
173
+ self.wfile.write(config_file.read_bytes())
174
+ return True
175
+ else:
176
+ self.send_error(404, "Config file not found")
177
+ return True
178
+
179
+ # Handle API proxying
180
+ path_map = {
181
+ "/api/dff/configurations": "/nidynamicformfields/v1/configurations",
182
+ "/api/dff/update-configurations": "/nidynamicformfields/v1/update-configurations",
183
+ }
184
+
185
+ if parsed.path in path_map:
186
+ target_path = path_map[parsed.path]
187
+ elif parsed.path.startswith("/nidynamicformfields/v1/"):
188
+ target_path = parsed.path
189
+ elif parsed.path.startswith("/niuser/v1/workspaces"):
190
+ target_path = parsed.path
191
+ else:
192
+ return False
193
+
194
+ # Require per-session secret on all proxied routes
195
+ req_secret = self.headers.get("X-Editor-Secret")
196
+ if not secret or req_secret != secret:
197
+ self.send_error(403, "Forbidden: Missing or invalid editor secret")
198
+ return True
199
+
200
+ target_url = f"{api_base}{target_path}"
201
+ if parsed.query:
202
+ target_url = f"{target_url}?{parsed.query}"
203
+
204
+ headers = dict(default_headers)
205
+ data = None
206
+
207
+ if method == "POST":
208
+ content_length = int(self.headers.get("Content-Length", "0"))
209
+ data = self.rfile.read(content_length) if content_length > 0 else b""
210
+ headers["Content-Type"] = self.headers.get("Content-Type", "application/json")
211
+
212
+ try:
213
+ resp = requests.request(
214
+ method=method,
215
+ url=target_url,
216
+ headers=headers,
217
+ data=data,
218
+ verify=ssl_verify,
219
+ )
220
+ except requests.RequestException as exc: # pragma: no cover
221
+ self.send_error(502, f"Proxy error: {exc}")
222
+ return True
223
+
224
+ self.send_response(resp.status_code)
225
+ content_type = resp.headers.get("Content-Type")
226
+ if content_type:
227
+ self.send_header("Content-Type", content_type)
228
+ self.end_headers()
229
+ self.wfile.write(resp.content)
230
+ return True
231
+
232
+ def do_GET(self) -> None: # noqa: N802
233
+ if self._proxy_request("GET"):
234
+ return
235
+ super().do_GET()
236
+
237
+ def do_POST(self) -> None: # noqa: N802
238
+ if self._proxy_request("POST"):
239
+ return
240
+ # SimpleHTTPRequestHandler lacks POST; return 405 when not proxied.
241
+ self.send_error(405, "Method Not Allowed")
242
+
243
+ try:
244
+ with EditorTCPServer(("127.0.0.1", self.port), Handler) as httpd:
245
+ server_url = f"http://127.0.0.1:{self.port}"
246
+
247
+ # Start server in background thread
248
+ server_thread = threading.Thread(target=httpd.serve_forever)
249
+ server_thread.daemon = True
250
+ server_thread.start()
251
+
252
+ click.echo(f"✓ Starting Custom Fields editor at {server_url}")
253
+ click.echo(f"✓ Loading editor from: {editor_dir}")
254
+
255
+ if open_browser:
256
+ click.echo("✓ Opening in your default browser...")
257
+ webbrowser.open(server_url)
258
+
259
+ click.echo("\nPress Ctrl+C to stop the editor server")
260
+
261
+ try:
262
+ # Keep the server running
263
+ while True:
264
+ threading.Event().wait(1)
265
+ except KeyboardInterrupt:
266
+ click.echo("\n✓ Editor server stopped")
267
+ httpd.shutdown()
268
+ httpd.server_close()
269
+ server_thread.join(timeout=2)
270
+
271
+ except OSError as e:
272
+ if "Address already in use" in str(e):
273
+ click.echo(
274
+ f"✗ Port {self.port} is already in use. Try a different port with --port",
275
+ err=True,
276
+ )
277
+ sys.exit(ExitCodes.GENERAL_ERROR)
278
+ else:
279
+ raise
280
+
281
+
282
+ def launch_dff_editor(
283
+ file: Optional[str] = None,
284
+ port: int = 8080,
285
+ open_browser: bool = True,
286
+ ) -> None:
287
+ """Launch the DFF web editor with specified configuration.
288
+
289
+ Args:
290
+ file: Optional JSON file to edit
291
+ port: Port for local HTTP server
292
+ open_browser: Whether to auto-open browser
293
+ """
294
+ editor = DFFWebEditor(port=port)
295
+ editor.launch(file=file, open_browser=open_browser)