diff --git a/.gitignore b/.gitignore index 635baeb..71b732f 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ htmlcov/ # Discussion files created during testing test_*.md + +# Generated diagrams (keep examples/diagrams/ for documentation) +diagrams/ diff --git a/CLAUDE.md b/CLAUDE.md index d5dc175..0896582 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,12 @@ pytest --cov=discussions # Test SmartTools directly (Unix philosophy - test tools independently) cat examples/brainstorm_notification_system.md | discussion-parser | jq . cat examples/brainstorm_notification_system.md | discussion-parser | discussion-vote-counter + +# Find orphaned diagrams (not referenced by any discussion) +discussions cleanup + +# Delete orphaned diagrams with confirmation +discussions cleanup --delete ``` ## Architecture diff --git a/README.md b/README.md index 9cc5b2b..d149753 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,32 @@ discussions ui --tui ## Installation ```bash -# Clone and install +# Clone and install (SmartTools installed automatically from git) git clone https://gitea.brrd.tech/rob/orchestrated-discussions.git cd orchestrated-discussions pip install -e ".[dev]" -# For TUI support only +# With GUI support (includes artifact-editor for diagram creation) +pip install -e ".[gui]" +``` + +### From local checkouts (for development) + +```bash +# Clone all projects +git clone https://gitea.brrd.tech/rob/SmartTools.git ~/PycharmProjects/SmartTools +git clone https://gitea.brrd.tech/rob/artifact-editor.git ~/PycharmProjects/artifact-editor +git clone https://gitea.brrd.tech/rob/orchestrated-discussions.git ~/PycharmProjects/orchestrated-discussions + +# Install in order (editable mode) +pip install -e ~/PycharmProjects/SmartTools +pip install -e ~/PycharmProjects/artifact-editor +pip install -e "~/PycharmProjects/orchestrated-discussions[dev]" +``` + +### Minimal install (TUI only, no artifact editor) + +```bash pip install -e ".[tui]" ``` @@ -77,7 +97,7 @@ docker-compose run --rm shell - Python 3.10+ - [SmartTools](https://gitea.brrd.tech/rob/SmartTools) (installed automatically) -- At least one AI CLI tool (Claude, Codex, etc.) +- At least one AI provider configured in SmartTools (Claude, Codex, Gemini, etc.) ## How It Works @@ -123,6 +143,7 @@ User/CLI/UI | `discussions comment ` | Add a human comment | | `discussions participants` | List available participant SmartTools | | `discussions advance --phase ` | Advance to a specific phase | +| `discussions cleanup [directory]` | Find orphaned diagrams not referenced by any discussion | | `discussions ui [directory]` | Launch interactive UI | ### Examples @@ -145,6 +166,12 @@ discussions comment my-feature.md "I agree with the concerns" --vote changes # Check consensus status discussions status my-feature.md + +# Find orphaned diagrams (not referenced by any discussion) +discussions cleanup + +# Delete orphaned diagrams (with confirmation) +discussions cleanup --delete ``` ## Templates diff --git a/pyproject.toml b/pyproject.toml index 4be9ac4..c9b19b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,17 +28,22 @@ classifiers = [ ] dependencies = [ "PyYAML>=6.0", - "smarttools>=0.1.0", + "smarttools @ git+https://gitea.brrd.tech/rob/SmartTools.git", ] [project.optional-dependencies] tui = [ "urwid>=2.1.0", ] +gui = [ + "dearpygui>=2.0.0", + "artifact-editor @ git+https://gitea.brrd.tech/rob/artifact-editor.git", +] dev = [ "pytest>=7.0", "pytest-cov>=4.0", "urwid>=2.1.0", + "dearpygui>=2.0.0", ] [project.scripts] diff --git a/src/discussions/cli.py b/src/discussions/cli.py index 7b4d9c3..9b14172 100644 --- a/src/discussions/cli.py +++ b/src/discussions/cli.py @@ -244,6 +244,114 @@ def cmd_advance(args) -> int: return 0 +def cmd_cleanup(args) -> int: + """Find and optionally delete orphaned diagrams.""" + from .discussion import Discussion + from .markers import extract_diagrams + import re + + directory = Path(args.directory) if args.directory else Path.cwd() + + if not directory.exists(): + print(f"Error: Directory not found: {directory}") + return 1 + + # Find all discussion files + discussions = list(directory.glob("**/*.discussion.md")) + + # Collect all referenced diagrams from all discussions + referenced = set() + for disc_path in discussions: + try: + d = Discussion.load(disc_path) + for comment in d.comments: + diagrams = extract_diagrams(comment.body) + for diagram in diagrams: + # Resolve relative to discussion file + abs_path = (disc_path.parent / diagram.path).resolve() + referenced.add(abs_path) + except Exception as e: + print(f"Warning: Could not parse {disc_path}: {e}") + + # Find all diagram directories + diagram_dirs = set() + + # Check for diagrams folders alongside discussions + for disc_path in discussions: + diagrams_dir = disc_path.parent / "diagrams" + if diagrams_dir.exists(): + diagram_dirs.add(diagrams_dir) + + # Also check top-level diagrams folder + top_diagrams = directory / "diagrams" + if top_diagrams.exists(): + diagram_dirs.add(top_diagrams) + + # Scan for any diagrams/ folders recursively (catches orphaned folders with no discussions) + for diagrams_dir in directory.glob("**/diagrams"): + if diagrams_dir.is_dir(): + diagram_dirs.add(diagrams_dir) + + if not diagram_dirs: + print("No diagram directories found") + return 0 + + # Find all diagram files + diagram_extensions = {'.puml', '.plantuml', '.mmd', '.mermaid', '.svg', '.png', '.scad'} + all_diagrams = set() + for diagrams_dir in diagram_dirs: + for ext in diagram_extensions: + for f in diagrams_dir.glob(f"*{ext}"): + all_diagrams.add(f.resolve()) + + # Find orphans + orphans = all_diagrams - referenced + + if not orphans: + print(f"No orphaned diagrams found ({len(all_diagrams)} diagrams, all referenced)") + return 0 + + print(f"Found {len(orphans)} orphaned diagram(s) out of {len(all_diagrams)} total:\n") + + # Group by directory for display + by_dir = {} + for orphan in sorted(orphans): + parent = orphan.parent + if parent not in by_dir: + by_dir[parent] = [] + by_dir[parent].append(orphan) + + for parent, files in sorted(by_dir.items()): + try: + rel_parent = parent.relative_to(directory) + except ValueError: + rel_parent = parent + print(f" {rel_parent}/") + for f in files: + size = f.stat().st_size + size_str = f"{size:,} bytes" if size < 1024 else f"{size/1024:.1f} KB" + print(f" {f.name} ({size_str})") + + if args.delete: + print() + confirm = input(f"Delete {len(orphans)} orphaned file(s)? [y/N] ") + if confirm.lower() == 'y': + deleted = 0 + for orphan in orphans: + try: + orphan.unlink() + deleted += 1 + except Exception as e: + print(f" Error deleting {orphan.name}: {e}") + print(f"Deleted {deleted} file(s)") + else: + print("Cancelled") + else: + print(f"\nRun with --delete to remove these files") + + return 0 + + def cmd_ui(args) -> int: """Launch the interactive UI (GUI by default, TUI with --tui).""" # Determine if path is a file or directory @@ -340,6 +448,12 @@ def main(argv: list[str] = None) -> int: p_advance.add_argument("--phase", help="Target phase ID") p_advance.set_defaults(func=cmd_advance) + # 'cleanup' command + p_cleanup = subparsers.add_parser("cleanup", help="Find orphaned diagrams") + p_cleanup.add_argument("directory", nargs="?", help="Directory to scan (default: current)") + p_cleanup.add_argument("--delete", "-d", action="store_true", help="Delete orphaned files (with confirmation)") + p_cleanup.set_defaults(func=cmd_cleanup) + # 'ui' command p_ui = subparsers.add_parser("ui", help="Launch interactive UI") p_ui.add_argument("path", nargs="?", help="Discussion file (.md) or directory to browse") diff --git a/src/discussions/ui/gui.py b/src/discussions/ui/gui.py index ff85399..0ac4843 100644 --- a/src/discussions/ui/gui.py +++ b/src/discussions/ui/gui.py @@ -534,6 +534,39 @@ class DiscussionGUI: self.discussions_dir = Path(folder_path) self._refresh_discussions() + def _browse_for_folder(self, target_input_tag: str): + """Show folder browser and update a target input field with selected path. + + Args: + target_input_tag: Tag of the input_text widget to update with selection + """ + # Create a one-time folder dialog for this specific purpose + dialog_tag = f"browse_folder_{target_input_tag}" + + def on_select(sender, app_data): + folder_path = app_data.get("file_path_name", "") + if folder_path and dpg.does_item_exist(target_input_tag): + dpg.set_value(target_input_tag, folder_path) + dpg.delete_item(dialog_tag) + + def on_cancel(sender, app_data): + dpg.delete_item(dialog_tag) + + # Get current value to start from + current = dpg.get_value(target_input_tag) if dpg.does_item_exist(target_input_tag) else str(Path.cwd()) + + with dpg.file_dialog( + directory_selector=True, + show=True, + callback=on_select, + cancel_callback=on_cancel, + tag=dialog_tag, + default_path=current, + width=700, + height=400 + ): + pass + def _refresh_discussions(self): """Refresh the list of discussions.""" self.discussions_list = [] @@ -869,6 +902,7 @@ class DiscussionGUI: """ cmd, env = self._get_artifact_editor_cmd() if not cmd: + self._add_output("Error: Artifact editor not found") return None cmd.extend(["--output", str(file_path)]) @@ -881,6 +915,14 @@ class DiscussionGUI: env=env, ) + # Log any stderr output for debugging + if result.stderr: + self._add_output(f"Artifact editor: {result.stderr[:200]}") + + # Check return code + if result.returncode != 0: + self._add_output(f"Artifact editor exited with code {result.returncode}") + # Parse ARTIFACT_SAVED:path from stdout for line in result.stdout.splitlines(): if line.startswith("ARTIFACT_SAVED:"): @@ -888,6 +930,7 @@ class DiscussionGUI: return None except Exception as e: + self._add_output(f"Error launching artifact editor: {e}") return None def _create_new_artifact(self): @@ -900,6 +943,19 @@ class DiscussionGUI: self._show_error("No discussion loaded") return + # Check if artifact editor is available before proceeding + cmd, env = self._get_artifact_editor_cmd() + if not cmd: + self._show_error( + "Artifact Editor not installed.\n\n" + "Install from ~/PycharmProjects/artifact-editor:\n" + " cd ~/PycharmProjects/artifact-editor\n" + " ./install.sh\n\n" + "Or install with pip:\n" + " pip install -e ~/PycharmProjects/artifact-editor" + ) + return + # Create diagrams directory if needed diagrams_dir = self.current_discussion.path.parent / "diagrams" diagrams_dir.mkdir(exist_ok=True) @@ -1712,6 +1768,20 @@ class DiscussionGUI: to the comment only after we know the actual saved path (user may change format). """ if not self.current_discussion: + self._show_error("No discussion loaded. Open a discussion first.") + return + + # Check if artifact editor is available before proceeding + cmd, env = self._get_artifact_editor_cmd() + if not cmd: + self._show_error( + "Artifact Editor not installed.\n\n" + "Install from ~/PycharmProjects/artifact-editor:\n" + " cd ~/PycharmProjects/artifact-editor\n" + " ./install.sh\n\n" + "Or install with pip:\n" + " pip install -e ~/PycharmProjects/artifact-editor" + ) return # Create diagrams directory if needed @@ -2046,8 +2116,14 @@ class DiscussionGUI: slug = title.lower().replace(" ", "-") slug = "".join(c for c in slug if c.isalnum() or c == "-") - # Determine output directory - output_dir = self.discussions_dir if self.discussions_dir else Path.cwd() + # Get output directory from the location field + output_dir = Path(dpg.get_value("new_disc_location")) + if not output_dir.exists(): + try: + output_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + self._show_error(f"Cannot create directory: {e}") + return output_path = output_dir / f"{slug}.discussion.md" # Check if file exists @@ -2076,12 +2152,29 @@ class DiscussionGUI: except Exception as e: self._show_error(f"Failed to create discussion: {e}") + # Determine initial output directory + if self.current_discussion: + initial_dir = self.current_discussion.path.parent + else: + initial_dir = self.discussions_dir if self.discussions_dir else Path.cwd() + with dpg.window(label="New Discussion", tag=window_tag, - width=550, height=520, pos=[400, 150], no_collapse=True): + width=550, height=550, pos=[400, 150], no_collapse=True): dpg.add_text("Title:", color=(150, 200, 255)) dpg.add_input_text(tag="new_disc_title", width=-1, hint="Enter discussion title") + dpg.add_spacer(height=5) + dpg.add_text("Location:", color=(150, 200, 255)) + with dpg.group(horizontal=True): + dpg.add_input_text( + tag="new_disc_location", + default_value=str(initial_dir), + width=-80, + readonly=False + ) + dpg.add_button(label="Browse", callback=lambda: self._browse_for_folder("new_disc_location")) + dpg.add_spacer(height=10) dpg.add_text("Template:", color=(150, 200, 255)) template_items = templates + ["+ Create New Template..."]