hyperresearch 0.2.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.
Files changed (76) hide show
  1. hyperresearch/__init__.py +3 -0
  2. hyperresearch/__main__.py +5 -0
  3. hyperresearch/cli/__init__.py +117 -0
  4. hyperresearch/cli/_output.py +131 -0
  5. hyperresearch/cli/assets.py +114 -0
  6. hyperresearch/cli/batch.py +301 -0
  7. hyperresearch/cli/config_cmd.py +143 -0
  8. hyperresearch/cli/dedup.py +138 -0
  9. hyperresearch/cli/export.py +117 -0
  10. hyperresearch/cli/fetch.py +294 -0
  11. hyperresearch/cli/fetch_batch.py +231 -0
  12. hyperresearch/cli/git_cmd.py +161 -0
  13. hyperresearch/cli/graph.py +286 -0
  14. hyperresearch/cli/import_cmd.py +85 -0
  15. hyperresearch/cli/index.py +77 -0
  16. hyperresearch/cli/install.py +160 -0
  17. hyperresearch/cli/link.py +81 -0
  18. hyperresearch/cli/lint.py +212 -0
  19. hyperresearch/cli/main.py +170 -0
  20. hyperresearch/cli/mcp_cmd.py +19 -0
  21. hyperresearch/cli/note.py +481 -0
  22. hyperresearch/cli/repair.py +254 -0
  23. hyperresearch/cli/research.py +313 -0
  24. hyperresearch/cli/search.py +125 -0
  25. hyperresearch/cli/serve.py +26 -0
  26. hyperresearch/cli/setup.py +356 -0
  27. hyperresearch/cli/sources.py +97 -0
  28. hyperresearch/cli/tag.py +121 -0
  29. hyperresearch/cli/template.py +58 -0
  30. hyperresearch/cli/topic.py +163 -0
  31. hyperresearch/cli/watch.py +113 -0
  32. hyperresearch/core/__init__.py +1 -0
  33. hyperresearch/core/agent_docs.py +331 -0
  34. hyperresearch/core/config.py +113 -0
  35. hyperresearch/core/db.py +162 -0
  36. hyperresearch/core/enrich.py +110 -0
  37. hyperresearch/core/fetcher.py +127 -0
  38. hyperresearch/core/frontmatter.py +55 -0
  39. hyperresearch/core/hooks.py +358 -0
  40. hyperresearch/core/linker.py +138 -0
  41. hyperresearch/core/migrations.py +80 -0
  42. hyperresearch/core/note.py +117 -0
  43. hyperresearch/core/patterns.py +12 -0
  44. hyperresearch/core/similarity.py +81 -0
  45. hyperresearch/core/sync.py +301 -0
  46. hyperresearch/core/templates.py +209 -0
  47. hyperresearch/core/vault.py +153 -0
  48. hyperresearch/export/__init__.py +1 -0
  49. hyperresearch/graph/__init__.py +1 -0
  50. hyperresearch/indexgen/__init__.py +1 -0
  51. hyperresearch/indexgen/generator.py +256 -0
  52. hyperresearch/mcp/__init__.py +1 -0
  53. hyperresearch/mcp/server.py +404 -0
  54. hyperresearch/models/__init__.py +1 -0
  55. hyperresearch/models/graph.py +21 -0
  56. hyperresearch/models/note.py +89 -0
  57. hyperresearch/models/output.py +28 -0
  58. hyperresearch/models/search.py +24 -0
  59. hyperresearch/py.typed +0 -0
  60. hyperresearch/search/__init__.py +1 -0
  61. hyperresearch/search/filters.py +95 -0
  62. hyperresearch/search/fts.py +139 -0
  63. hyperresearch/serve/__init__.py +1 -0
  64. hyperresearch/serve/renderer.py +124 -0
  65. hyperresearch/serve/server.py +588 -0
  66. hyperresearch/skills/__init__.py +1 -0
  67. hyperresearch/skills/research.md +172 -0
  68. hyperresearch/web/__init__.py +1 -0
  69. hyperresearch/web/base.py +101 -0
  70. hyperresearch/web/builtin.py +111 -0
  71. hyperresearch/web/crawl4ai_provider.py +253 -0
  72. hyperresearch-0.2.0.dist-info/METADATA +172 -0
  73. hyperresearch-0.2.0.dist-info/RECORD +76 -0
  74. hyperresearch-0.2.0.dist-info/WHEEL +4 -0
  75. hyperresearch-0.2.0.dist-info/entry_points.txt +3 -0
  76. hyperresearch-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """Hyperresearch — agent-driven research knowledge base."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m hyperresearch`."""
