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.
- hyperresearch/__init__.py +3 -0
- hyperresearch/__main__.py +5 -0
- hyperresearch/cli/__init__.py +117 -0
- hyperresearch/cli/_output.py +131 -0
- hyperresearch/cli/assets.py +114 -0
- hyperresearch/cli/batch.py +301 -0
- hyperresearch/cli/config_cmd.py +143 -0
- hyperresearch/cli/dedup.py +138 -0
- hyperresearch/cli/export.py +117 -0
- hyperresearch/cli/fetch.py +294 -0
- hyperresearch/cli/fetch_batch.py +231 -0
- hyperresearch/cli/git_cmd.py +161 -0
- hyperresearch/cli/graph.py +286 -0
- hyperresearch/cli/import_cmd.py +85 -0
- hyperresearch/cli/index.py +77 -0
- hyperresearch/cli/install.py +160 -0
- hyperresearch/cli/link.py +81 -0
- hyperresearch/cli/lint.py +212 -0
- hyperresearch/cli/main.py +170 -0
- hyperresearch/cli/mcp_cmd.py +19 -0
- hyperresearch/cli/note.py +481 -0
- hyperresearch/cli/repair.py +254 -0
- hyperresearch/cli/research.py +313 -0
- hyperresearch/cli/search.py +125 -0
- hyperresearch/cli/serve.py +26 -0
- hyperresearch/cli/setup.py +356 -0
- hyperresearch/cli/sources.py +97 -0
- hyperresearch/cli/tag.py +121 -0
- hyperresearch/cli/template.py +58 -0
- hyperresearch/cli/topic.py +163 -0
- hyperresearch/cli/watch.py +113 -0
- hyperresearch/core/__init__.py +1 -0
- hyperresearch/core/agent_docs.py +331 -0
- hyperresearch/core/config.py +113 -0
- hyperresearch/core/db.py +162 -0
- hyperresearch/core/enrich.py +110 -0
- hyperresearch/core/fetcher.py +127 -0
- hyperresearch/core/frontmatter.py +55 -0
- hyperresearch/core/hooks.py +358 -0
- hyperresearch/core/linker.py +138 -0
- hyperresearch/core/migrations.py +80 -0
- hyperresearch/core/note.py +117 -0
- hyperresearch/core/patterns.py +12 -0
- hyperresearch/core/similarity.py +81 -0
- hyperresearch/core/sync.py +301 -0
- hyperresearch/core/templates.py +209 -0
- hyperresearch/core/vault.py +153 -0
- hyperresearch/export/__init__.py +1 -0
- hyperresearch/graph/__init__.py +1 -0
- hyperresearch/indexgen/__init__.py +1 -0
- hyperresearch/indexgen/generator.py +256 -0
- hyperresearch/mcp/__init__.py +1 -0
- hyperresearch/mcp/server.py +404 -0
- hyperresearch/models/__init__.py +1 -0
- hyperresearch/models/graph.py +21 -0
- hyperresearch/models/note.py +89 -0
- hyperresearch/models/output.py +28 -0
- hyperresearch/models/search.py +24 -0
- hyperresearch/py.typed +0 -0
- hyperresearch/search/__init__.py +1 -0
- hyperresearch/search/filters.py +95 -0
- hyperresearch/search/fts.py +139 -0
- hyperresearch/serve/__init__.py +1 -0
- hyperresearch/serve/renderer.py +124 -0
- hyperresearch/serve/server.py +588 -0
- hyperresearch/skills/__init__.py +1 -0
- hyperresearch/skills/research.md +172 -0
- hyperresearch/web/__init__.py +1 -0
- hyperresearch/web/base.py +101 -0
- hyperresearch/web/builtin.py +111 -0
- hyperresearch/web/crawl4ai_provider.py +253 -0
- hyperresearch-0.2.0.dist-info/METADATA +172 -0
- hyperresearch-0.2.0.dist-info/RECORD +76 -0
- hyperresearch-0.2.0.dist-info/WHEEL +4 -0
- hyperresearch-0.2.0.dist-info/entry_points.txt +3 -0
- hyperresearch-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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.[/]")
|