lestash 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lestash/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Le Stash - Personal knowledge base CLI."""
2
+
3
+ __version__ = "0.1.0"
lestash/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m lestash."""
2
+
3
+ from lestash.cli.main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1 @@
1
+ """CLI module for Le Stash."""
lestash/cli/config.py ADDED
@@ -0,0 +1,85 @@
1
+ """Config commands for Le Stash CLI."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from lestash.core.config import Config, get_config_path, init_config
9
+
10
+ app = typer.Typer(help="Manage configuration.")
11
+ console = Console()
12
+
13
+
14
+ @app.command("show")
15
+ def show_config() -> None:
16
+ """Show current configuration."""
17
+ config_path = get_config_path()
18
+
19
+ if not config_path.exists():
20
+ console.print(f"[dim]No config file at {config_path}[/dim]")
21
+ console.print("[dim]Run 'lestash config init' to create one.[/dim]")
22
+ return
23
+
24
+ config = Config.load()
25
+ console.print(f"[bold]Config file:[/bold] {config_path}")
26
+ console.print()
27
+
28
+ data = config.model_dump()
29
+ for section, values in data.items():
30
+ console.print(f"[bold][{section}][/bold]")
31
+ if isinstance(values, dict):
32
+ for key, value in values.items():
33
+ console.print(f" {key} = {value}")
34
+ else:
35
+ console.print(f" {values}")
36
+ console.print()
37
+
38
+
39
+ @app.command("init")
40
+ def init_config_cmd(
41
+ force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite existing config")] = False,
42
+ ) -> None:
43
+ """Initialize configuration file with defaults."""
44
+ config_path = get_config_path()
45
+
46
+ if config_path.exists() and not force:
47
+ console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
48
+ console.print("[dim]Use --force to overwrite.[/dim]")
49
+ raise typer.Exit(1)
50
+
51
+ init_config()
52
+ console.print(f"[green]Created config at {config_path}[/green]")
53
+
54
+
55
+ @app.command("set")
56
+ def set_config(
57
+ key: Annotated[str, typer.Argument(help="Config key (e.g., general.database_path)")],
58
+ value: Annotated[str, typer.Argument(help="Value to set")],
59
+ ) -> None:
60
+ """Set a configuration value."""
61
+ config = Config.load()
62
+
63
+ parts = key.split(".")
64
+ if len(parts) != 2:
65
+ console.print("[red]Key must be in format 'section.key'[/red]")
66
+ raise typer.Exit(1)
67
+
68
+ section, setting = parts
69
+
70
+ # Get current config as dict
71
+ data = config.model_dump()
72
+
73
+ if section not in data:
74
+ data[section] = {}
75
+
76
+ data[section][setting] = value
77
+
78
+ # Save updated config
79
+ try:
80
+ updated_config = Config(**data)
81
+ updated_config.save()
82
+ console.print(f"[green]Set {key} = {value}[/green]")
83
+ except Exception as e:
84
+ console.print(f"[red]Error: {e}[/red]")
85
+ raise typer.Exit(1) from None
lestash/cli/items.py ADDED
@@ -0,0 +1,191 @@
1
+ """Item commands for Le Stash CLI."""
2
+
3
+ import json
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from lestash.core.config import Config
11
+ from lestash.core.database import get_connection
12
+ from lestash.models.item import Item
13
+
14
+ app = typer.Typer(help="Manage items in your knowledge base.")
15
+ console = Console()
16
+
17
+
18
+ @app.command("list")
19
+ def list_items(
20
+ source: Annotated[
21
+ str | None, typer.Option("--source", "-s", help="Filter by source type")
22
+ ] = None,
23
+ own: Annotated[bool | None, typer.Option("--own", help="Show only your own content")] = None,
24
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Limit results")] = 20,
25
+ ) -> None:
26
+ """List items in the knowledge base."""
27
+ config = Config.load()
28
+
29
+ with get_connection(config) as conn:
30
+ query = "SELECT * FROM items WHERE 1=1"
31
+ params: list = []
32
+
33
+ if source:
34
+ query += " AND source_type = ?"
35
+ params.append(source)
36
+
37
+ if own is not None:
38
+ query += " AND is_own_content = ?"
39
+ params.append(own)
40
+
41
+ query += " ORDER BY created_at DESC LIMIT ?"
42
+ params.append(limit)
43
+
44
+ cursor = conn.execute(query, params)
45
+ rows = cursor.fetchall()
46
+
47
+ if not rows:
48
+ console.print("[dim]No items found.[/dim]")
49
+ return
50
+
51
+ table = Table(show_header=True, header_style="bold")
52
+ table.add_column("ID", style="dim")
53
+ table.add_column("Source")
54
+ table.add_column("Title / Content Preview")
55
+ table.add_column("Author")
56
+ table.add_column("Created")
57
+
58
+ for row in rows:
59
+ item = Item.from_row(row)
60
+ preview = (
61
+ item.title or item.content[:50] + "..." if len(item.content) > 50 else item.content
62
+ )
63
+ created = item.created_at.strftime("%Y-%m-%d") if item.created_at else "-"
64
+ table.add_row(
65
+ str(item.id),
66
+ item.source_type,
67
+ preview,
68
+ item.author or "-",
69
+ created,
70
+ )
71
+
72
+ console.print(table)
73
+
74
+
75
+ @app.command("search")
76
+ def search_items(
77
+ query: Annotated[str, typer.Argument(help="Search query")],
78
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Limit results")] = 20,
79
+ ) -> None:
80
+ """Search items using full-text search."""
81
+ config = Config.load()
82
+
83
+ with get_connection(config) as conn:
84
+ cursor = conn.execute(
85
+ """
86
+ SELECT items.* FROM items
87
+ JOIN items_fts ON items.id = items_fts.rowid
88
+ WHERE items_fts MATCH ?
89
+ ORDER BY rank
90
+ LIMIT ?
91
+ """,
92
+ (query, limit),
93
+ )
94
+ rows = cursor.fetchall()
95
+
96
+ if not rows:
97
+ console.print(f"[dim]No items found matching '{query}'.[/dim]")
98
+ return
99
+
100
+ table = Table(show_header=True, header_style="bold")
101
+ table.add_column("ID", style="dim")
102
+ table.add_column("Source")
103
+ table.add_column("Title / Content Preview")
104
+ table.add_column("Author")
105
+
106
+ for row in rows:
107
+ item = Item.from_row(row)
108
+ preview = (
109
+ item.title or item.content[:50] + "..." if len(item.content) > 50 else item.content
110
+ )
111
+ table.add_row(
112
+ str(item.id),
113
+ item.source_type,
114
+ preview,
115
+ item.author or "-",
116
+ )
117
+
118
+ console.print(table)
119
+
120
+
121
+ @app.command("show")
122
+ def show_item(
123
+ item_id: Annotated[int, typer.Argument(help="Item ID to show")],
124
+ ) -> None:
125
+ """Show details of a specific item."""
126
+ config = Config.load()
127
+
128
+ with get_connection(config) as conn:
129
+ cursor = conn.execute("SELECT * FROM items WHERE id = ?", (item_id,))
130
+ row = cursor.fetchone()
131
+
132
+ if not row:
133
+ console.print(f"[red]Item {item_id} not found.[/red]")
134
+ raise typer.Exit(1)
135
+
136
+ item = Item.from_row(row)
137
+
138
+ console.print(f"[bold]ID:[/bold] {item.id}")
139
+ console.print(f"[bold]Source:[/bold] {item.source_type}")
140
+ if item.source_id:
141
+ console.print(f"[bold]Source ID:[/bold] {item.source_id}")
142
+ if item.url:
143
+ console.print(f"[bold]URL:[/bold] {item.url}")
144
+ if item.title:
145
+ console.print(f"[bold]Title:[/bold] {item.title}")
146
+ console.print(f"[bold]Author:[/bold] {item.author or '-'}")
147
+ if item.created_at:
148
+ console.print(f"[bold]Created:[/bold] {item.created_at}")
149
+ console.print(f"[bold]Fetched:[/bold] {item.fetched_at}")
150
+ console.print(f"[bold]Own Content:[/bold] {item.is_own_content}")
151
+ console.print()
152
+ console.print("[bold]Content:[/bold]")
153
+ console.print(item.content)
154
+
155
+ if item.metadata:
156
+ console.print()
157
+ console.print("[bold]Metadata:[/bold]")
158
+ console.print(json.dumps(item.metadata, indent=2))
159
+
160
+
161
+ @app.command("export")
162
+ def export_items(
163
+ output: Annotated[
164
+ str, typer.Option("--output", "-o", help="Output file path")
165
+ ] = "lestash-export.json",
166
+ source: Annotated[
167
+ str | None, typer.Option("--source", "-s", help="Filter by source type")
168
+ ] = None,
169
+ ) -> None:
170
+ """Export items to JSON."""
171
+ config = Config.load()
172
+
173
+ with get_connection(config) as conn:
174
+ query = "SELECT * FROM items"
175
+ params: list = []
176
+
177
+ if source:
178
+ query += " WHERE source_type = ?"
179
+ params.append(source)
180
+
181
+ query += " ORDER BY created_at DESC"
182
+
183
+ cursor = conn.execute(query, params)
184
+ rows = cursor.fetchall()
185
+
186
+ items = [Item.from_row(row).model_dump(mode="json") for row in rows]
187
+
188
+ with open(output, "w") as f:
189
+ json.dump(items, f, indent=2, default=str)
190
+
191
+ console.print(f"[green]Exported {len(items)} items to {output}[/green]")
lestash/cli/main.py ADDED
@@ -0,0 +1,56 @@
1
+ """Main CLI for Le Stash."""
2
+
3
+ import typer
4
+
5
+ from lestash import __version__
6
+ from lestash.cli import config, items, sources
7
+ from lestash.core.database import init_database
8
+ from lestash.core.logging import get_console, get_logger, setup_logging
9
+ from lestash.plugins.loader import load_plugins
10
+
11
+ app = typer.Typer(
12
+ name="lestash",
13
+ help="Le Stash - Personal knowledge base CLI.",
14
+ no_args_is_help=True,
15
+ )
16
+ console = get_console()
17
+ logger = get_logger("cli.main")
18
+
19
+ # Register core command groups
20
+ app.add_typer(items.app, name="items")
21
+ app.add_typer(sources.app, name="sources")
22
+ app.add_typer(config.app, name="config")
23
+
24
+
25
+ def register_plugin_commands() -> None:
26
+ """Discover and register plugin commands."""
27
+ plugins = load_plugins()
28
+ for name, plugin in plugins.items():
29
+ try:
30
+ plugin_app = plugin.get_commands()
31
+ app.add_typer(plugin_app, name=name)
32
+ except Exception as e:
33
+ logger.warning(f"Failed to register commands for '{name}': {e}")
34
+
35
+
36
+ @app.callback()
37
+ def main(
38
+ version: bool = typer.Option(False, "--version", "-v", help="Show version and exit."),
39
+ ) -> None:
40
+ """Le Stash - Personal knowledge base CLI."""
41
+ # Initialize logging first
42
+ setup_logging()
43
+
44
+ if version:
45
+ console.print(f"lestash {__version__}")
46
+ raise typer.Exit()
47
+
48
+ # Ensure database exists
49
+ init_database()
50
+
51
+
52
+ # Register plugin commands at import time
53
+ register_plugin_commands()
54
+
55
+ if __name__ == "__main__":
56
+ app()
lestash/cli/sources.py ADDED
@@ -0,0 +1,222 @@
1
+ """Source commands for Le Stash CLI."""
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from lestash.core.config import Config
12
+ from lestash.core.database import get_connection
13
+ from lestash.plugins.loader import load_plugins
14
+
15
+ app = typer.Typer(help="Manage content sources.")
16
+ console = Console()
17
+
18
+
19
+ @app.command("list")
20
+ def list_sources() -> None:
21
+ """List installed source plugins."""
22
+ plugins = load_plugins()
23
+
24
+ if not plugins:
25
+ console.print("[dim]No source plugins installed.[/dim]")
26
+ return
27
+
28
+ table = Table(show_header=True, header_style="bold")
29
+ table.add_column("Name")
30
+ table.add_column("Description")
31
+ table.add_column("Status")
32
+
33
+ config = Config.load()
34
+ with get_connection(config) as conn:
35
+ for name, plugin in plugins.items():
36
+ cursor = conn.execute(
37
+ "SELECT enabled, last_sync FROM sources WHERE source_type = ?",
38
+ (name,),
39
+ )
40
+ row = cursor.fetchone()
41
+
42
+ if row:
43
+ status = "enabled" if row["enabled"] else "disabled"
44
+ if row["last_sync"]:
45
+ status += f" (last sync: {row['last_sync']})"
46
+ else:
47
+ status = "not configured"
48
+
49
+ table.add_row(name, plugin.description, status)
50
+
51
+ console.print(table)
52
+
53
+
54
+ @app.command("sync")
55
+ def sync_source(
56
+ source_name: Annotated[str | None, typer.Argument(help="Source to sync (omit for all)")] = None,
57
+ all_sources: Annotated[bool, typer.Option("--all", help="Sync all enabled sources")] = False,
58
+ ) -> None:
59
+ """Sync items from a source."""
60
+ plugins = load_plugins()
61
+
62
+ if not plugins:
63
+ console.print("[red]No source plugins installed.[/red]")
64
+ raise typer.Exit(1)
65
+
66
+ if source_name:
67
+ sources_to_sync = [source_name]
68
+ elif all_sources:
69
+ sources_to_sync = list(plugins.keys())
70
+ else:
71
+ console.print("[red]Specify a source name or use --all[/red]")
72
+ raise typer.Exit(1)
73
+
74
+ config = Config.load()
75
+
76
+ for name in sources_to_sync:
77
+ if name not in plugins:
78
+ console.print(f"[red]Unknown source: {name}[/red]")
79
+ continue
80
+
81
+ plugin = plugins[name]
82
+ plugin_config = config.get_plugin_config(name)
83
+
84
+ console.print(f"[bold]Syncing {name}...[/bold]")
85
+
86
+ with get_connection(config) as conn:
87
+ # Log sync start
88
+ started_at = datetime.now()
89
+ cursor = conn.execute(
90
+ "INSERT INTO sync_log (source_type, started_at, status) VALUES (?, ?, ?)",
91
+ (name, started_at, "running"),
92
+ )
93
+ sync_id = cursor.lastrowid
94
+ conn.commit()
95
+
96
+ items_added = 0
97
+ items_updated = 0
98
+ error_message = None
99
+
100
+ try:
101
+ for item in plugin.sync(plugin_config):
102
+ # Try to insert, update on conflict
103
+ metadata_json = json.dumps(item.metadata) if item.metadata else None
104
+ cursor = conn.execute(
105
+ """
106
+ INSERT INTO items (
107
+ source_type, source_id, url, title, content,
108
+ author, created_at, is_own_content, metadata
109
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
110
+ ON CONFLICT(source_type, source_id) DO UPDATE SET
111
+ url = excluded.url,
112
+ title = excluded.title,
113
+ content = excluded.content,
114
+ author = excluded.author,
115
+ is_own_content = excluded.is_own_content,
116
+ metadata = excluded.metadata
117
+ """,
118
+ (
119
+ item.source_type,
120
+ item.source_id,
121
+ item.url,
122
+ item.title,
123
+ item.content,
124
+ item.author,
125
+ item.created_at,
126
+ item.is_own_content,
127
+ metadata_json,
128
+ ),
129
+ )
130
+ if cursor.rowcount > 0:
131
+ items_added += 1
132
+
133
+ conn.commit()
134
+
135
+ # Update source last_sync
136
+ conn.execute(
137
+ """
138
+ INSERT INTO sources (source_type, last_sync)
139
+ VALUES (?, ?)
140
+ ON CONFLICT(source_type) DO UPDATE SET last_sync = excluded.last_sync
141
+ """,
142
+ (name, datetime.now()),
143
+ )
144
+ conn.commit()
145
+
146
+ status = "completed"
147
+ except Exception as e:
148
+ status = "failed"
149
+ error_message = str(e)
150
+ console.print(f"[red]Error syncing {name}: {e}[/red]")
151
+
152
+ # Update sync log
153
+ conn.execute(
154
+ """
155
+ UPDATE sync_log SET
156
+ completed_at = ?,
157
+ status = ?,
158
+ items_added = ?,
159
+ items_updated = ?,
160
+ error_message = ?
161
+ WHERE id = ?
162
+ """,
163
+ (datetime.now(), status, items_added, items_updated, error_message, sync_id),
164
+ )
165
+ conn.commit()
166
+
167
+ if status == "completed":
168
+ console.print(f"[green]Synced {name}: {items_added} items added[/green]")
169
+
170
+
171
+ @app.command("status")
172
+ def source_status() -> None:
173
+ """Show sync status for all sources."""
174
+ config = Config.load()
175
+
176
+ with get_connection(config) as conn:
177
+ cursor = conn.execute(
178
+ """
179
+ SELECT source_type, started_at, completed_at, status,
180
+ items_added, items_updated, error_message
181
+ FROM sync_log
182
+ ORDER BY started_at DESC
183
+ LIMIT 20
184
+ """
185
+ )
186
+ rows = cursor.fetchall()
187
+
188
+ if not rows:
189
+ console.print("[dim]No sync history.[/dim]")
190
+ return
191
+
192
+ table = Table(show_header=True, header_style="bold")
193
+ table.add_column("Source")
194
+ table.add_column("Started")
195
+ table.add_column("Status")
196
+ table.add_column("Items")
197
+ table.add_column("Error")
198
+
199
+ for row in rows:
200
+ started = row["started_at"][:16] if row["started_at"] else "-"
201
+ items = f"+{row['items_added']}" if row["items_added"] else "-"
202
+ error = (
203
+ row["error_message"][:30] + "..."
204
+ if row["error_message"] and len(row["error_message"]) > 30
205
+ else (row["error_message"] or "-")
206
+ )
207
+
208
+ status_style = {
209
+ "completed": "green",
210
+ "failed": "red",
211
+ "running": "yellow",
212
+ }.get(row["status"], "")
213
+
214
+ table.add_row(
215
+ row["source_type"],
216
+ started,
217
+ f"[{status_style}]{row['status']}[/{status_style}]" if status_style else row["status"],
218
+ items,
219
+ error,
220
+ )
221
+
222
+ console.print(table)
@@ -0,0 +1,10 @@
1
+ """Core module for Le Stash."""
2
+
3
+ from lestash.core.logging import get_console, get_logger, get_plugin_logger, setup_logging
4
+
5
+ __all__ = [
6
+ "get_console",
7
+ "get_logger",
8
+ "get_plugin_logger",
9
+ "setup_logging",
10
+ ]
lestash/core/config.py ADDED
@@ -0,0 +1,108 @@
1
+ """Configuration management for Le Stash."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Literal
5
+
6
+ import toml
7
+ from pydantic import BaseModel
8
+
9
+
10
+ def get_config_dir() -> Path:
11
+ """Get the configuration directory path."""
12
+ config_dir = Path.home() / ".config" / "lestash"
13
+ return config_dir
14
+
15
+
16
+ def get_config_path() -> Path:
17
+ """Get the configuration file path."""
18
+ return get_config_dir() / "config.toml"
19
+
20
+
21
+ def get_database_path() -> Path:
22
+ """Get the default database path."""
23
+ return get_config_dir() / "lestash.db"
24
+
25
+
26
+ def get_log_path() -> Path:
27
+ """Get the default log file path."""
28
+ return get_config_dir() / "logs" / "lestash.log"
29
+
30
+
31
+ class GeneralConfig(BaseModel):
32
+ """General configuration."""
33
+
34
+ database_path: str = ""
35
+
36
+ def model_post_init(self, __context: Any) -> None:
37
+ if not self.database_path:
38
+ self.database_path = str(get_database_path())
39
+
40
+
41
+ class LoggingConfig(BaseModel):
42
+ """Logging configuration."""
43
+
44
+ # Global log level
45
+ level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
46
+
47
+ # Console settings (diagnostic output to stderr)
48
+ console_enabled: bool = True
49
+ console_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
50
+ console_timestamps: bool = False
51
+
52
+ # File settings (persistent debug trail)
53
+ file_enabled: bool = True
54
+ file_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
55
+ file_path: str = ""
56
+ max_bytes: int = 10 * 1024 * 1024 # 10MB
57
+ backup_count: int = 5
58
+ file_format: Literal["simple", "detailed", "json"] = "detailed"
59
+
60
+ # Database settings (optional, queryable history)
61
+ db_enabled: bool = False
62
+ db_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
63
+
64
+ # Filter noisy third-party loggers
65
+ filters: dict[str, str] = {
66
+ "httpx": "WARNING",
67
+ "httpcore": "WARNING",
68
+ "urllib3": "WARNING",
69
+ }
70
+
71
+ def model_post_init(self, __context: Any) -> None:
72
+ if not self.file_path:
73
+ self.file_path = str(get_log_path())
74
+
75
+
76
+ class Config(BaseModel):
77
+ """Application configuration."""
78
+
79
+ general: GeneralConfig = GeneralConfig()
80
+ logging: LoggingConfig = LoggingConfig()
81
+
82
+ @classmethod
83
+ def load(cls) -> "Config":
84
+ """Load configuration from file."""
85
+ config_path = get_config_path()
86
+ if config_path.exists():
87
+ data = toml.load(config_path)
88
+ return cls(**data)
89
+ return cls()
90
+
91
+ def save(self) -> None:
92
+ """Save configuration to file."""
93
+ config_path = get_config_path()
94
+ config_path.parent.mkdir(parents=True, exist_ok=True)
95
+ with open(config_path, "w") as f:
96
+ toml.dump(self.model_dump(), f)
97
+
98
+ def get_plugin_config(self, plugin_name: str) -> dict[str, Any]:
99
+ """Get configuration for a specific plugin."""
100
+ data = self.model_dump()
101
+ return data.get(plugin_name, {})
102
+
103
+
104
+ def init_config() -> Config:
105
+ """Initialize configuration with defaults."""
106
+ config = Config()
107
+ config.save()
108
+ return config