2
+
3
+ from hyperresearch.cli import app # encoding fix runs in cli/__init__.py
4
+
5
+ app()
@@ -0,0 +1,117 @@
1
+ """Hyperresearch CLI — main typer application."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ # Fix Windows console encoding — crawl4ai's rich logger uses Unicode chars that
7
+ # crash on Windows cp1252 consoles. Reconfigure streams to UTF-8 at startup.
8
+ if sys.platform == "win32":
9
+ os.environ["PYTHONIOENCODING"] = "utf-8"
10
+ os.environ["PYTHONUTF8"] = "1"
11
+ for stream_name in ("stdout", "stderr"):
12
+ stream = getattr(sys, stream_name)
13
+ if hasattr(stream, "reconfigure"):
14
+ try:
15
+ stream.reconfigure(encoding="utf-8", errors="replace")
16
+ except Exception:
17
+ pass
18
+
19
+ import typer
20
+
21
+ from hyperresearch import __version__
22
+
23
+
24
+ def _version_callback(value: bool) -> None:
25
+ if value:
26
+ typer.echo(f"hyperresearch v{__version__}")
27
+ raise typer.Exit()
28
+
29
+
30
+ app = typer.Typer(
31
+ name="hyperresearch",
32
+ help="Agent-driven research knowledge base.",
33
+ no_args_is_help=True,
34
+ rich_markup_mode="rich",
35
+ )
36
+
37
+
38
+ @app.callback()
39
+ def main(
40
+ version: bool = typer.Option(False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version"),
41
+ ) -> None:
42
+ pass
43
+
44
+
45
+ # Root-level commands
46
+ from hyperresearch.cli.dedup import dedup as _dedup
47
+ from hyperresearch.cli.fetch import fetch as _fetch
48
+ from hyperresearch.cli.import_cmd import import_vault as _import
49
+ from hyperresearch.cli.install import install as _install
50
+ from hyperresearch.cli.main import init as _init
51
+ from hyperresearch.cli.main import status as _status
52
+ from hyperresearch.cli.main import sync as _sync
53
+ from hyperresearch.cli.mcp_cmd import mcp as _mcp
54
+ from hyperresearch.cli.note import note_show as _show
55
+ from hyperresearch.cli.repair import repair as _repair
56
+ from hyperresearch.cli.research import research as _research
57
+ from hyperresearch.cli.search import search as _search
58
+ from hyperresearch.cli.serve import serve as _serve
59
+ from hyperresearch.cli.tag import tag_list as _tags
60
+ from hyperresearch.cli.watch import watch as _watch
61
+
62
+ app.command("install")(_install)
63
+
64
+ from hyperresearch.cli.setup import setup as _setup
65
+
66
+ app.command("setup")(_setup)
67
+ app.command("init")(_init)
68
+ app.command("status")(_status)
69
+ app.command("sync")(_sync)
70
+ app.command("search")(_search)
71
+ app.command("fetch")(_fetch)
72
+
73
+ from hyperresearch.cli.fetch_batch import fetch_batch as _fetch_batch
74
+
75
+ app.command("fetch-batch")(_fetch_batch)
76
+ app.command("research")(_research)
77
+ app.command("tags")(_tags)
78
+ app.command("show", hidden=True)(_show)
79
+ app.command("dedup")(_dedup)
80
+ app.command("import")(_import)
81
+ app.command("repair")(_repair)
82
+ app.command("watch")(_watch)
83
+ app.command("serve")(_serve)
84
+ app.command("mcp")(_mcp)
85
+
86
+ # Sub-apps
87
+ from hyperresearch.cli.batch import app as batch_app
88
+ from hyperresearch.cli.config_cmd import app as config_app
89
+ from hyperresearch.cli.export import app as export_app
90
+ from hyperresearch.cli.git_cmd import app as git_app
91
+ from hyperresearch.cli.graph import app as graph_app
92
+ from hyperresearch.cli.index import app as index_app
93
+ from hyperresearch.cli.lint import app as lint_app
94
+ from hyperresearch.cli.note import app as note_app
95
+ from hyperresearch.cli.tag import app as tag_app
96
+ from hyperresearch.cli.template import app as template_app
97
+ from hyperresearch.cli.topic import app as topic_app
98
+
99
+ app.add_typer(note_app, name="note", help="Note CRUD operations.")
100
+ app.add_typer(graph_app, name="graph", help="Knowledge graph and link analysis.")
101
+ app.add_typer(index_app, name="index", help="Auto-generated index pages.")
102
+ app.add_typer(lint_app, name="lint", help="Health-check the vault.")
103
+ app.add_typer(export_app, name="export", help="Export notes.")
104
+ app.add_typer(config_app, name="config", help="Configuration.")
105
+ app.add_typer(topic_app, name="topic", help="Topic hierarchy.")
106
+ app.add_typer(batch_app, name="batch", help="Bulk operations.")
107
+ app.add_typer(template_app, name="template", help="Note templates.")
108
+ app.add_typer(git_app, name="git", help="Git integration.")
109
+ app.add_typer(tag_app, name="tag", help="Tag management.")
110
+
111
+ from hyperresearch.cli.assets import app as assets_app
112
+ from hyperresearch.cli.link import app as link_app
113
+ from hyperresearch.cli.sources import app as sources_app
114
+
115
+ app.add_typer(sources_app, name="sources", help="Fetched web sources.")
116
+ app.add_typer(assets_app, name="assets", help="Downloaded images, screenshots, and media.")
117
+ app.add_typer(link_app, name="link", help="Auto-discover and insert wiki-links.")
@@ -0,0 +1,131 @@
1
+ """Dual-mode output: rich terminal for humans, JSON for LLM agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+ from rich.tree import Tree
12
+
13
+ from hyperresearch.models.output import Envelope
14
+
15
+ console = Console()
16
+ err_console = Console(stderr=True)
17
+
18
+
19
+ def output(data: Any, *, json_mode: bool = False, **kwargs: Any) -> None:
20
+ """Output data in either JSON or rich terminal format."""
21
+ if json_mode:
22
+ _output_json(data)
23
+ else:
24
+ _output_rich(data, **kwargs)
25
+
26
+
27
+ def _output_json(data: Any) -> None:
28
+ """Output as JSON. Uses sys.stdout with UTF-8 to avoid Windows encoding issues."""
29
+ import sys
30
+
31
+ if isinstance(data, Envelope) or hasattr(data, "model_dump_json"):
32
+ text = data.model_dump_json(indent=2, exclude_none=True)
33
+ else:
34
+ text = json.dumps(data, indent=2, default=str)
35
+
36
+ sys.stdout.buffer.write(text.encode("utf-8"))
37
+ sys.stdout.buffer.write(b"\n")
38
+ sys.stdout.buffer.flush()
39
+
40
+
41
+ def _output_rich(data: Any, **kwargs: Any) -> None:
42
+ """Output in rich terminal format."""
43
+ if isinstance(data, Envelope):
44
+ if not data.ok:
45
+ err_console.print(f"[red bold]Error:[/] {data.error}")
46
+ raise typer.Exit(1)
47
+ _output_rich(data.data, **kwargs)
48
+ return
49
+
50
+ if isinstance(data, dict):
51
+ _print_dict(data, **kwargs)
52
+ elif isinstance(data, list):
53
+ _print_list(data, **kwargs)
54
+ elif isinstance(data, str):
55
+ console.print(data)
56
+ else:
57
+ console.print(str(data))
58
+
59
+
60
+ def _print_dict(data: dict, **kwargs: Any) -> None:
61
+ """Pretty-print a dict."""
62
+ for key, value in data.items():
63
+ if isinstance(value, dict):
64
+ console.print(f"[bold]{key}:[/]")
65
+ _print_dict(value)
66
+ elif isinstance(value, list) and value and isinstance(value[0], dict):
67
+ console.print(f"\n[bold]{key}:[/]")
68
+ _print_list(value)
69
+ else:
70
+ console.print(f" [dim]{key}:[/] {value}")
71
+
72
+
73
+ def _print_list(data: list, **kwargs: Any) -> None:
74
+ """Pretty-print a list of items, as a table if they're dicts."""
75
+ if not data:
76
+ console.print(" [dim](none)[/]")
77
+ return
78
+
79
+ if isinstance(data[0], dict):
80
+ table = Table(show_header=True, header_style="bold")
81
+ cols = list(data[0].keys())
82
+ for col in cols:
83
+ table.add_column(col)
84
+ for item in data:
85
+ table.add_row(*(str(item.get(c, "")) for c in cols))
86
+ console.print(table)
87
+ else:
88
+ for item in data:
89
+ console.print(f" - {item}")
90
+
91
+
92
+ def print_note_summary(notes: list[dict], title: str = "Notes") -> None:
93
+ """Print a table of notes."""
94
+ table = Table(title=title, show_header=True, header_style="bold")
95
+ table.add_column("ID", style="cyan", no_wrap=True)
96
+ table.add_column("Title", style="white")
97
+ table.add_column("Status", style="yellow")
98
+ table.add_column("Tags", style="green")
99
+ table.add_column("Words", justify="right", style="dim")
100
+ for n in notes:
101
+ tags = ", ".join(n.get("tags", []))
102
+ table.add_row(
103
+ n.get("id", ""),
104
+ n.get("title", ""),
105
+ n.get("status", ""),
106
+ tags,
107
+ str(n.get("word_count", "")),
108
+ )
109
+ console.print(table)
110
+
111
+
112
+ def print_vault_status(data: dict) -> None:
113
+ """Print vault status in a nice tree format."""
114
+ tree = Tree(f"[bold]{data.get('vault_name', 'Vault')}[/]")
115
+ notes = tree.add("[bold]Notes[/]")
116
+ nd = data.get("notes", {})
117
+ notes.add(f"Total: {nd.get('total', 0)}")
118
+ for status, count in nd.get("by_status", {}).items():
119
+ notes.add(f"{status}: {count}")
120
+
121
+ tags = tree.add("[bold]Tags[/]")
122
+ tags.add(f"Unique: {data.get('tags', {}).get('total_unique', 0)}")
123
+
124
+ graph = tree.add("[bold]Graph[/]")
125
+ gd = data.get("graph", {})
126
+ graph.add(f"Links: {gd.get('total_links', 0)}")
127
+ graph.add(f"Broken: {gd.get('broken_links', 0)}")
128
+ graph.add(f"Orphans: {gd.get('orphan_notes', 0)}")
129
+
130
+ tree.add(f"[dim]Words: {data.get('total_words', 0):,}[/]")
131
+ console.print(tree)
@@ -0,0 +1,114 @@
1
+ """Assets CLI — list and view downloaded images, screenshots, and other media."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from hyperresearch.cli._output import console, output
8
+ from hyperresearch.models.output import error, success
9
+
10
+ app = typer.Typer()
11
+
12
+
13
+ @app.command("list")
14
+ def assets_list(
15
+ note_id: str | None = typer.Option(None, "--note", "-n", help="Filter by note ID"),
16
+ asset_type: str | None = typer.Option(None, "--type", "-t", help="Filter by type: image|screenshot|pdf"),
17
+ json_output: bool = typer.Option(False, "--json", "-j", help="JSON output"),
18
+ ) -> None:
19
+ """List downloaded assets (images, screenshots, PDFs)."""
20
+ from hyperresearch.core.vault import Vault, VaultError
21
+
22
+ try:
23
+ vault = Vault.discover()
24
+ except VaultError as e:
25
+ if json_output:
26
+ output(error(str(e), "NO_VAULT"), json_mode=True)
27
+ else:
28
+ console.print(f"[red]Error:[/] {e}")
29
+ raise typer.Exit(1)
30
+
31
+ vault.auto_sync()
32
+ conn = vault.db
33
+
34
+ query = "SELECT * FROM assets WHERE 1=1"
35
+ params: list = []
36
+ if note_id:
37
+ query += " AND note_id = ?"
38
+ params.append(note_id)
39
+ if asset_type:
40
+ query += " AND type = ?"
41
+ params.append(asset_type)
42
+ query += " ORDER BY created_at DESC"
43
+
44
+ rows = conn.execute(query, params).fetchall()
45
+ assets = [
46
+ {
47
+ "id": row["id"],
48
+ "note_id": row["note_id"],
49
+ "type": row["type"],
50
+ "filename": row["filename"],
51
+ "url": row["url"],
52
+ "alt_text": row["alt_text"],
53
+ "content_type": row["content_type"],
54
+ "size_bytes": row["size_bytes"],
55
+ }
56
+ for row in rows
57
+ ]
58
+
59
+ if json_output:
60
+ output(success(assets, count=len(assets), vault=str(vault.root)), json_mode=True)
61
+ else:
62
+ if not assets:
63
+ console.print("[dim]No assets found.[/]")
64
+ return
65
+ for a in assets:
66
+ size = f"{a['size_bytes'] / 1024:.0f}KB" if a["size_bytes"] else "?"
67
+ alt = f" — {a['alt_text'][:60]}" if a["alt_text"] else ""
68
+ console.print(
69
+ f" [{a['type']}] {a['note_id']}: {a['filename']} ({size}){alt}"
70
+ )
71
+ console.print(f"\n[dim]{len(assets)} assets total[/]")
72
+
73
+
74
+ @app.command("path")
75
+ def asset_path(
76
+ note_id: str = typer.Argument(..., help="Note ID"),
77
+ asset_type: str = typer.Option("screenshot", "--type", "-t", help="Asset type: screenshot|image"),
78
+ json_output: bool = typer.Option(False, "--json", "-j", help="JSON output"),
79
+ ) -> None:
80
+ """Get the file path for a note's asset (for viewing with Read tool)."""
81
+ from hyperresearch.core.vault import Vault, VaultError
82
+
83
+ try:
84
+ vault = Vault.discover()
85
+ except VaultError as e:
86
+ if json_output:
87
+ output(error(str(e), "NO_VAULT"), json_mode=True)
88
+ else:
89
+ console.print(f"[red]Error:[/] {e}")
90
+ raise typer.Exit(1)
91
+
92
+ conn = vault.db
93
+ rows = conn.execute(
94
+ "SELECT filename, alt_text, size_bytes FROM assets WHERE note_id = ? AND type = ? ORDER BY id",
95
+ (note_id, asset_type),
96
+ ).fetchall()
97
+
98
+ if not rows:
99
+ if json_output:
100
+ output(error(f"No {asset_type} assets for note '{note_id}'", "NOT_FOUND"), json_mode=True)
101
+ else:
102
+ console.print(f"[yellow]No {asset_type} assets for note '{note_id}'[/]")
103
+ raise typer.Exit(1)
104
+
105
+ paths = [
106
+ {"path": row["filename"], "alt_text": row["alt_text"], "size_bytes": row["size_bytes"]}
107
+ for row in rows
108
+ ]
109
+
110
+ if json_output:
111
+ output(success(paths, count=len(paths), vault=str(vault.root)), json_mode=True)
112
+ else:
113
+ for p in paths:
114
+ console.print(p["path"])
@@ -0,0 +1,301 @@
1
+ """Batch operations — modify multiple notes at once."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from hyperresearch.cli._output import console, output
11
+ from hyperresearch.models.output import error, success
12
+
13
+ VALID_STATUSES = {"draft", "review", "evergreen", "stale", "deprecated", "archive"}
14
+
15
+
16
+ def _discover_vault(json_output: bool):
17
+ """Discover vault with proper error handling."""
18
+ from hyperresearch.core.vault import Vault, VaultError
19
+ try:
20
+ return Vault.discover()
21
+ except VaultError as e:
22
+ if json_output:
23
+ output(error(str(e), "NO_VAULT"), json_mode=True)
24
+ else:
25
+ console.print(f"[red]Error:[/] {e}")
26
+ raise typer.Exit(1)
27
+
28
+ app = typer.Typer()
29
+
30
+
31
+ def _get_matching_notes(vault, status=None, tag=None, parent=None, note_type=None) -> list[dict]:
32
+ """Find notes matching the given filters."""
33
+ clauses = ["n.type NOT IN ('index')"]
34
+ params: list = []
35
+
36
+ if status:
37
+ clauses.append("n.status = ?")
38
+ params.append(status)
39
+ if tag:
40
+ clauses.append("n.id IN (SELECT note_id FROM tags WHERE tag = ?)")
41
+ params.append(tag.lower())
42
+ if parent:
43
+ clauses.append("(n.parent = ? OR n.parent LIKE ?)")
44
+ params.append(parent)
45
+ params.append(parent + "/%")
46
+ if note_type:
47
+ clauses.append("n.type = ?")
48
+ params.append(note_type)
49
+
50
+ where = " AND ".join(clauses)
51
+ rows = vault.db.execute(
52
+ f"SELECT n.id, n.path, n.title FROM notes n WHERE {where} ORDER BY n.title",
53
+ params,
54
+ ).fetchall()
55
+ return [dict(r) for r in rows]
56
+
57
+
58
+ def _batch_update_files(vault, notes: list[dict], updates: dict) -> tuple[list[str], list[dict]]:
59
+ """Update frontmatter on multiple files. Returns (modified_ids, errors)."""
60
+ modified = []
61
+ errors = []
62
+ for n in notes:
63
+ try:
64
+ _update_file_frontmatter(vault.root, n["path"], updates)
65
+ modified.append(n["id"])
66
+ except Exception as e:
67
+ errors.append({"id": n["id"], "path": n["path"], "error": str(e)})
68
+ # Sync after all modifications
69
+ if modified:
70
+ from hyperresearch.core.sync import compute_sync_plan, execute_sync
71
+ plan = compute_sync_plan(vault, force=True)
72
+ execute_sync(vault, plan)
73
+ return modified, errors
74
+
75
+
76
+ def _update_file_frontmatter(vault_root: Path, rel_path: str, updates: dict) -> None:
77
+ """Read a note file, patch its frontmatter, write it back."""
78
+ from hyperresearch.core.frontmatter import parse_frontmatter, serialize_frontmatter
79
+
80
+ file_path = vault_root / rel_path
81
+ content = file_path.read_text(encoding="utf-8-sig")
82
+ meta, body = parse_frontmatter(content)
83
+
84
+ for key, value in updates.items():
85
+ if key == "add_tag":
86
+ if value.lower() not in meta.tags:
87
+ meta.tags.append(value.lower())
88
+ elif key == "remove_tag":
89
+ meta.tags = [t for t in meta.tags if t != value.lower()]
90
+ elif hasattr(meta, key):
91
+ setattr(meta, key, value)
92
+
93
+ meta.updated = datetime.now(UTC)
94
+ new_content = serialize_frontmatter(meta) + "\n" + body
95
+ file_path.write_text(new_content, encoding="utf-8")
96
+
97
+
98
+ # --- Tag operations ---
99
+
100
+ @app.command("tag-add")
101
+ def batch_tag_add(
102
+ tag: str = typer.Argument(..., help="Tag to add"),
103
+ status: str | None = typer.Option(None, "--status", "-s", help="Filter by status"),
104
+ filter_tag: str | None = typer.Option(None, "--tag", "-t", help="Filter by existing tag"),
105
+ parent: str | None = typer.Option(None, "--parent", "-p", help="Filter by parent topic"),
106
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes"),
107
+ json_output: bool = typer.Option(False, "--json", "-j", help="JSON output"),
108
+ ) -> None:
109
+ """Add a tag to matching notes."""
110
+ vault = _discover_vault(json_output)
111
+ vault.auto_sync()
112
+ notes = _get_matching_notes(vault, status=status, tag=filter_tag, parent=parent)
113
+
114
+ if dry_run:
115
+ data = {"action": "tag-add", "tag": tag, "would_modify": [n["id"] for n in notes]}
116
+ if json_output:
117
+ output(success(data, count=len(notes), vault=str(vault.root)), json_mode=True)
118
+ else:
119
+ console.print(f"[bold]Would add tag '{tag}' to {len(notes)} notes:[/]")
120
+ for n in notes:
121
+ console.print(f" [cyan]{n['id']}[/] — {n['title']}")
122
+ return
123
+
124
+ for n in notes:
125
+ _update_file_frontmatter(vault.root, n["path"], {"add_tag": tag})
126
+
127
+ from hyperresearch.core.sync import compute_sync_plan, execute_sync
128
+ plan = compute_sync_plan(vault, force=True)
129
+ execute_sync(vault, plan)
130
+
131
+ if json_output:
132
+ output(success({"action": "tag-add", "tag": tag, "modified": [n["id"] for n in notes]},
133
+ count=len(notes), vault=str(vault.root)), json_mode=True)
134
+ else:
135
+ console.print(f"[green]Added tag '{tag}' to {len(notes)} notes.[/]")
136
+
137
+
138
+ @app.command("tag-remove")
139
+ def batch_tag_remove(
140
+ tag: str = typer.Argument(..., help="Tag to remove"),
141
+ status: str | None = typer.Option(None, "--status", "-s", help="Filter by status"),
142
+ filter_tag: str | None = typer.Option(None, "--tag", "-t", help="Filter by existing tag"),
143
+ parent: str | None = typer.Option(None, "--parent", "-p", help="Filter by parent topic"),
144
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes"),
145
+ json_output: bool = typer.Option(False, "--json", "-j", help="JSON output"),
146
+ ) -> None:
147
+ """Remove a tag from matching notes."""
148
+ vault = _discover_vault(json_output)
149
+ vault.auto_sync()
150
+ notes = _get_matching_notes(vault, status=status, tag=filter_tag or tag, parent=parent)
151
+
152
+ if dry_run:
153
+ data = {"action": "tag-remove", "tag": tag, "would_modify": [n["id"] for n in notes]}
154
+ if json_output:
155
+ output(success(data, count=len(notes), vault=str(vault.root)), json_mode=True)
156
+ else:
157
+ console.print(f"[bold]Would remove tag '{tag}' from {len(notes)} notes:[/]")
158
+ for n in notes:
159
+ console.print(f" [cyan]{n['id']}[/] — {n['title']}")
160
+ return
161
+
162
+ for n in notes:
163
+ _update_file_frontmatter(vault.root, n["path"], {"remove_tag": tag})
164
+
165
+ from hyperresearch.core.sync import compute_sync_plan, execute_sync
166
+ plan = compute_sync_plan(vault, force=True)
167
+ execute_sync(vault, plan)
168
+
169
+ if json_output:
170
+ output(success({"action": "tag-remove", "tag": tag, "modified": [n["id"] for n in notes]},
171
+ count=len(notes), vault=str(vault.root)), json_mode=True)
172
+ else:
173
+ console.print(f"[green]Removed tag '{tag}' from {len(notes)} notes.[/]")
174
+
175
+
176
+ # --- Status operations ---
177
+
178
+ @app.command("set-status")
179
+ def batch_set_status(
180
+ new_status: str = typer.Argument(..., help="New status: draft|review|evergreen|stale|deprecated|archive"),
181
+ status: str | None = typer.Option(None, "--status", "-s", help="Filter by current status"),
182
+ tag: str | None = typer.Option(None, "--tag", "-t", help="Filter by tag"),
183
+ parent: str | None = typer.Option(None, "--parent", "-p", help="Filter by parent topic"),
184
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes"),
185
+ json_output: bool = typer.Option(False, "--json", "-j", help="JSON output"),
186
+ ) -> None:
187
+ """Set status on matching notes."""
188
+ if new_status not in VALID_STATUSES:
189
+ msg = f"Invalid status '{new_status}'. Valid: {', '.join(sorted(VALID_STATUSES))}"
190
+ if json_output:
191
+ output(error(msg, "INVALID_STATUS"), json_mode=True)
192
+ else:
193
+ console.print(f"[red]{msg}[/]")
194
+ raise typer.Exit(1)
195
+
196
+ vault = _discover_vault(json_output)
197
+ vault.auto_sync()
198
+ notes = _get_matching_notes(vault, status=status, tag=tag, parent=parent)
199
+
200
+ if dry_run:
201
+ if json_output:
202
+ output(success({"action": "set-status", "status": new_status,
203
+ "would_modify": [n["id"] for n in notes]},
204
+ count=len(notes), vault=str(vault.root)), json_mode=True)
205
+ else:
206
+ console.print(f"[bold]Would set status '{new_status}' on {len(notes)} notes.[/]")
207
+ return
208
+
209
+ for n in notes:
210
+ _update_file_frontmatter(vault.root, n["path"], {"status": new_status})
211
+
212
+ from hyperresearch.core.sync import compute_sync_plan, execute_sync
213
+ plan = compute_sync_plan(vault, force=True)
214
+ execute_sync(vault, plan)
215
+
216
+ if json_output:
217
+ output(success({"action": "set-status", "status": new_status,
218
+ "modified": [n["id"] for n in notes]},
219
+ count=len(notes), vault=str(vault.root)), json_mode=True)
220
+ else:
221
+ console.print(f"[green]Set status '{new_status}' on {len(notes)} notes.[/]")
222
+
223
+
224
+ # --- Deprecate ---
225
+
226
+ @app.command("deprecate")
227
+ def batch_deprecate(
228
+ status: str | None = typer.Option(None, "--status", "-s", help="Filter by status"),
229
+ tag: str | None = typer.Option(None, "--tag", "-t", help="Filter by tag"),
230
+ parent: str | None = typer.Option(None, "--parent", "-p", help="Filter by parent topic"),
231
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes"),
232
+ json_output: bool = typer.Option(False, "--json", "-j", help="JSON output"),
233
+ ) -> None:
234
+ """Mark matching notes as deprecated."""
235
+ vault = _discover_vault(json_output)
236
+ vault.auto_sync()
237
+ notes = _get_matching_notes(vault, status=status, tag=tag, parent=parent)
238
+
239
+ if dry_run:
240
+ if json_output:
241
+ output(success({"action": "deprecate",
242
+ "would_modify": [n["id"] for n in notes]},
243
+ count=len(notes), vault=str(vault.root)), json_mode=True)
244
+ else:
245
+ console.print(f"[bold]Would deprecate {len(notes)} notes.[/]")
246
+ return
247
+
248
+ updates = {"status": "deprecated", "deprecated": True}
249
+
250
+ for n in notes:
251
+ _update_file_frontmatter(vault.root, n["path"], updates)
252
+
253
+ from hyperresearch.core.sync import compute_sync_plan, execute_sync
254
+ plan = compute_sync_plan(vault, force=True)
255
+ execute_sync(vault, plan)
256
+
257
+ if json_output:
258
+ output(success({"action": "deprecate", "modified": [n["id"] for n in notes]},
259
+ count=len(notes), vault=str(vault.root)), json_mode=True)
260
+ else:
261
+ console.print(f"[green]Deprecated {len(notes)} notes.[/]")
262
+
263
+
264
+ # --- Parent ---
265
+
266
+ @app.command("set-parent")
267
+ def batch_set_parent(
268
+ new_parent: str = typer.Argument(..., help="New parent topic (e.g. ml/deep-learning)"),
269
+ status: str | None = typer.Option(None, "--status", "-s", help="Filter by status"),
270
+ tag: str | None = typer.Option(None, "--tag", "-t", help="Filter by tag"),
271
+ parent: str | None = typer.Option(None, "--parent", "-p", help="Filter by current parent"),
272
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes"),
273
+ json_output: bool = typer.Option(False, "--json", "-j", help="JSON output"),
274
+ ) -> None:
275
+ """Set parent topic on matching notes."""
276
+ vault = _discover_vault(json_output)
277
+ vault.auto_sync()
278
+ notes = _get_matching_notes(vault, status=status, tag=tag, parent=parent)
279
+
280
+ if dry_run:
281
+ if json_output:
282
+ output(success({"action": "set-parent", "parent": new_parent,
283
+ "would_modify": [n["id"] for n in notes]},
284
+ count=len(notes), vault=str(vault.root)), json_mode=True)
285
+ else:
286
+ console.print(f"[bold]Would set parent '{new_parent}' on {len(notes)} notes.[/]")
287
+ return
288
+
289
+ for n in notes:
290
+ _update_file_frontmatter(vault.root, n["path"], {"parent": new_parent})
291
+
292
+ from hyperresearch.core.sync import compute_sync_plan, execute_sync
293
+ plan = compute_sync_plan(vault, force=True)
294
+ execute_sync(vault, plan)
295
+
296
+ if json_output:
297
+ output(success({"action": "set-parent", "parent": new_parent,
298
+ "modified": [n["id"] for n in notes]},
299
+ count=len(notes), vault=str(vault.root)), json_mode=True)
300
+ else:
301
+ console.print(f"[green]Set parent '{new_parent}' on {len(notes)} notes.[/]")