alignscope 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.
@@ -0,0 +1,368 @@
1
+ /**
2
+ * AlignScope — Topology Graph (D3.js Force-Directed)
3
+ *
4
+ * Renders agents as nodes and their relationships as edges.
5
+ * Coalition clusters form visually via D3 force simulation parameters.
6
+ *
7
+ * Nodes: colored by team (from config), sized by role stability
8
+ * Edges: opacity = reciprocity strength, width = total interactions
9
+ * Defectors: pulsing animation, pushed to periphery
10
+ *
11
+ * All team colors and role shapes are dynamically assigned from config.
12
+ */
13
+
14
+ const TopologyGraph = {
15
+ svg: null,
16
+ simulation: null,
17
+ config: null,
18
+ width: 0,
19
+ height: 0,
20
+ nodes: [],
21
+ links: [],
22
+ nodeElements: null,
23
+ linkElements: null,
24
+ labelElements: null,
25
+
26
+ // D3 symbol types palette — assigned dynamically to roles
27
+ shapePalette: [
28
+ d3.symbolCircle,
29
+ d3.symbolDiamond,
30
+ d3.symbolSquare,
31
+ d3.symbolTriangle,
32
+ d3.symbolStar,
33
+ d3.symbolCross,
34
+ d3.symbolWye,
35
+ ],
36
+
37
+ // Computed mappings (built from config)
38
+ roleShapes: {},
39
+ roleSize: {},
40
+
41
+ init(config) {
42
+ this.config = config;
43
+
44
+ // Build role → shape mapping dynamically from config
45
+ const roles = config.roles || [];
46
+ this.roleShapes = {};
47
+ this.roleSize = {};
48
+ roles.forEach((role, idx) => {
49
+ this.roleShapes[role] = this.shapePalette[idx % this.shapePalette.length];
50
+ this.roleSize[role] = 55 + (idx % 4) * 5; // slight size variation
51
+ });
52
+
53
+ const container = document.getElementById('topology-container');
54
+ this.width = container.clientWidth;
55
+ this.height = container.clientHeight;
56
+
57
+ this.svg = d3.select('#topology-svg')
58
+ .attr('width', this.width)
59
+ .attr('height', this.height);
60
+
61
+ // Clear any previous content
62
+ this.svg.selectAll('*').remove();
63
+
64
+ // Create layer groups (links behind nodes)
65
+ this.svg.append('g').attr('class', 'links-layer');
66
+ this.svg.append('g').attr('class', 'nodes-layer');
67
+ this.svg.append('g').attr('class', 'labels-layer');
68
+
69
+ // Initialize force simulation
70
+ this.simulation = d3.forceSimulation()
71
+ .force('charge', d3.forceManyBody()
72
+ .strength(-120)
73
+ .distanceMax(250))
74
+ .force('center', d3.forceCenter(this.width / 2, this.height / 2))
75
+ .force('collision', d3.forceCollide().radius(22))
76
+ .force('x', d3.forceX(this.width / 2).strength(0.03))
77
+ .force('y', d3.forceY(this.height / 2).strength(0.03))
78
+ .alphaDecay(0.01)
79
+ .on('tick', () => this.ticked());
80
+
81
+ this.updateBadge(0);
82
+ },
83
+
84
+ /**
85
+ * Get team color from config or fallback to AlignScope palette.
86
+ */
87
+ getTeamColor(teamIdx) {
88
+ return AlignScope.getTeamColor(teamIdx);
89
+ },
90
+
91
+ /**
92
+ * Get a slightly darker team color for stroke.
93
+ */
94
+ getTeamStroke(teamIdx) {
95
+ const color = this.getTeamColor(teamIdx);
96
+ // Darken by mixing toward black
97
+ return this._darkenColor(color, 0.2);
98
+ },
99
+
100
+ _darkenColor(hex, amount) {
101
+ // Simple hex darkening
102
+ let c = hex.replace('#', '');
103
+ if (c.length === 3) c = c[0]+c[0]+c[1]+c[1]+c[2]+c[2];
104
+ const r = Math.max(0, Math.round(parseInt(c.substr(0,2), 16) * (1 - amount)));
105
+ const g = Math.max(0, Math.round(parseInt(c.substr(2,2), 16) * (1 - amount)));
106
+ const b = Math.max(0, Math.round(parseInt(c.substr(4,2), 16) * (1 - amount)));
107
+ return `rgb(${r},${g},${b})`;
108
+ },
109
+
110
+ update(data) {
111
+ if (!this.svg) return;
112
+
113
+ const agents = data.agents || [];
114
+ const relationships = data.relationships || [];
115
+ const metrics = data.metrics?.agent_metrics || {};
116
+ const groupBy = this.config?.topology?.groupBy || 'team';
117
+
118
+ // Build node data
119
+ const nodeIds = new Set(agents.map(a => a.agent_id));
120
+ this.nodes = agents.map(a => {
121
+ const existing = this.simulation?.nodes()?.find(n => n.id === a.agent_id);
122
+ const m = metrics[a.agent_id] || metrics[String(a.agent_id)] || {};
123
+ return {
124
+ id: a.agent_id,
125
+ team: a[groupBy] !== undefined ? a[groupBy] : a.team,
126
+ role: a.role,
127
+ isDefector: a.is_defector,
128
+ coalitionId: a.coalition_id,
129
+ roleStability: m.role_stability || 1.0,
130
+ x: existing?.x || this.width / 2 + (Math.random() - 0.5) * 100,
131
+ y: existing?.y || this.height / 2 + (Math.random() - 0.5) * 100,
132
+ vx: existing?.vx || 0,
133
+ vy: existing?.vy || 0,
134
+ };
135
+ });
136
+
137
+ // Build link data
138
+ this.links = relationships
139
+ .filter(r => r.weight > 0 && nodeIds.has(r.source) && nodeIds.has(r.target))
140
+ .map(r => ({
141
+ source: r.source,
142
+ target: r.target,
143
+ weight: r.weight,
144
+ reciprocity: r.reciprocity,
145
+ sameTeam: r.same_team,
146
+ }));
147
+
148
+ this.render();
149
+ this.updateBadge(agents.length);
150
+
151
+ this.simulation.nodes(this.nodes);
152
+ this.simulation
153
+ .force('link', d3.forceLink(this.links)
154
+ .id(d => d.id)
155
+ .distance(d => {
156
+ const base = 100;
157
+ return Math.max(40, base - d.weight * 5);
158
+ })
159
+ .strength(d => {
160
+ return d.sameTeam ? 0.3 + d.reciprocity * 0.4 : 0.05;
161
+ }))
162
+ .alpha(0.15)
163
+ .restart();
164
+ },
165
+
166
+ render() {
167
+ const linksLayer = this.svg.select('.links-layer');
168
+ const nodesLayer = this.svg.select('.nodes-layer');
169
+ const labelsLayer = this.svg.select('.labels-layer');
170
+
171
+ // --- Links ---
172
+ this.linkElements = linksLayer.selectAll('.link-line')
173
+ .data(this.links, d => `${d.source.id || d.source}-${d.target.id || d.target}`);
174
+
175
+ this.linkElements.exit().remove();
176
+
177
+ const linkEnter = this.linkElements.enter()
178
+ .append('line')
179
+ .attr('class', 'link-line');
180
+
181
+ this.linkElements = linkEnter.merge(this.linkElements)
182
+ .attr('stroke-width', d => Math.max(0.5, Math.min(3, d.weight * 0.3)))
183
+ .attr('stroke-opacity', d => Math.max(0.08, Math.min(0.6, d.reciprocity * 0.8)))
184
+ .attr('stroke', d => d.sameTeam ? 'var(--text-muted)' : 'var(--border-subtle)');
185
+
186
+ // --- Nodes ---
187
+ this.nodeElements = nodesLayer.selectAll('.node-group')
188
+ .data(this.nodes, d => d.id);
189
+
190
+ this.nodeElements.exit().remove();
191
+
192
+ const nodeEnter = this.nodeElements.enter()
193
+ .append('g')
194
+ .attr('class', 'node-group')
195
+ .style('cursor', 'pointer')
196
+ .on('click', (event, d) => {
197
+ AlignScope.selectAgent(d.id);
198
+ this.highlightAgent(d.id);
199
+ })
200
+ .call(d3.drag()
201
+ .on('start', (event, d) => this.dragStarted(event, d))
202
+ .on('drag', (event, d) => this.dragged(event, d))
203
+ .on('end', (event, d) => this.dragEnded(event, d)));
204
+
205
+ nodeEnter.append('path').attr('class', 'node-shape');
206
+
207
+ this.nodeElements = nodeEnter.merge(this.nodeElements);
208
+
209
+ // Update node appearance — colors and shapes from config
210
+ this.nodeElements.select('.node-shape')
211
+ .attr('d', d => {
212
+ const size = this.roleSize[d.role] || 60;
213
+ const scaledSize = size * (0.8 + d.roleStability * 0.6);
214
+ const shape = this.roleShapes[d.role] || d3.symbolCircle;
215
+ return d3.symbol().type(shape).size(scaledSize)();
216
+ })
217
+ .attr('fill', d => {
218
+ if (d.isDefector) return 'var(--color-defection)';
219
+ return this.getTeamColor(d.team);
220
+ })
221
+ .attr('stroke', d => {
222
+ if (d.isDefector) return 'var(--color-defection)';
223
+ return this.getTeamStroke(d.team);
224
+ })
225
+ .attr('stroke-width', 1.5)
226
+ .attr('opacity', d => d.isDefector ? 0.7 : 0.9)
227
+ .classed('node-defector', d => d.isDefector);
228
+
229
+ // --- Labels ---
230
+ this.labelElements = labelsLayer.selectAll('.node-label')
231
+ .data(this.nodes, d => d.id);
232
+
233
+ this.labelElements.exit().remove();
234
+
235
+ const labelEnter = this.labelElements.enter()
236
+ .append('text')
237
+ .attr('class', 'node-label')
238
+ .attr('text-anchor', 'middle')
239
+ .attr('dy', -12);
240
+
241
+ this.labelElements = labelEnter.merge(this.labelElements)
242
+ .text(d => d.id);
243
+ },
244
+
245
+ ticked() {
246
+ if (this.linkElements) {
247
+ this.linkElements
248
+ .attr('x1', d => d.source.x)
249
+ .attr('y1', d => d.source.y)
250
+ .attr('x2', d => d.target.x)
251
+ .attr('y2', d => d.target.y);
252
+ }
253
+
254
+ if (this.nodeElements) {
255
+ this.nodeElements
256
+ .attr('transform', d => {
257
+ d.x = Math.max(20, Math.min(this.width - 20, d.x));
258
+ d.y = Math.max(20, Math.min(this.height - 20, d.y));
259
+ return `translate(${d.x},${d.y})`;
260
+ });
261
+ }
262
+
263
+ if (this.labelElements) {
264
+ this.labelElements
265
+ .attr('x', d => d.x)
266
+ .attr('y', d => d.y);
267
+ }
268
+ },
269
+
270
+ dragStarted(event, d) {
271
+ if (!event.active) this.simulation.alphaTarget(0.1).restart();
272
+ d.fx = d.x;
273
+ d.fy = d.y;
274
+ },
275
+
276
+ dragged(event, d) {
277
+ d.fx = event.x;
278
+ d.fy = event.y;
279
+ },
280
+
281
+ dragEnded(event, d) {
282
+ if (!event.active) this.simulation.alphaTarget(0);
283
+ d.fx = null;
284
+ d.fy = null;
285
+ },
286
+
287
+ highlightAgent(agentId) {
288
+ if (!this.nodeElements) return;
289
+
290
+ this.nodeElements.select('.node-shape')
291
+ .attr('stroke-width', d => d.id === agentId ? 3 : 1.5)
292
+ .attr('stroke', d => {
293
+ if (d.id === agentId) return 'var(--text-primary)';
294
+ if (d.isDefector) return 'var(--color-defection)';
295
+ return this.getTeamStroke(d.team);
296
+ });
297
+
298
+ if (this.linkElements) {
299
+ this.linkElements
300
+ .attr('stroke-opacity', d => {
301
+ const srcId = d.source.id ?? d.source;
302
+ const tgtId = d.target.id ?? d.target;
303
+ if (srcId === agentId || tgtId === agentId) {
304
+ return 0.8;
305
+ }
306
+ return Math.max(0.05, Math.min(0.3, d.reciprocity * 0.4));
307
+ })
308
+ .attr('stroke-width', d => {
309
+ const srcId = d.source.id ?? d.source;
310
+ const tgtId = d.target.id ?? d.target;
311
+ if (srcId === agentId || tgtId === agentId) {
312
+ return Math.max(1.5, d.weight * 0.5);
313
+ }
314
+ return Math.max(0.5, Math.min(3, d.weight * 0.3));
315
+ });
316
+ }
317
+ },
318
+
319
+ clearSelection() {
320
+ if (!this.nodeElements) return;
321
+ this.nodeElements.select('.node-shape')
322
+ .attr('stroke-width', 1.5)
323
+ .attr('stroke', d => {
324
+ if (d.isDefector) return 'var(--color-defection)';
325
+ return this.getTeamStroke(d.team);
326
+ });
327
+
328
+ if (this.linkElements) {
329
+ this.linkElements
330
+ .attr('stroke-opacity', d => Math.max(0.08, Math.min(0.6, d.reciprocity * 0.8)))
331
+ .attr('stroke-width', d => Math.max(0.5, Math.min(3, d.weight * 0.3)));
332
+ }
333
+ },
334
+
335
+ updateBadge(count) {
336
+ document.getElementById('topology-badge').textContent =
337
+ `${count} node${count !== 1 ? 's' : ''}`;
338
+ },
339
+
340
+ resize() {
341
+ const container = document.getElementById('topology-container');
342
+ this.width = container.clientWidth;
343
+ this.height = container.clientHeight;
344
+
345
+ if (this.svg) {
346
+ this.svg
347
+ .attr('width', this.width)
348
+ .attr('height', this.height);
349
+ }
350
+
351
+ if (this.simulation) {
352
+ this.simulation
353
+ .force('center', d3.forceCenter(this.width / 2, this.height / 2))
354
+ .force('x', d3.forceX(this.width / 2).strength(0.03))
355
+ .force('y', d3.forceY(this.height / 2).strength(0.03))
356
+ .alpha(0.3)
357
+ .restart();
358
+ }
359
+ },
360
+
361
+ reset() {
362
+ if (this.svg) this.svg.selectAll('.links-layer *, .nodes-layer *, .labels-layer *').remove();
363
+ this.nodes = [];
364
+ this.links = [];
365
+ if (this.simulation) this.simulation.nodes([]);
366
+ this.updateBadge(0);
367
+ },
368
+ };
alignscope/adapters.py ADDED
@@ -0,0 +1,169 @@
1
+ from typing import Dict, Any
2
+
3
+ def extract_mpe_state(env, agent_id: str) -> Dict[str, Any]:
4
+ """
5
+ Extracts continuous (x, y) coordinates from PettingZoo MPE physics engine.
6
+ MPE agents have state.p_pos = [x, y].
7
+ """
8
+ try:
9
+ # Aces environments often expose the raw wrapped env
10
+ raw_env = getattr(env, "env", env)
11
+ if hasattr(raw_env, "unwrapped"):
12
+ raw_env = raw_env.unwrapped
13
+
14
+ if hasattr(raw_env, "world"):
15
+ # Multi-Particle Environment (MPE)
16
+ for internal_agent in raw_env.world.agents:
17
+ # MPE raw agents are usually just named "agent 0", "adversary 0"
18
+ if agent_id.replace("_", " ") in internal_agent.name or internal_agent.name.replace(" ", "_") in agent_id:
19
+ return {
20
+ "x": float(internal_agent.state.p_pos[0]),
21
+ "y": float(internal_agent.state.p_pos[1])
22
+ }
23
+ except Exception:
24
+ pass
25
+
26
+ # Fallback if properties not found
27
+ return {"x": 0.0, "y": 0.0}
28
+
29
+
30
+ def extract_kaz_state(env, agent_id: str) -> Dict[str, Any]:
31
+ """
32
+ Extracts grid coordinates from PettingZoo Knights-Archers-Zombies (KAZ).
33
+ Also accurately identifies the role based on the agent name.
34
+ """
35
+ role = "agent"
36
+ if "knight" in agent_id.lower():
37
+ role = "knight"
38
+ elif "archer" in agent_id.lower():
39
+ role = "archer"
40
+ elif "zombie" in agent_id.lower():
41
+ role = "zombie"
42
+
43
+ pos = {"x": 0.0, "y": 0.0}
44
+ try:
45
+ raw_env = getattr(env, "env", env)
46
+ if hasattr(raw_env, "unwrapped"):
47
+ raw_env = raw_env.unwrapped
48
+
49
+ # KAZ internal objects are kept in lists like env.knights, env.archers
50
+ collection = []
51
+ if role == "knight" and hasattr(raw_env, "knights"):
52
+ collection = raw_env.knights
53
+ elif role == "archer" and hasattr(raw_env, "archers"):
54
+ collection = raw_env.archers
55
+ elif role == "zombie" and hasattr(raw_env, "zombies"):
56
+ collection = raw_env.zombies
57
+
58
+ # We try to match the index if agent_id ends with a number (e.g., "knight_0")
59
+ try:
60
+ idx = int(agent_id.split("_")[-1])
61
+ if idx < len(collection):
62
+ obj = collection[idx]
63
+ if hasattr(obj, "position"): # Usually [x, y] in KAZ
64
+ pos["x"] = float(obj.position[0])
65
+ pos["y"] = float(obj.position[1])
66
+ except (ValueError, IndexError):
67
+ pass
68
+
69
+ except Exception:
70
+ pass
71
+
72
+ return {
73
+ "role": role,
74
+ "x": pos["x"],
75
+ "y": pos["y"]
76
+ }
77
+
78
+ def extract_smac_state(env, agent_id: str) -> Dict[str, Any]:
79
+ """
80
+ Extracts unit type, position, health, and shield from SMAC (StarCraft Multi-Agent Challenge).
81
+
82
+ SMAC exposes per-unit data via env.get_unit_by_id() or the internal
83
+ controller's allies/enemies lists.
84
+ """
85
+ role = "marine" # Default SMAC unit
86
+ pos = {"x": 0.0, "y": 0.0}
87
+ health = 0.0
88
+ shield = 0.0
89
+
90
+ try:
91
+ raw_env = getattr(env, "env", env)
92
+ if hasattr(raw_env, "unwrapped"):
93
+ raw_env = raw_env.unwrapped
94
+
95
+ # Extract agent index
96
+ try:
97
+ idx = int(agent_id.split("_")[-1])
98
+ except (ValueError, IndexError):
99
+ idx = 0
100
+
101
+ # SMAC StarCraft2Env exposes allies via controller
102
+ if hasattr(raw_env, "agents") and hasattr(raw_env, "get_unit_by_id"):
103
+ unit = raw_env.get_unit_by_id(idx)
104
+ if unit is not None:
105
+ pos["x"] = float(unit.pos.x) if hasattr(unit, "pos") else 0.0
106
+ pos["y"] = float(unit.pos.y) if hasattr(unit, "pos") else 0.0
107
+ health = float(unit.health) / float(unit.health_max) if hasattr(unit, "health_max") and unit.health_max > 0 else 0.0
108
+ shield = float(unit.shield) / float(unit.shield_max) if hasattr(unit, "shield_max") and unit.shield_max > 0 else 0.0
109
+
110
+ # Infer role from unit type
111
+ unit_type = getattr(unit, "unit_type", None)
112
+ type_map = {
113
+ 0: "marine", 1: "marauder", 2: "medivac",
114
+ 48: "marine", 49: "marauder", 50: "reaper",
115
+ 51: "ghost", 73: "zealot", 74: "stalker",
116
+ 75: "sentry", 76: "high_templar", 77: "colossus",
117
+ }
118
+ if unit_type is not None:
119
+ role = type_map.get(unit_type, f"unit_{unit_type}")
120
+
121
+ # Fallback: try the _obs property or controller
122
+ elif hasattr(raw_env, "controller") or hasattr(raw_env, "_sc2_env"):
123
+ # Newer SMAC versions
124
+ sc2_env = getattr(raw_env, "_sc2_env", raw_env)
125
+ if hasattr(sc2_env, "get_ally_units"):
126
+ allies = sc2_env.get_ally_units()
127
+ if idx < len(allies):
128
+ unit = allies[idx]
129
+ pos["x"] = float(getattr(unit, "x", 0))
130
+ pos["y"] = float(getattr(unit, "y", 0))
131
+ health = float(getattr(unit, "health", 0)) / max(float(getattr(unit, "health_max", 1)), 1)
132
+ role = "ally"
133
+
134
+ except Exception:
135
+ pass
136
+
137
+ return {
138
+ "role": role,
139
+ "x": pos["x"],
140
+ "y": pos["y"],
141
+ "health": health,
142
+ "shield": shield,
143
+ }
144
+
145
+
146
+ def try_extract_env_state(env, agent_id: str) -> Dict[str, Any]:
147
+ """
148
+ Master adapter router. Detects environment type and routes to the correct extractor.
149
+ """
150
+ env_name = str(env).lower()
151
+
152
+ # Check for MPE
153
+ if "mpe" in env_name or "simple_" in env_name:
154
+ return extract_mpe_state(env, agent_id)
155
+
156
+ # Check for Knights Archers Zombies
157
+ if "knights_archers_zombies" in env_name or "kaz" in env_name:
158
+ return extract_kaz_state(env, agent_id)
159
+
160
+ # Check for SMAC (StarCraft Multi-Agent Challenge)
161
+ if "starcraft" in env_name or "smac" in env_name or "sc2" in env_name:
162
+ return extract_smac_state(env, agent_id)
163
+
164
+ # Check for SMAC by attribute detection (env_name may not contain keywords)
165
+ raw = getattr(env, "unwrapped", env)
166
+ if hasattr(raw, "get_unit_by_id") or hasattr(raw, "_sc2_env"):
167
+ return extract_smac_state(env, agent_id)
168
+
169
+ return {"x": 0.0, "y": 0.0}
alignscope/cli.py ADDED
@@ -0,0 +1,99 @@
1
+ """
2
+ AlignScope — CLI
3
+
4
+ Commands:
5
+ alignscope start [--port 8000] [--demo] Start the dashboard server
6
+ alignscope patch <framework> Auto-patch a MARL framework
7
+ alignscope share Create a public tunnel (ngrok)
8
+ alignscope version Print version
9
+ """
10
+
11
+ import click
12
+ from rich.console import Console
13
+
14
+ console = Console()
15
+
16
+
17
+ @click.group()
18
+ def main():
19
+ """🔬 AlignScope — MARL Alignment Observability"""
20
+ pass
21
+
22
+
23
+ @main.command()
24
+ @click.option("--port", default=8000, help="Port to serve on")
25
+ @click.option("--host", default="0.0.0.0", help="Host to bind to")
26
+ @click.option("--demo", is_flag=True, help="Run built-in demo simulator")
27
+ def start(port: int, host: str, demo: bool):
28
+ """Start the AlignScope dashboard server."""
29
+ from alignscope.server import run_server
30
+
31
+ console.print()
32
+ console.print("[bold cyan]🔬 AlignScope[/] starting...", highlight=False)
33
+ console.print()
34
+
35
+ run_server(host=host, port=port, demo=demo)
36
+
37
+
38
+ @main.command()
39
+ @click.argument("framework", type=click.Choice(["rllib", "pettingzoo", "pymarl"]))
40
+ def patch(framework: str):
41
+ """Auto-patch a MARL framework for zero-code integration."""
42
+ from alignscope.patches import apply_patch
43
+
44
+ console.print(f"\n[bold cyan]🔬 AlignScope[/] patching [bold]{framework}[/]...\n")
45
+
46
+ success = apply_patch(framework)
47
+
48
+ if success:
49
+ console.print(f"[green]✓[/] [bold]{framework}[/] successfully patched!")
50
+ console.print(f" Your training code needs [bold]zero changes[/].")
51
+ console.print(f" Just run [cyan]alignscope start[/] in another terminal,")
52
+ console.print(f" then run your training script as normal.\n")
53
+ else:
54
+ console.print(f"[red]✗[/] Failed to patch {framework}.")
55
+ console.print(f" Is it installed? Try: [cyan]pip install alignscope[{framework}][/]\n")
56
+
57
+
58
+ @main.command()
59
+ @click.option("--port", default=8000, help="Local port to tunnel")
60
+ def share(port: int):
61
+ """Create a public URL to share your dashboard with teammates."""
62
+ try:
63
+ from pyngrok import ngrok
64
+
65
+ console.print(f"\n[bold cyan]🔬 AlignScope[/] creating public tunnel...\n")
66
+
67
+ tunnel = ngrok.connect(port, "http")
68
+ public_url = tunnel.public_url
69
+
70
+ console.print(f"[green]✓[/] Dashboard is now publicly accessible:")
71
+ console.print(f" [bold link={public_url}]{public_url}[/]")
72
+ console.print(f"\n Share this URL with your team.")
73
+ console.print(f" Press [bold]Ctrl+C[/] to stop sharing.\n")
74
+
75
+ try:
76
+ ngrok.get_tunnels()
77
+ input(" Press Enter to stop sharing...")
78
+ except KeyboardInterrupt:
79
+ pass
80
+ finally:
81
+ ngrok.disconnect(tunnel.public_url)
82
+ console.print("\n[yellow]Tunnel closed.[/]\n")
83
+
84
+ except ImportError:
85
+ console.print(
86
+ "\n[red]✗[/] ngrok is not installed.\n"
87
+ " Install it with: [cyan]pip install pyngrok[/]\n"
88
+ )
89
+
90
+
91
+ @main.command()
92
+ def version():
93
+ """Print AlignScope version."""
94
+ from alignscope import __version__
95
+ console.print(f"AlignScope v{__version__}")
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()