htmlgraph 0.26.5__py3-none-any.whl → 0.26.7__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 (70) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +1 -1
  2. htmlgraph/__init__.py +1 -1
  3. htmlgraph/api/main.py +50 -10
  4. htmlgraph/api/templates/dashboard-redesign.html +608 -54
  5. htmlgraph/api/templates/partials/activity-feed.html +21 -0
  6. htmlgraph/api/templates/partials/features.html +81 -12
  7. htmlgraph/api/templates/partials/orchestration.html +35 -0
  8. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  9. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  10. htmlgraph/cli/__init__.py +42 -0
  11. htmlgraph/cli/__main__.py +6 -0
  12. htmlgraph/cli/analytics.py +939 -0
  13. htmlgraph/cli/base.py +660 -0
  14. htmlgraph/cli/constants.py +206 -0
  15. htmlgraph/cli/core.py +856 -0
  16. htmlgraph/cli/main.py +143 -0
  17. htmlgraph/cli/models.py +462 -0
  18. htmlgraph/cli/templates/__init__.py +1 -0
  19. htmlgraph/cli/templates/cost_dashboard.py +398 -0
  20. htmlgraph/cli/work/__init__.py +159 -0
  21. htmlgraph/cli/work/features.py +567 -0
  22. htmlgraph/cli/work/orchestration.py +675 -0
  23. htmlgraph/cli/work/sessions.py +465 -0
  24. htmlgraph/cli/work/tracks.py +485 -0
  25. htmlgraph/dashboard.html +6414 -634
  26. htmlgraph/db/schema.py +8 -3
  27. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +20 -13
  28. htmlgraph/docs/README.md +2 -3
  29. htmlgraph/hooks/event_tracker.py +355 -26
  30. htmlgraph/hooks/git_commands.py +175 -0
  31. htmlgraph/hooks/orchestrator.py +137 -71
  32. htmlgraph/hooks/orchestrator_reflector.py +23 -0
  33. htmlgraph/hooks/pretooluse.py +29 -6
  34. htmlgraph/hooks/session_handler.py +28 -0
  35. htmlgraph/hooks/session_summary.py +391 -0
  36. htmlgraph/hooks/subagent_detection.py +202 -0
  37. htmlgraph/hooks/subagent_stop.py +71 -12
  38. htmlgraph/hooks/validator.py +192 -79
  39. htmlgraph/operations/__init__.py +18 -0
  40. htmlgraph/operations/initialization.py +596 -0
  41. htmlgraph/operations/initialization.py.backup +228 -0
  42. htmlgraph/orchestration/__init__.py +16 -1
  43. htmlgraph/orchestration/claude_launcher.py +185 -0
  44. htmlgraph/orchestration/command_builder.py +71 -0
  45. htmlgraph/orchestration/headless_spawner.py +72 -1332
  46. htmlgraph/orchestration/plugin_manager.py +136 -0
  47. htmlgraph/orchestration/prompts.py +137 -0
  48. htmlgraph/orchestration/spawners/__init__.py +16 -0
  49. htmlgraph/orchestration/spawners/base.py +194 -0
  50. htmlgraph/orchestration/spawners/claude.py +170 -0
  51. htmlgraph/orchestration/spawners/codex.py +442 -0
  52. htmlgraph/orchestration/spawners/copilot.py +299 -0
  53. htmlgraph/orchestration/spawners/gemini.py +478 -0
  54. htmlgraph/orchestration/subprocess_runner.py +33 -0
  55. htmlgraph/orchestration.md +563 -0
  56. htmlgraph/orchestrator-system-prompt-optimized.txt +620 -55
  57. htmlgraph/orchestrator_config.py +357 -0
  58. htmlgraph/orchestrator_mode.py +45 -12
  59. htmlgraph/transcript.py +16 -4
  60. htmlgraph-0.26.7.data/data/htmlgraph/dashboard.html +6592 -0
  61. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/METADATA +1 -1
  62. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/RECORD +68 -34
  63. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/entry_points.txt +1 -1
  64. htmlgraph/cli.py +0 -7256
  65. htmlgraph-0.26.5.data/data/htmlgraph/dashboard.html +0 -812
  66. {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/styles.css +0 -0
  67. {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  68. {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  69. {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  70. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/WHEEL +0 -0
@@ -0,0 +1,567 @@
1
+ """HtmlGraph CLI - Feature management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from typing import TYPE_CHECKING
7
+
8
+ from rich import box
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ from htmlgraph.cli.base import BaseCommand, CommandError, CommandResult
14
+ from htmlgraph.cli.constants import DEFAULT_GRAPH_DIR
15
+
16
+ if TYPE_CHECKING:
17
+ from argparse import _SubParsersAction
18
+
19
+ console = Console()
20
+
21
+
22
+ def register_feature_commands(subparsers: _SubParsersAction) -> None:
23
+ """Register feature management commands."""
24
+ feature_parser = subparsers.add_parser("feature", help="Feature management")
25
+ feature_subparsers = feature_parser.add_subparsers(
26
+ dest="feature_command", help="Feature command"
27
+ )
28
+
29
+ # feature list
30
+ feature_list = feature_subparsers.add_parser("list", help="List all features")
31
+ feature_list.add_argument(
32
+ "--status",
33
+ choices=["todo", "in_progress", "completed", "blocked"],
34
+ help="Filter by status",
35
+ )
36
+ feature_list.add_argument(
37
+ "--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
38
+ )
39
+ feature_list.add_argument(
40
+ "--format", choices=["json", "text"], default="text", help="Output format"
41
+ )
42
+ feature_list.add_argument(
43
+ "--quiet", "-q", action="store_true", help="Suppress empty output"
44
+ )
45
+ feature_list.set_defaults(func=FeatureListCommand.from_args)
46
+
47
+ # feature create
48
+ feature_create = feature_subparsers.add_parser(
49
+ "create", help="Create a new feature"
50
+ )
51
+ feature_create.add_argument("title", help="Feature title")
52
+ feature_create.add_argument("--description", help="Feature description")
53
+ feature_create.add_argument(
54
+ "--priority", choices=["low", "medium", "high", "critical"], default="medium"
55
+ )
56
+ feature_create.add_argument("--steps", type=int, help="Number of steps")
57
+ feature_create.add_argument(
58
+ "--collection", default="features", help="Collection name"
59
+ )
60
+ feature_create.add_argument("--track", help="Track ID to link feature to")
61
+ feature_create.add_argument("--agent", default="claude-code", help="Agent name")
62
+ feature_create.add_argument(
63
+ "--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
64
+ )
65
+ feature_create.add_argument(
66
+ "--format", choices=["json", "text"], default="text", help="Output format"
67
+ )
68
+ feature_create.set_defaults(func=FeatureCreateCommand.from_args)
69
+
70
+ # feature start
71
+ feature_start = feature_subparsers.add_parser(
72
+ "start", help="Start working on a feature"
73
+ )
74
+ feature_start.add_argument("id", help="Feature ID")
75
+ feature_start.add_argument(
76
+ "--collection", default="features", help="Collection name"
77
+ )
78
+ feature_start.add_argument("--agent", default="claude-code", help="Agent name")
79
+ feature_start.add_argument(
80
+ "--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
81
+ )
82
+ feature_start.add_argument(
83
+ "--format", choices=["json", "text"], default="text", help="Output format"
84
+ )
85
+ feature_start.set_defaults(func=FeatureStartCommand.from_args)
86
+
87
+ # feature complete
88
+ feature_complete = feature_subparsers.add_parser(
89
+ "complete", help="Mark feature as completed"
90
+ )
91
+ feature_complete.add_argument("id", help="Feature ID")
92
+ feature_complete.add_argument(
93
+ "--collection", default="features", help="Collection name"
94
+ )
95
+ feature_complete.add_argument("--agent", default="claude-code", help="Agent name")
96
+ feature_complete.add_argument(
97
+ "--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
98
+ )
99
+ feature_complete.add_argument(
100
+ "--format", choices=["json", "text"], default="text", help="Output format"
101
+ )
102
+ feature_complete.set_defaults(func=FeatureCompleteCommand.from_args)
103
+
104
+ # feature claim
105
+ feature_claim = feature_subparsers.add_parser("claim", help="Claim a feature")
106
+ feature_claim.add_argument("id", help="Feature ID")
107
+ feature_claim.add_argument(
108
+ "--collection", default="features", help="Collection name"
109
+ )
110
+ feature_claim.add_argument("--agent", default="claude-code", help="Agent name")
111
+ feature_claim.add_argument(
112
+ "--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
113
+ )
114
+ feature_claim.add_argument(
115
+ "--format", choices=["json", "text"], default="text", help="Output format"
116
+ )
117
+ feature_claim.set_defaults(func=FeatureClaimCommand.from_args)
118
+
119
+ # feature release
120
+ feature_release = feature_subparsers.add_parser("release", help="Release a feature")
121
+ feature_release.add_argument("id", help="Feature ID")
122
+ feature_release.add_argument(
123
+ "--collection", default="features", help="Collection name"
124
+ )
125
+ feature_release.add_argument("--agent", default="claude-code", help="Agent name")
126
+ feature_release.add_argument(
127
+ "--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
128
+ )
129
+ feature_release.add_argument(
130
+ "--format", choices=["json", "text"], default="text", help="Output format"
131
+ )
132
+ feature_release.set_defaults(func=FeatureReleaseCommand.from_args)
133
+
134
+ # feature primary
135
+ feature_primary = feature_subparsers.add_parser(
136
+ "primary", help="Set primary feature"
137
+ )
138
+ feature_primary.add_argument("id", help="Feature ID")
139
+ feature_primary.add_argument(
140
+ "--collection", default="features", help="Collection name"
141
+ )
142
+ feature_primary.add_argument("--agent", default="claude-code", help="Agent name")
143
+ feature_primary.add_argument(
144
+ "--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
145
+ )
146
+ feature_primary.add_argument(
147
+ "--format", choices=["json", "text"], default="text", help="Output format"
148
+ )
149
+ feature_primary.set_defaults(func=FeaturePrimaryCommand.from_args)
150
+
151
+
152
+ # ============================================================================
153
+ # Feature Commands
154
+ # ============================================================================
155
+
156
+
157
+ class FeatureListCommand(BaseCommand):
158
+ """List all features."""
159
+
160
+ def __init__(self, *, status: str | None, quiet: bool) -> None:
161
+ super().__init__()
162
+ self.status = status
163
+ self.quiet = quiet
164
+
165
+ @classmethod
166
+ def from_args(cls, args: argparse.Namespace) -> FeatureListCommand:
167
+ # Validate inputs using FeatureFilter model
168
+ from htmlgraph.cli.models import FeatureFilter
169
+
170
+ try:
171
+ filter_model = FeatureFilter(status=args.status)
172
+ except ValueError as e:
173
+ raise CommandError(str(e))
174
+
175
+ return cls(
176
+ status=filter_model.status,
177
+ quiet=args.quiet,
178
+ )
179
+
180
+ def execute(self) -> CommandResult:
181
+ """List all features."""
182
+ from htmlgraph.cli.models import FeatureDisplay
183
+ from htmlgraph.converter import node_to_dict
184
+
185
+ sdk = self.get_sdk()
186
+
187
+ # Query features with SDK
188
+ if self.status:
189
+ nodes = sdk.features.where(status=self.status)
190
+ else:
191
+ nodes = sdk.features.all()
192
+
193
+ # Convert to display models for type-safe sorting
194
+ display_features = [FeatureDisplay.from_node(n) for n in nodes]
195
+
196
+ # Sort by priority then updated using display model's sort_key
197
+ display_features.sort(key=lambda f: f.sort_key(), reverse=True)
198
+
199
+ if not display_features:
200
+ if not self.quiet:
201
+ from htmlgraph.cli.base import TextOutputBuilder
202
+
203
+ status_msg = f"with status '{self.status}'" if self.status else ""
204
+ output = TextOutputBuilder()
205
+ output.add_warning(f"No features found {status_msg}.")
206
+ return CommandResult(text=output.build(), json_data={"features": []})
207
+ return CommandResult(json_data={"features": []})
208
+
209
+ # Create Rich table
210
+ table = Table(
211
+ title="Features",
212
+ show_header=True,
213
+ header_style="bold magenta",
214
+ box=box.ROUNDED,
215
+ )
216
+ table.add_column("ID", style="cyan", no_wrap=False, max_width=20)
217
+ table.add_column("Title", style="yellow", max_width=40)
218
+ table.add_column("Status", style="green", width=12)
219
+ table.add_column("Priority", style="blue", width=10)
220
+ table.add_column("Updated", style="white", width=16)
221
+
222
+ for feature in display_features:
223
+ table.add_row(
224
+ feature.id,
225
+ feature.title,
226
+ feature.status,
227
+ feature.priority,
228
+ feature.updated_str,
229
+ )
230
+
231
+ # Return table object directly - TextFormatter will print it properly
232
+ return CommandResult(
233
+ data=table,
234
+ json_data=[node_to_dict(n) for n in nodes],
235
+ )
236
+
237
+
238
+ class FeatureCreateCommand(BaseCommand):
239
+ """Create a new feature."""
240
+
241
+ def __init__(
242
+ self,
243
+ *,
244
+ title: str,
245
+ description: str | None,
246
+ priority: str,
247
+ steps: int | None,
248
+ collection: str,
249
+ track_id: str | None,
250
+ ) -> None:
251
+ super().__init__()
252
+ self.title = title
253
+ self.description = description
254
+ self.priority = priority
255
+ self.steps = steps
256
+ self.collection = collection
257
+ self.track_id = track_id
258
+
259
+ @classmethod
260
+ def from_args(cls, args: argparse.Namespace) -> FeatureCreateCommand:
261
+ return cls(
262
+ title=args.title,
263
+ description=args.description,
264
+ priority=args.priority,
265
+ steps=args.steps,
266
+ collection=args.collection,
267
+ track_id=args.track,
268
+ )
269
+
270
+ def execute(self) -> CommandResult:
271
+ """Create a new feature."""
272
+ from rich.prompt import Prompt
273
+
274
+ from htmlgraph.converter import node_to_dict
275
+
276
+ sdk = self.get_sdk()
277
+
278
+ # Convert steps count to list of step names
279
+ step_names = None
280
+ if self.steps:
281
+ step_names = [f"Step {i + 1}" for i in range(self.steps)]
282
+
283
+ # Determine track_id for feature creation
284
+ track_id = self.track_id
285
+
286
+ # Only enforce track selection for main features collection
287
+ if self.collection == "features":
288
+ if not track_id:
289
+ # Get available tracks
290
+ try:
291
+ tracks = sdk.tracks.all()
292
+ if not tracks:
293
+ raise CommandError(
294
+ "No tracks found. Create a track first:\n"
295
+ " uv run htmlgraph track new 'Track Title'"
296
+ )
297
+
298
+ if len(tracks) == 1:
299
+ # Auto-select if only one track exists
300
+ track_id = tracks[0].id
301
+ console.print(
302
+ f"[dim]Auto-selected track: {tracks[0].title}[/dim]"
303
+ )
304
+ else:
305
+ # Interactive selection
306
+ console.print("[bold]Available Tracks:[/bold]")
307
+ for i, track in enumerate(tracks, 1):
308
+ console.print(f" {i}. {track.title} ({track.id})")
309
+
310
+ selection = Prompt.ask(
311
+ "Select track",
312
+ choices=[str(i) for i in range(1, len(tracks) + 1)],
313
+ )
314
+ track_id = tracks[int(selection) - 1].id
315
+ except Exception as e:
316
+ raise CommandError(f"Failed to get available tracks: {e}")
317
+
318
+ builder = sdk.features.create(
319
+ title=self.title,
320
+ description=self.description or "",
321
+ priority=self.priority,
322
+ )
323
+ if step_names:
324
+ builder.add_steps(step_names)
325
+ if track_id:
326
+ builder.set_track(track_id)
327
+ node = builder.save()
328
+ else:
329
+ node = sdk.session_manager.create_feature(
330
+ title=self.title,
331
+ collection=self.collection,
332
+ description=self.description or "",
333
+ priority=self.priority,
334
+ steps=step_names,
335
+ agent=self.agent,
336
+ )
337
+
338
+ # Create Rich table for output
339
+ table = Table(show_header=False, box=None)
340
+ table.add_column(style="bold cyan")
341
+ table.add_column()
342
+
343
+ table.add_row("Created:", f"[green]{node.id}[/green]")
344
+ table.add_row("Title:", f"[yellow]{node.title}[/yellow]")
345
+ table.add_row("Status:", f"[blue]{node.status}[/blue]")
346
+ if node.track_id:
347
+ table.add_row("Track:", f"[cyan]{node.track_id}[/cyan]")
348
+ table.add_row(
349
+ "Path:", f"[dim]{self.graph_dir}/{self.collection}/{node.id}.html[/dim]"
350
+ )
351
+
352
+ # Return table object directly - TextFormatter will print it properly
353
+ return CommandResult(
354
+ data=table,
355
+ json_data=node_to_dict(node),
356
+ )
357
+
358
+
359
+ class FeatureStartCommand(BaseCommand):
360
+ """Start working on a feature."""
361
+
362
+ def __init__(self, *, feature_id: str, collection: str) -> None:
363
+ super().__init__()
364
+ self.feature_id = feature_id
365
+ self.collection = collection
366
+
367
+ @classmethod
368
+ def from_args(cls, args: argparse.Namespace) -> FeatureStartCommand:
369
+ return cls(feature_id=args.id, collection=args.collection)
370
+
371
+ def execute(self) -> CommandResult:
372
+ """Start working on a feature."""
373
+ from htmlgraph.converter import node_to_dict
374
+
375
+ sdk = self.get_sdk()
376
+ collection = getattr(sdk, self.collection, None)
377
+ self.require_collection(collection, self.collection)
378
+ assert collection is not None # Type narrowing for mypy
379
+
380
+ node = collection.start(self.feature_id)
381
+ self.require_node(node, "feature", self.feature_id)
382
+
383
+ status = sdk.session_manager.get_status()
384
+
385
+ # Create Rich table for output
386
+ table = Table(show_header=False, box=None)
387
+ table.add_column(style="bold cyan")
388
+ table.add_column()
389
+
390
+ table.add_row("Started:", f"[green]{node.id}[/green]")
391
+ table.add_row("Title:", f"[yellow]{node.title}[/yellow]")
392
+ table.add_row("Status:", f"[blue]{node.status}[/blue]")
393
+ wip_color = "red" if status["wip_count"] >= status["wip_limit"] else "green"
394
+ table.add_row(
395
+ "WIP:",
396
+ f"[{wip_color}]{status['wip_count']}/{status['wip_limit']}[/{wip_color}]",
397
+ )
398
+
399
+ # Return table object directly - TextFormatter will print it properly
400
+ return CommandResult(
401
+ data=table,
402
+ json_data=node_to_dict(node),
403
+ )
404
+
405
+
406
+ class FeatureCompleteCommand(BaseCommand):
407
+ """Mark feature as completed."""
408
+
409
+ def __init__(self, *, feature_id: str, collection: str) -> None:
410
+ super().__init__()
411
+ self.feature_id = feature_id
412
+ self.collection = collection
413
+
414
+ @classmethod
415
+ def from_args(cls, args: argparse.Namespace) -> FeatureCompleteCommand:
416
+ return cls(feature_id=args.id, collection=args.collection)
417
+
418
+ def execute(self) -> CommandResult:
419
+ """Mark feature as completed."""
420
+ from htmlgraph.converter import node_to_dict
421
+
422
+ sdk = self.get_sdk()
423
+ collection = getattr(sdk, self.collection, None)
424
+ self.require_collection(collection, self.collection)
425
+ assert collection is not None # Type narrowing for mypy
426
+
427
+ node = collection.complete(self.feature_id)
428
+ self.require_node(node, "feature", self.feature_id)
429
+
430
+ # Create Rich panel for output
431
+ panel = Panel(
432
+ f"[bold green]✓ Completed[/bold green]\n"
433
+ f"[cyan]{node.id}[/cyan]\n"
434
+ f"[yellow]{node.title}[/yellow]",
435
+ border_style="green",
436
+ )
437
+
438
+ # Return panel object directly - TextFormatter will print it properly
439
+ return CommandResult(
440
+ data=panel,
441
+ json_data=node_to_dict(node),
442
+ )
443
+
444
+
445
+ class FeatureClaimCommand(BaseCommand):
446
+ """Claim a feature."""
447
+
448
+ def __init__(self, *, feature_id: str, collection: str) -> None:
449
+ super().__init__()
450
+ self.feature_id = feature_id
451
+ self.collection = collection
452
+
453
+ @classmethod
454
+ def from_args(cls, args: argparse.Namespace) -> FeatureClaimCommand:
455
+ return cls(feature_id=args.id, collection=args.collection)
456
+
457
+ def execute(self) -> CommandResult:
458
+ """Claim a feature."""
459
+ from htmlgraph.converter import node_to_dict
460
+
461
+ sdk = self.get_sdk()
462
+ collection = getattr(sdk, self.collection, None)
463
+ self.require_collection(collection, self.collection)
464
+ assert collection is not None # Type narrowing for mypy
465
+
466
+ try:
467
+ node = collection.claim(self.feature_id)
468
+ except ValueError as e:
469
+ raise CommandError(str(e))
470
+
471
+ self.require_node(node, "feature", self.feature_id)
472
+
473
+ from htmlgraph.cli.base import TextOutputBuilder
474
+
475
+ output = TextOutputBuilder()
476
+ output.add_success(f"Claimed: {node.id}")
477
+ output.add_field("Agent", node.agent_assigned)
478
+ output.add_field("Session", node.claimed_by_session)
479
+
480
+ return CommandResult(
481
+ data=node_to_dict(node),
482
+ text=output.build(),
483
+ json_data=node_to_dict(node),
484
+ )
485
+
486
+
487
+ class FeatureReleaseCommand(BaseCommand):
488
+ """Release a feature."""
489
+
490
+ def __init__(self, *, feature_id: str, collection: str) -> None:
491
+ super().__init__()
492
+ self.feature_id = feature_id
493
+ self.collection = collection
494
+
495
+ @classmethod
496
+ def from_args(cls, args: argparse.Namespace) -> FeatureReleaseCommand:
497
+ return cls(feature_id=args.id, collection=args.collection)
498
+
499
+ def execute(self) -> CommandResult:
500
+ """Release a feature."""
501
+ from htmlgraph.converter import node_to_dict
502
+
503
+ sdk = self.get_sdk()
504
+ collection = getattr(sdk, self.collection, None)
505
+ self.require_collection(collection, self.collection)
506
+ assert collection is not None # Type narrowing for mypy
507
+
508
+ try:
509
+ node = collection.release(self.feature_id)
510
+ except ValueError as e:
511
+ raise CommandError(str(e))
512
+
513
+ self.require_node(node, "feature", self.feature_id)
514
+
515
+ from htmlgraph.cli.base import TextOutputBuilder
516
+
517
+ output = TextOutputBuilder()
518
+ output.add_success(f"Released: {node.id}")
519
+
520
+ return CommandResult(
521
+ data=node_to_dict(node),
522
+ text=output.build(),
523
+ json_data=node_to_dict(node),
524
+ )
525
+
526
+
527
+ class FeaturePrimaryCommand(BaseCommand):
528
+ """Set primary feature."""
529
+
530
+ def __init__(self, *, feature_id: str, collection: str) -> None:
531
+ super().__init__()
532
+ self.feature_id = feature_id
533
+ self.collection = collection
534
+
535
+ @classmethod
536
+ def from_args(cls, args: argparse.Namespace) -> FeaturePrimaryCommand:
537
+ return cls(feature_id=args.id, collection=args.collection)
538
+
539
+ def execute(self) -> CommandResult:
540
+ """Set primary feature."""
541
+ from htmlgraph.converter import node_to_dict
542
+
543
+ sdk = self.get_sdk()
544
+
545
+ # Only FeatureCollection has set_primary currently
546
+ if self.collection == "features":
547
+ node = sdk.features.set_primary(self.feature_id)
548
+ else:
549
+ # Fallback to direct session manager
550
+ node = sdk.session_manager.set_primary_feature(
551
+ self.feature_id, collection=self.collection, agent=self.agent
552
+ )
553
+
554
+ self.require_node(node, "feature", self.feature_id)
555
+ assert node is not None # Type narrowing for mypy
556
+
557
+ from htmlgraph.cli.base import TextOutputBuilder
558
+
559
+ output = TextOutputBuilder()
560
+ output.add_success(f"Primary feature set: {node.id}")
561
+ output.add_field("Title", node.title)
562
+
563
+ return CommandResult(
564
+ data=node_to_dict(node),
565
+ text=output.build(),
566
+ json_data=node_to_dict(node),
567
+ )