opencode-db 0.8.0__tar.gz → 0.8.2__tar.gz

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 (54) hide show
  1. {opencode_db-0.8.0/opencode_db.egg-info → opencode_db-0.8.2}/PKG-INFO +5 -1
  2. {opencode_db-0.8.0 → opencode_db-0.8.2}/README.md +40 -4
  3. {opencode_db-0.8.0 → opencode_db-0.8.2}/docs/README_PYPI.md +4 -0
  4. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/cli.py +5 -1
  5. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/__init__.py +3 -0
  6. opencode_db-0.8.2/opencode_db/commands/cmd_directories.py +135 -0
  7. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_help.py +11 -0
  8. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_list.py +23 -11
  9. opencode_db-0.8.2/opencode_db/commands/cmd_projects.py +130 -0
  10. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/i18n.py +29 -0
  11. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/store/base.py +124 -0
  12. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/store/v1.py +1 -1
  13. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/store/v2.py +1 -1
  14. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/utils.py +8 -0
  15. {opencode_db-0.8.0 → opencode_db-0.8.2/opencode_db.egg-info}/PKG-INFO +5 -1
  16. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db.egg-info/SOURCES.txt +1 -0
  17. {opencode_db-0.8.0 → opencode_db-0.8.2}/pyproject.toml +1 -1
  18. {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_commands.py +85 -0
  19. {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_store_v2.py +99 -0
  20. opencode_db-0.8.0/opencode_db/commands/cmd_projects.py +0 -83
  21. {opencode_db-0.8.0 → opencode_db-0.8.2}/LICENSE +0 -0
  22. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/__init__.py +0 -0
  23. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/__main__.py +0 -0
  24. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_costs.py +0 -0
  25. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_delete.py +0 -0
  26. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_export.py +0 -0
  27. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_info.py +0 -0
  28. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_prune.py +0 -0
  29. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_schema.py +0 -0
  30. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_search.py +0 -0
  31. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_stats.py +0 -0
  32. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_todos.py +0 -0
  33. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_tree.py +0 -0
  34. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_vacuum.py +0 -0
  35. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_view.py +0 -0
  36. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/config.py +0 -0
  37. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/db.py +0 -0
  38. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/formatters.py +0 -0
  39. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/markdown_to_ansi.py +0 -0
  40. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/runtime.py +0 -0
  41. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/schema.py +0 -0
  42. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/schema_manifest.json +0 -0
  43. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/store/__init__.py +0 -0
  44. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db.egg-info/dependency_links.txt +0 -0
  45. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db.egg-info/entry_points.txt +0 -0
  46. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db.egg-info/requires.txt +0 -0
  47. {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db.egg-info/top_level.txt +0 -0
  48. {opencode_db-0.8.0 → opencode_db-0.8.2}/setup.cfg +0 -0
  49. {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_db.py +0 -0
  50. {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_formatters.py +0 -0
  51. {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_i18n.py +0 -0
  52. {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_runtime.py +0 -0
  53. {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_schema.py +0 -0
  54. {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-db
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
5
5
  Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
6
6
  Project-URL: Source, https://github.com/VasilevNStas/opencode-db
@@ -32,6 +32,7 @@ uv tool install opencode-db
32
32
 
33
33
  ```bash
34
34
  opencode-db list # recent sessions
35
+ opencode-db directories # launch directories, including missing ones
35
36
  opencode-db view # interactive session viewer (colored, scrollable)
36
37
  opencode-db stats # database summary
37
38
  opencode-db schema # detect v1/v2 schema
@@ -44,6 +45,7 @@ opencode-db help # full reference
44
45
 
45
46
  - **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less`
46
47
  - **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
48
+ - **`directories`** (`dirs`) — все директории запусков, включая отсутствующие; `list --directory` добавляет путь к сессии
47
49
  - **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
48
50
  - **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
49
51
  - **`costs`** / **`stats`** — аналитика токенов и расходов
@@ -63,4 +65,6 @@ pip install opencode-db # zero external dependencies
63
65
  - [OpenCode](https://opencode.ai) (must have been run at least once)
64
66
  - macOS / Linux
65
67
 
68
+ **Directory support:** `global` is a synthetic project for non-Git directories; `directories` shows the real `session.directory` and marks it as `Global (no project root)`. Missing paths are detected with `os.path.isdir()` at command runtime and can be filtered with `--missing`.
69
+
66
70
  **Schema support:** OpenCode 1.19.x (v1) — read and write; OpenCode 2.x preview (v2) — read-only. Run `opencode-db schema` to check the detected schema. On v2, `delete`, `prune`, and `vacuum` are refused until write support is implemented.
@@ -172,6 +172,7 @@ opencode-db list --project proj_xxx # filter by project
172
172
  opencode-db list --agent build # filter by agent type
173
173
  opencode-db list --model deepseek # filter by model name
174
174
  opencode-db list --json # JSON output
175
+ opencode-db list --all --directory # include directory paths
175
176
  ```
176
177
 
177
178
  | Flag | Description | Default |
@@ -184,6 +185,37 @@ opencode-db list --json # JSON output
184
185
  | `--sort {date,cost,tokens,messages}` | Sort order | date |
185
186
  | `--json` | Output as JSON | — |
186
187
  | `--all` | Show all (overrides --limit) | — |
188
+ | `--directory` | Add a session directory column | — |
189
+
190
+ ---
191
+
192
+ ### `directories` — launch directories
193
+
194
+ Lists every directory where OpenCode has run, including paths that no longer exist. Combines session directories with project roots and project-directory mappings.
195
+
196
+ ```bash
197
+ opencode-db directories # grouped by path
198
+ opencode-db directories --missing # only missing/inaccessible paths
199
+ opencode-db directories --existing # only existing paths
200
+ opencode-db directories --project global # Global sessions by real directory
201
+ opencode-db directories --json # full paths, counts, tokens, cost
202
+ ```
203
+
204
+ `global` is a synthetic project for non-Git directories. The command never shows
205
+ `/` as a real directory; it shows the actual `session.directory` and marks the
206
+ project as `Global (no project root)`.
207
+
208
+ | Flag | Description | Default |
209
+ |---|---|---|
210
+ | `--missing` | Only paths not found on disk | — |
211
+ | `--existing` | Only paths that exist | — |
212
+ | `--project ID` | Filter by project | — |
213
+ | `--sort {date,sessions,cost,path}` | Sort order | date |
214
+ | `--limit N` | Max entries | 50 |
215
+ | `--no-check` | Skip filesystem check (`exists: null`) | — |
216
+ | `--json` | Output as JSON | — |
217
+
218
+ > `os.path.isdir()` is a point-in-time check. An offline volume or permission error can look like a missing directory.
187
219
 
188
220
  ---
189
221
 
@@ -390,13 +422,17 @@ Example output:
390
422
  Shows all projects in the database with session counts, tokens, and costs.
391
423
 
392
424
  ```bash
393
- opencode-db projects # project table
425
+ opencode-db projects # project table with paths
394
426
  opencode-db projects --json # JSON output
427
+ opencode-db projects --missing # projects whose path is gone
428
+ opencode-db projects --no-global # hide the synthetic Global project
395
429
  ```
396
430
 
397
431
  | Flag | Description |
398
432
  |---|---|
399
433
  | `--json` | JSON output |
434
+ | `--missing` | Only projects with a missing path |
435
+ | `--no-global` | Hide the synthetic Global project |
400
436
 
401
437
  ---
402
438
 
@@ -540,7 +576,7 @@ opencode-db/
540
576
  │ ├── config.py ← constants and paths
541
577
  │ ├── db.py ← SQLite queries
542
578
  │ ├── formatters.py ← tables, JSON, Markdown, snapshots
543
- │ ├── i18n.py ← localization (EN/RU, 296 keys)
579
+ │ ├── i18n.py ← localization (EN/RU, 324 keys)
544
580
  │ ├── markdown_to_ansi.py ← Markdown → ANSI renderer for `view`
545
581
  │ ├── runtime.py ← Python 3.12+ version guard
546
582
  │ ├── utils.py ← date/token formatting, validation
@@ -689,7 +725,7 @@ opencode-db --lang en help # English help text
689
725
  opencode-db --lang en stats # English statistics
690
726
  ```
691
727
 
692
- The `--lang` flag applies to any command. All 296 UI strings are translated. Data content (session titles, project names, etc.) remains in its original language.
728
+ The `--lang` flag applies to any command. All 324 UI strings are translated. Data content (session titles, project names, etc.) remains in its original language.
693
729
 
694
730
  The language can also be set via environment variable `OPENCODE_DB_LANG=en`.
695
731
 
@@ -774,7 +810,7 @@ source .venv/bin/activate
774
810
  uv pip install -e ".[rich]"
775
811
  uv pip install pytest ruff==0.17.1
776
812
 
777
- make check # check-python + ruff + 205 tests
813
+ make check # check-python + ruff + 214 tests
778
814
  ```
779
815
 
780
816
  `make check` fails fast with a clear message if the active interpreter is older than 3.12.
@@ -16,6 +16,7 @@ uv tool install opencode-db
16
16
 
17
17
  ```bash
18
18
  opencode-db list # recent sessions
19
+ opencode-db directories # launch directories, including missing ones
19
20
  opencode-db view # interactive session viewer (colored, scrollable)
20
21
  opencode-db stats # database summary
21
22
  opencode-db schema # detect v1/v2 schema
@@ -28,6 +29,7 @@ opencode-db help # full reference
28
29
 
29
30
  - **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less`
30
31
  - **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
32
+ - **`directories`** (`dirs`) — все директории запусков, включая отсутствующие; `list --directory` добавляет путь к сессии
31
33
  - **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
32
34
  - **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
33
35
  - **`costs`** / **`stats`** — аналитика токенов и расходов
@@ -47,4 +49,6 @@ pip install opencode-db # zero external dependencies
47
49
  - [OpenCode](https://opencode.ai) (must have been run at least once)
48
50
  - macOS / Linux
49
51
 
52
+ **Directory support:** `global` is a synthetic project for non-Git directories; `directories` shows the real `session.directory` and marks it as `Global (no project root)`. Missing paths are detected with `os.path.isdir()` at command runtime and can be filtered with `--missing`.
53
+
50
54
  **Schema support:** OpenCode 1.19.x (v1) — read and write; OpenCode 2.x preview (v2) — read-only. Run `opencode-db schema` to check the detected schema. On v2, `delete`, `prune`, and `vacuum` are refused until write support is implemented.
@@ -43,7 +43,11 @@ def build_parser() -> argparse.ArgumentParser:
43
43
  )
44
44
 
45
45
  subparsers = parser.add_subparsers(dest="command", required=True)
46
- for _name, mod in commands.get_all().items():
46
+ seen: set[str] = set()
47
+ for mod in commands.get_all().values():
48
+ if mod.__name__ in seen:
49
+ continue
50
+ seen.add(mod.__name__)
47
51
  mod.register(subparsers)
48
52
  return parser
49
53
 
@@ -13,6 +13,7 @@
13
13
  from . import (
14
14
  cmd_costs,
15
15
  cmd_delete,
16
+ cmd_directories,
16
17
  cmd_export,
17
18
  cmd_help,
18
19
  cmd_info,
@@ -35,6 +36,8 @@ COMMANDS = {
35
36
  "info": cmd_info,
36
37
  "export": cmd_export,
37
38
  "delete": cmd_delete,
39
+ "directories": cmd_directories,
40
+ "dirs": cmd_directories,
38
41
  "costs": cmd_costs,
39
42
  "prune": cmd_prune,
40
43
  "search": cmd_search,
@@ -0,0 +1,135 @@
1
+ """Команда directories: все директории запусков OpenCode."""
2
+
3
+ import argparse
4
+ from typing import Literal
5
+
6
+ from ..formatters import print_json, print_table_simple
7
+ from ..i18n import _
8
+ from ..store import open_store
9
+ from ..utils import build_help_epilog, format_cost, format_ts, shorten_path
10
+
11
+ _DIRECTORIES_EXAMPLES = [
12
+ ("", "help.directories.e0"),
13
+ ("--missing", "help.directories.e1"),
14
+ ("--json", "help.directories.e2"),
15
+ ("--no-check", "help.directories.e3"),
16
+ ]
17
+
18
+
19
+ def register(subparsers) -> None:
20
+ p = subparsers.add_parser(
21
+ "directories",
22
+ aliases=["dirs"],
23
+ help=_("help.cmd.directories"),
24
+ formatter_class=argparse.RawDescriptionHelpFormatter,
25
+ epilog=build_help_epilog("directories", _DIRECTORIES_EXAMPLES),
26
+ )
27
+ p.add_argument("--missing", action="store_true", help="Only missing directories")
28
+ p.add_argument("--existing", action="store_true", help="Only existing directories")
29
+ p.add_argument("--project", type=str, help="Filter by project ID")
30
+ p.add_argument(
31
+ "--sort",
32
+ choices=["date", "sessions", "cost", "path"],
33
+ default="date",
34
+ help="Sort order (default: date)",
35
+ )
36
+ p.add_argument("--limit", type=int, default=50, help="Max results (default 50)")
37
+ p.add_argument("--no-check", action="store_true", help="Skip filesystem check")
38
+ p.add_argument("--json", action="store_true", help="JSON output")
39
+
40
+
41
+ def run(args, db) -> Literal[1] | Literal[0]:
42
+ if getattr(args, "missing", False) and getattr(args, "existing", False):
43
+ print(_("directories.conflict"))
44
+ return 1
45
+
46
+ entries = open_store(db).get_directories(check_exists=not getattr(args, "no_check", False))
47
+
48
+ if getattr(args, "project", None):
49
+ entries = [
50
+ entry
51
+ for entry in entries
52
+ if args.project == entry["project_id"] or args.project in entry["project_ids"]
53
+ ]
54
+ if getattr(args, "missing", False):
55
+ entries = [entry for entry in entries if entry["exists"] is False]
56
+ elif getattr(args, "existing", False):
57
+ entries = [entry for entry in entries if entry["exists"] is True]
58
+
59
+ sorters = {
60
+ "date": lambda entry: entry["last_ts"] or 0,
61
+ "sessions": lambda entry: entry["sessions"],
62
+ "cost": lambda entry: entry["cost"],
63
+ "path": lambda entry: entry["path"],
64
+ }
65
+ reverse = getattr(args, "sort", "date") != "path"
66
+ entries.sort(key=sorters[getattr(args, "sort", "date")], reverse=reverse)
67
+ if getattr(args, "limit", 50):
68
+ entries = entries[: getattr(args, "limit", 50)]
69
+
70
+ if getattr(args, "json", False):
71
+ data = [
72
+ {
73
+ "path": entry["path"],
74
+ "exists": entry["exists"],
75
+ "project_id": entry["project_id"],
76
+ "project_ids": entry["project_ids"],
77
+ "project": entry["project_label"],
78
+ "is_global": entry["is_global"],
79
+ "sessions": entry["sessions"],
80
+ "first_activity": format_ts(entry["first_ts"]) if entry["first_ts"] else None,
81
+ "last_activity": format_ts(entry["last_ts"]) if entry["last_ts"] else None,
82
+ "tokens_input": entry["tokens_input"],
83
+ "tokens_output": entry["tokens_output"],
84
+ "tokens_reasoning": entry["tokens_reasoning"],
85
+ "tokens_cache_read": entry["tokens_cache_read"],
86
+ "tokens_cache_write": entry["tokens_cache_write"],
87
+ "cost": entry["cost"],
88
+ "sources": entry["sources"],
89
+ }
90
+ for entry in entries
91
+ ]
92
+ print_json(data)
93
+ return 0
94
+
95
+ if not entries:
96
+ print(_("directories.none"))
97
+ return 0
98
+
99
+ status_icon = {True: "✓", False: "✗", None: "?"}
100
+ table = []
101
+ for entry in entries:
102
+ status = {
103
+ True: _("directories.exists"),
104
+ False: _("directories.missing"),
105
+ None: _("directories.unknown"),
106
+ }[entry["exists"]]
107
+ last = format_ts(entry["last_ts"], "%Y-%m-%d") if entry["last_ts"] else "—"
108
+ table.append(
109
+ [
110
+ shorten_path(entry["path"], 52),
111
+ f"{status_icon[entry['exists']]} {status}",
112
+ entry["project_label"][:22],
113
+ str(entry["sessions"]),
114
+ last,
115
+ format_cost(entry["cost"]),
116
+ ]
117
+ )
118
+
119
+ print(f"\n {_('directories.title')}")
120
+ print_table_simple(
121
+ [
122
+ _("directories.header.path"),
123
+ _("directories.header.status"),
124
+ _("directories.header.project"),
125
+ _("directories.header.sessions"),
126
+ _("directories.header.last"),
127
+ _("directories.header.cost"),
128
+ ],
129
+ table,
130
+ )
131
+
132
+ missing = sum(1 for entry in entries if entry["exists"] is False)
133
+ sessions = sum(entry["sessions"] for entry in entries)
134
+ print(_("directories.summary", total=len(entries), sessions=sessions, missing=missing))
135
+ return 0
@@ -41,6 +41,7 @@ _COMMANDS = [
41
41
  ("--sort cost", "help.list.e2"),
42
42
  ("--project proj_xxx", "help.list.e3"),
43
43
  ("--json", "help.list.e4"),
44
+ ("--directory", "help.list.e5"),
44
45
  ],
45
46
  ),
46
47
  (
@@ -119,6 +120,16 @@ _COMMANDS = [
119
120
  ("--json", "help.projects.e1"),
120
121
  ],
121
122
  ),
123
+ (
124
+ "directories",
125
+ "help.cmd.directories",
126
+ [
127
+ ("", "help.directories.e0"),
128
+ ("--missing", "help.directories.e1"),
129
+ ("--json", "help.directories.e2"),
130
+ ("--no-check", "help.directories.e3"),
131
+ ],
132
+ ),
122
133
  (
123
134
  "schema",
124
135
  "help.cmd.schema",
@@ -7,7 +7,7 @@ from ..db import get_session_title, parse_model
7
7
  from ..formatters import print_json, print_table_simple
8
8
  from ..i18n import _
9
9
  from ..store import open_store
10
- from ..utils import build_help_epilog, format_ts
10
+ from ..utils import build_help_epilog, format_ts, shorten_path
11
11
 
12
12
  _LIST_EXAMPLES = [
13
13
  ("", "help.list.e0"),
@@ -15,6 +15,7 @@ _LIST_EXAMPLES = [
15
15
  ("--sort cost", "help.list.e2"),
16
16
  ("--project proj_xxx", "help.list.e3"),
17
17
  ("--json", "help.list.e4"),
18
+ ("--directory", "help.list.e5"),
18
19
  ]
19
20
 
20
21
 
@@ -38,6 +39,7 @@ def register(subparsers) -> None:
38
39
  )
39
40
  p.add_argument("--json", action="store_true", help="JSON output")
40
41
  p.add_argument("--all", action="store_true", help="Show all (no limit)")
42
+ p.add_argument("--directory", action="store_true", help="Show session directory")
41
43
 
42
44
 
43
45
  def run(args, db) -> Literal[0]:
@@ -60,6 +62,7 @@ def run(args, db) -> Literal[0]:
60
62
  "id": r["id"],
61
63
  "title": get_session_title(r),
62
64
  "project_id": r["project_id"],
65
+ "directory": r["directory"],
63
66
  "model": parse_model(r),
64
67
  "agent": r["agent"],
65
68
  "cost": r["cost"],
@@ -78,29 +81,38 @@ def run(args, db) -> Literal[0]:
78
81
  model_name = parse_model(r)
79
82
  last_act = format_ts(r["last_time"], "%Y-%m-%d %H:%M") if r["last_time"] else "—"
80
83
 
81
- table_rows.append(
84
+ row = [
85
+ r["id"][:24],
86
+ get_session_title(r)[:32],
87
+ project_name[:16],
88
+ ]
89
+ if getattr(args, "directory", False):
90
+ row.append(shorten_path(r["directory"] or "—", 44))
91
+ row.extend(
82
92
  [
83
- r["id"][:24],
84
- get_session_title(r)[:32],
85
- project_name[:16],
86
93
  model_name[:20],
87
94
  last_act,
88
95
  str(r["msg_count"]),
89
96
  f"${r['cost']:.4f}" if r["cost"] else "—",
90
97
  ]
91
98
  )
99
+ table_rows.append(row)
92
100
 
93
- print_table_simple(
101
+ headers = [
102
+ _("list.header.id"),
103
+ _("list.header.title"),
104
+ _("list.header.project"),
105
+ ]
106
+ if getattr(args, "directory", False):
107
+ headers.append(_("list.header.directory"))
108
+ headers.extend(
94
109
  [
95
- _("list.header.id"),
96
- _("list.header.title"),
97
- _("list.header.project"),
98
110
  _("list.header.model"),
99
111
  _("list.header.activity"),
100
112
  _("list.header.messages"),
101
113
  _("list.header.cost"),
102
- ],
103
- table_rows,
114
+ ]
104
115
  )
116
+ print_table_simple(headers, table_rows)
105
117
 
106
118
  return 0
@@ -0,0 +1,130 @@
1
+ """Команда projects: список проектов со статистикой."""
2
+
3
+ import argparse
4
+ import os
5
+ from typing import Literal
6
+
7
+ from ..i18n import _
8
+ from ..store import open_store
9
+ from ..utils import build_help_epilog, format_cost, format_tokens, format_ts, shorten_path
10
+
11
+ _PROJECTS_EXAMPLES = [
12
+ ("", "help.projects.e0"),
13
+ ("--json", "help.projects.e1"),
14
+ ("--no-global", "help.projects.e2"),
15
+ ("--missing", "help.projects.e3"),
16
+ ]
17
+
18
+
19
+ def register(subparsers) -> None:
20
+ p = subparsers.add_parser(
21
+ "projects",
22
+ help=_("help.cmd.projects"),
23
+ formatter_class=argparse.RawDescriptionHelpFormatter,
24
+ epilog=build_help_epilog("projects", _PROJECTS_EXAMPLES),
25
+ )
26
+ p.add_argument("--json", action="store_true", help="JSON output")
27
+ p.add_argument("--no-global", action="store_true", help="Hide the Global project")
28
+ p.add_argument("--missing", action="store_true", help="Only projects with a missing path")
29
+
30
+
31
+ def _project_view(r) -> dict:
32
+ worktree = r["worktree"]
33
+ is_global = r["id"] == "global"
34
+ path = None if is_global else worktree
35
+ exists = None if is_global else (os.path.isdir(path) if path else False)
36
+ name = r["name"]
37
+ if not name and path and not is_global:
38
+ name = os.path.basename(str(path).rstrip("/")) or None
39
+ return {
40
+ "id": r["id"],
41
+ "name": name,
42
+ "worktree": worktree,
43
+ "path": path,
44
+ "exists": exists,
45
+ "is_global": is_global,
46
+ "time_created": format_ts(r["time_created"]) if r["time_created"] else None,
47
+ "time_updated": format_ts(r["time_updated"]) if r["time_updated"] else None,
48
+ "sessions": r["session_count"],
49
+ "cost": r["total_cost"],
50
+ "tokens_input": r["total_ti"],
51
+ "tokens_output": r["total_to"],
52
+ "tokens_reasoning": r["total_tr"],
53
+ "tokens_total": r["total_ti"] + r["total_to"] + r["total_tr"],
54
+ }
55
+
56
+
57
+ def run(args, db) -> Literal[0]:
58
+ rows = open_store(db).get_projects()
59
+ views = [_project_view(r) for r in rows]
60
+ if getattr(args, "no_global", False):
61
+ views = [v for v in views if not v["is_global"]]
62
+ if getattr(args, "missing", False):
63
+ views = [v for v in views if v["exists"] is False]
64
+
65
+ if args.json:
66
+ from ..formatters import print_json
67
+
68
+ data = []
69
+ for v in views:
70
+ data.append(
71
+ {
72
+ "id": v["id"],
73
+ "name": v["name"],
74
+ "worktree": v["worktree"],
75
+ "path": v["path"],
76
+ "exists": v["exists"],
77
+ "is_global": v["is_global"],
78
+ "time_created": v["time_created"],
79
+ "time_updated": v["time_updated"],
80
+ "sessions": v["sessions"],
81
+ "cost": v["cost"],
82
+ "tokens_input": v["tokens_input"],
83
+ "tokens_output": v["tokens_output"],
84
+ "tokens_reasoning": v["tokens_reasoning"],
85
+ "tokens_total": v["tokens_total"],
86
+ }
87
+ )
88
+ print_json(data)
89
+ return 0
90
+
91
+ from ..formatters import print_table_simple
92
+
93
+ if not views:
94
+ print(_("projects.none"))
95
+ return 0
96
+
97
+ table = []
98
+ for v in views:
99
+ name = _("projects.global") if v["is_global"] else (v["name"] or "—")
100
+ path = "—" if v["is_global"] else shorten_path(v["path"], 40)
101
+ if v["exists"] is False:
102
+ path = f"{path} ({_('projects.missing')})"
103
+ updated = v["time_updated"][:10] if v["time_updated"] else "—"
104
+
105
+ table.append(
106
+ [
107
+ v["id"][:12],
108
+ name[:20],
109
+ path,
110
+ updated,
111
+ str(v["sessions"]),
112
+ format_tokens(v["tokens_total"]),
113
+ format_cost(v["cost"]),
114
+ ]
115
+ )
116
+
117
+ print_table_simple(
118
+ [
119
+ _("projects.header.id"),
120
+ _("projects.header.name"),
121
+ _("projects.header.path"),
122
+ _("projects.header.activity"),
123
+ _("projects.header.sessions"),
124
+ _("projects.header.tokens"),
125
+ _("projects.header.cost"),
126
+ ],
127
+ table,
128
+ )
129
+
130
+ return 0
@@ -93,6 +93,7 @@ L: dict[str, dict[str, str]] = {
93
93
  "list.header.id": {"ru": "ID", "en": "ID"},
94
94
  "list.header.title": {"ru": "Название", "en": "Title"},
95
95
  "list.header.project": {"ru": "Проект", "en": "Project"},
96
+ "list.header.directory": {"ru": "Директория", "en": "Directory"},
96
97
  "list.header.model": {"ru": "Модель", "en": "Model"},
97
98
  "list.header.activity": {"ru": "Активность", "en": "Activity"},
98
99
  "list.header.messages": {"ru": "Сообщ.", "en": "Msgs"},
@@ -303,10 +304,14 @@ L: dict[str, dict[str, str]] = {
303
304
  # ==================================================================
304
305
  "projects.header.id": {"ru": "ID", "en": "ID"},
305
306
  "projects.header.name": {"ru": "Название", "en": "Name"},
307
+ "projects.header.path": {"ru": "Путь", "en": "Path"},
306
308
  "projects.header.activity": {"ru": "Активность", "en": "Activity"},
307
309
  "projects.header.sessions": {"ru": "Сессии", "en": "Sessions"},
308
310
  "projects.header.tokens": {"ru": "Токены", "en": "Tokens"},
309
311
  "projects.header.cost": {"ru": "Стоимость", "en": "Cost"},
312
+ "projects.global": {"ru": "Global (без корня)", "en": "Global (no project root)"},
313
+ "projects.missing": {"ru": "не найдена", "en": "not found"},
314
+ "projects.none": {"ru": "Проекты не найдены.", "en": "No projects found."},
310
315
  # ==================================================================
311
316
  # STATS
312
317
  # ==================================================================
@@ -468,6 +473,27 @@ L: dict[str, dict[str, str]] = {
468
473
  "help.cmd.search": {"ru": "Поиск по сообщениям", "en": "Search messages"},
469
474
  "help.cmd.tree": {"ru": "Дерево ветвления сессий", "en": "Session branching tree"},
470
475
  "help.cmd.projects": {"ru": "Список проектов", "en": "Project list"},
476
+ "help.cmd.directories": {"ru": "Директории запусков", "en": "Launch directories"},
477
+ "help.directories.e0": {"ru": "Все директории с сессиями", "en": "All directories with sessions"},
478
+ "help.directories.e1": {"ru": "Только отсутствующие директории", "en": "Only missing directories"},
479
+ "help.directories.e2": {"ru": "Вывод в JSON", "en": "JSON output"},
480
+ "help.directories.e3": {"ru": "Не проверять файловую систему", "en": "Skip filesystem check"},
481
+ "directories.title": {"ru": "Директории запусков OpenCode", "en": "OpenCode launch directories"},
482
+ "directories.header.path": {"ru": "Путь", "en": "Path"},
483
+ "directories.header.status": {"ru": "Статус", "en": "Status"},
484
+ "directories.header.project": {"ru": "Проект", "en": "Project"},
485
+ "directories.header.sessions": {"ru": "Сессии", "en": "Sessions"},
486
+ "directories.header.last": {"ru": "Последняя", "en": "Last"},
487
+ "directories.header.cost": {"ru": "Стоимость", "en": "Cost"},
488
+ "directories.exists": {"ru": "да", "en": "yes"},
489
+ "directories.missing": {"ru": "не найдена", "en": "not found"},
490
+ "directories.unknown": {"ru": "?", "en": "?"},
491
+ "directories.global": {"ru": "Global (без корня)", "en": "Global (no project root)"},
492
+ "directories.none": {"ru": "Директории не найдены.", "en": "No directories found."},
493
+ "directories.total": {"ru": "Всего директорий: {n}", "en": "Directories: {n}"},
494
+ "directories.summary": {"ru": "Директорий: {total}, сессий: {sessions}, отсутствует: {missing}", "en": "Directories: {total}, sessions: {sessions}, missing: {missing}"},
495
+ "directories.conflict": {"ru": "Нельзя одновременно использовать --missing и --existing.", "en": "Cannot use --missing and --existing together."},
496
+
471
497
  "help.cmd.stats": {"ru": "Статистика БД", "en": "Database statistics"},
472
498
  "help.cmd.todos": {"ru": "Задачи из сессий", "en": "Tasks from sessions"},
473
499
  "help.cmd.vacuum": {"ru": "Оптимизация БД", "en": "Database optimization"},
@@ -494,6 +520,7 @@ L: dict[str, dict[str, str]] = {
494
520
  "help.list.e2": {"ru": "Сортировка по стоимости", "en": "Sort by cost"},
495
521
  "help.list.e3": {"ru": "Фильтр по проекту", "en": "Filter by project"},
496
522
  "help.list.e4": {"ru": "Вывод в JSON", "en": "JSON output"},
523
+ "help.list.e5": {"ru": "Показать директории сессий", "en": "Show session directories"},
497
524
  "help.info.e0": {
498
525
  "ru": "Все метаданные, токены, todo, ветвление",
499
526
  "en": "Metadata, tokens, todos, branching",
@@ -544,6 +571,8 @@ L: dict[str, dict[str, str]] = {
544
571
  "help.tree.e2": {"ru": "Глубина 3 уровня", "en": "Max depth of 3"},
545
572
  "help.projects.e0": {"ru": "Проекты со статистикой", "en": "Projects with statistics"},
546
573
  "help.projects.e1": {"ru": "Вывод в JSON", "en": "JSON output"},
574
+ "help.projects.e2": {"ru": "Скрыть Global-проект", "en": "Hide the Global project"},
575
+ "help.projects.e3": {"ru": "Только проекты с отсутствующим путём", "en": "Only projects with a missing path"},
547
576
  "help.stats.e0": {
548
577
  "ru": "Сессии, сообщения, токены, стоимость, топ моделей",
549
578
  "en": "Sessions, messages, tokens, costs, top models",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import sqlite3
6
7
  from typing import Any
7
8
 
@@ -210,6 +211,129 @@ class BaseStore:
210
211
  ).fetchall()
211
212
  return [dict(row) for row in rows]
212
213
 
214
+ def get_directories(self, check_exists: bool = True) -> list[dict[str, Any]]:
215
+ """Aggregate directories from sessions, projects and project mappings."""
216
+ entries: dict[str, dict[str, Any]] = {}
217
+
218
+ def ensure(path: str, project_id: str | None, source: str) -> dict[str, Any]:
219
+ normalized = os.path.normpath(path)
220
+ entry = entries.setdefault(
221
+ normalized,
222
+ {
223
+ "path": normalized,
224
+ "sessions": 0,
225
+ "first_ts": None,
226
+ "last_ts": None,
227
+ "tokens_input": 0,
228
+ "tokens_output": 0,
229
+ "tokens_reasoning": 0,
230
+ "tokens_cache_read": 0,
231
+ "tokens_cache_write": 0,
232
+ "cost": 0.0,
233
+ "project_ids": set(),
234
+ "session_projects": {},
235
+ "project_sources": set(),
236
+ "sources": set(),
237
+ },
238
+ )
239
+ entry["sources"].add(source)
240
+ if project_id:
241
+ entry["project_ids"].add(project_id)
242
+ if source == "sessions":
243
+ entry["session_projects"][project_id] = (
244
+ entry["session_projects"].get(project_id, 0)
245
+ )
246
+ else:
247
+ entry["project_sources"].add(project_id)
248
+ return entry
249
+
250
+ session_rows = self.conn.execute(
251
+ f"""
252
+ SELECT directory, project_id,
253
+ COUNT(*) AS sessions,
254
+ MIN(time_created) AS first_ts,
255
+ MAX(time_created) AS last_ts,
256
+ COALESCE(SUM(tokens_input), 0) AS tokens_input,
257
+ COALESCE(SUM(tokens_output), 0) AS tokens_output,
258
+ COALESCE(SUM(tokens_reasoning), 0) AS tokens_reasoning,
259
+ COALESCE(SUM(tokens_cache_read), 0) AS tokens_cache_read,
260
+ COALESCE(SUM(tokens_cache_write), 0) AS tokens_cache_write,
261
+ COALESCE(SUM(cost), 0) AS cost
262
+ FROM {self.session_table}
263
+ WHERE directory IS NOT NULL AND directory != ''
264
+ GROUP BY directory, project_id
265
+ """
266
+ ).fetchall()
267
+ for row in session_rows:
268
+ entry = ensure(row["directory"], row["project_id"], "sessions")
269
+ entry["sessions"] += int(row["sessions"])
270
+ entry["session_projects"][row["project_id"]] = int(row["sessions"])
271
+ for key in ("tokens_input", "tokens_output", "tokens_reasoning",
272
+ "tokens_cache_read", "tokens_cache_write"):
273
+ entry[key] += int(row[key] or 0)
274
+ entry["cost"] += float(row["cost"] or 0)
275
+ first_ts = row["first_ts"]
276
+ last_ts = row["last_ts"]
277
+ if first_ts is not None:
278
+ entry["first_ts"] = first_ts if entry["first_ts"] is None else min(entry["first_ts"], first_ts)
279
+ if last_ts is not None:
280
+ entry["last_ts"] = last_ts if entry["last_ts"] is None else max(entry["last_ts"], last_ts)
281
+
282
+ project_rows = self.conn.execute(
283
+ "SELECT id, name, worktree FROM project WHERE worktree IS NOT NULL AND worktree != ''"
284
+ ).fetchall()
285
+ project_meta = {row["id"]: dict(row) for row in project_rows}
286
+ for row in project_rows:
287
+ if row["id"] == "global" and os.path.normpath(row["worktree"]) == "/":
288
+ continue
289
+ ensure(row["worktree"], row["id"], "project")
290
+
291
+ if "project_directory" in self.schema.tables:
292
+ directory_rows = self.conn.execute(
293
+ """
294
+ SELECT project_id, directory
295
+ FROM project_directory
296
+ WHERE directory IS NOT NULL AND directory != ''
297
+ """
298
+ ).fetchall()
299
+ for row in directory_rows:
300
+ ensure(row["directory"], row["project_id"], "project_directory")
301
+
302
+ for entry in entries.values():
303
+ candidates = [
304
+ project_id
305
+ for project_id in entry["project_ids"]
306
+ if entry["session_projects"].get(project_id, 0) > 0
307
+ ]
308
+ if not candidates:
309
+ candidates = list(entry["project_ids"])
310
+ non_global = [project_id for project_id in candidates if project_id != "global"]
311
+ pool = non_global or candidates
312
+ chosen = max(
313
+ pool,
314
+ key=lambda project_id: entry["session_projects"].get(project_id, 0),
315
+ ) if pool else None
316
+
317
+ entry["project_id"] = chosen
318
+ entry["project_ids"] = sorted(entry["project_ids"])
319
+ entry["is_global"] = chosen == "global"
320
+ entry["sources"] = sorted(entry["sources"])
321
+ if chosen == "global":
322
+ entry["project_label"] = _("directories.global")
323
+ elif chosen:
324
+ meta = project_meta.get(chosen, {})
325
+ label = meta.get("name")
326
+ if not label:
327
+ worktree = meta.get("worktree") or ""
328
+ label = os.path.basename(str(worktree).rstrip("/")) or chosen[:12]
329
+ entry["project_label"] = str(label)
330
+ else:
331
+ entry["project_label"] = "—"
332
+
333
+ entry["exists"] = os.path.isdir(entry["path"]) if check_exists else None
334
+
335
+ return list(entries.values())
336
+
213
337
  def get_cost_total(self, project_id: str | None = None) -> dict[str, Any]:
214
338
  where = "WHERE s.project_id = ?" if project_id else ""
215
339
  params: list[Any] = [project_id] if project_id else []
@@ -136,7 +136,7 @@ class V1Store(BaseStore):
136
136
  order = sort_map.get(sort, sort_map["date"])
137
137
  rows = self.conn.execute(
138
138
  f"""
139
- SELECT s.id, s.title, s.model, s.project_id, s.agent,
139
+ SELECT s.id, s.title, s.model, s.project_id, s.agent, s.directory,
140
140
  s.cost, s.tokens_input, s.tokens_output,
141
141
  MAX(m.time_created) AS last_time,
142
142
  COUNT(DISTINCT m.id) AS msg_count
@@ -329,7 +329,7 @@ class V2Store(BaseStore):
329
329
  order = sort_map.get(sort, sort_map["date"])
330
330
  rows = self.conn.execute(
331
331
  f"""
332
- SELECT s.id, s.title, s.model, s.project_id, s.agent,
332
+ SELECT s.id, s.title, s.model, s.project_id, s.agent, s.directory,
333
333
  s.cost, s.tokens_input, s.tokens_output,
334
334
  MAX(m.time_created) AS last_time,
335
335
  COUNT(DISTINCT m.id) AS msg_count
@@ -37,6 +37,14 @@ def summarize_val(v, max_len=120) -> str:
37
37
  return s
38
38
 
39
39
 
40
+ def shorten_path(path, max_len=48) -> str:
41
+ """Обрезает длинный путь слева, сохраняя конечные сегменты."""
42
+ s = str(path)
43
+ if len(s) <= max_len:
44
+ return s
45
+ return "…" + s[-(max_len - 1) :]
46
+
47
+
40
48
  def parse_date(date_str: str) -> datetime | None:
41
49
  """Парсит дату в формате ГГГГ-ММ-ДД."""
42
50
  if not date_str or not isinstance(date_str, str):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-db
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
5
5
  Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
6
6
  Project-URL: Source, https://github.com/VasilevNStas/opencode-db
@@ -32,6 +32,7 @@ uv tool install opencode-db
32
32
 
33
33
  ```bash
34
34
  opencode-db list # recent sessions
35
+ opencode-db directories # launch directories, including missing ones
35
36
  opencode-db view # interactive session viewer (colored, scrollable)
36
37
  opencode-db stats # database summary
37
38
  opencode-db schema # detect v1/v2 schema
@@ -44,6 +45,7 @@ opencode-db help # full reference
44
45
 
45
46
  - **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less`
46
47
  - **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
48
+ - **`directories`** (`dirs`) — все директории запусков, включая отсутствующие; `list --directory` добавляет путь к сессии
47
49
  - **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
48
50
  - **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
49
51
  - **`costs`** / **`stats`** — аналитика токенов и расходов
@@ -63,4 +65,6 @@ pip install opencode-db # zero external dependencies
63
65
  - [OpenCode](https://opencode.ai) (must have been run at least once)
64
66
  - macOS / Linux
65
67
 
68
+ **Directory support:** `global` is a synthetic project for non-Git directories; `directories` shows the real `session.directory` and marks it as `Global (no project root)`. Missing paths are detected with `os.path.isdir()` at command runtime and can be filtered with `--missing`.
69
+
66
70
  **Schema support:** OpenCode 1.19.x (v1) — read and write; OpenCode 2.x preview (v2) — read-only. Run `opencode-db schema` to check the detected schema. On v2, `delete`, `prune`, and `vacuum` are refused until write support is implemented.
@@ -23,6 +23,7 @@ opencode_db.egg-info/top_level.txt
23
23
  opencode_db/commands/__init__.py
24
24
  opencode_db/commands/cmd_costs.py
25
25
  opencode_db/commands/cmd_delete.py
26
+ opencode_db/commands/cmd_directories.py
26
27
  opencode_db/commands/cmd_export.py
27
28
  opencode_db/commands/cmd_help.py
28
29
  opencode_db/commands/cmd_info.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opencode-db"
7
- version = "0.8.0"
7
+ version = "0.8.2"
8
8
  description = "OpenCode database CLI manager — browse, export, analyze, clean up sessions"
9
9
  readme = "docs/README_PYPI.md"
10
10
  requires-python = ">=3.12"
@@ -1,4 +1,5 @@
1
1
  import argparse
2
+ import json
2
3
  from unittest.mock import patch
3
4
 
4
5
 
@@ -699,3 +700,87 @@ class TestViewCommand:
699
700
  sty = _init_styles(raw=True)
700
701
  text = _format_session(db, info, [], sty)
701
702
  assert "конец сессии" in text or "end of session" in text
703
+
704
+
705
+ class TestDirectoriesCommand:
706
+ def test_directories_json_includes_project_paths(self, db, capsys) -> None:
707
+ from opencode_db.commands.cmd_directories import run
708
+
709
+ args = _ns(
710
+ missing=False,
711
+ existing=False,
712
+ project=None,
713
+ sort="path",
714
+ limit=50,
715
+ no_check=True,
716
+ json=True,
717
+ )
718
+ assert run(args, db) == 0
719
+ data = json.loads(capsys.readouterr().out)
720
+ by_path = {item["path"]: item for item in data}
721
+ assert by_path["/home/user/proj1"]["sessions"] == 2
722
+ assert by_path["/home/user/proj1"]["project"] == "My Project"
723
+ assert by_path["/home/user/proj2"]["sessions"] == 0
724
+ assert by_path["/home/user/proj2"]["project"] == "proj2"
725
+
726
+ def test_directories_missing_and_existing_conflict(self, db) -> None:
727
+ from opencode_db.commands.cmd_directories import run
728
+
729
+ args = _ns(missing=True, existing=True, project=None, sort="date", limit=50, no_check=True, json=True)
730
+ assert run(args, db) == 1
731
+
732
+ def test_list_directory_json(self, db, capsys) -> None:
733
+ from opencode_db.commands.cmd_list import run
734
+
735
+ args = _ns(
736
+ limit=10,
737
+ offset=0,
738
+ project=None,
739
+ agent=None,
740
+ model=None,
741
+ sort="date",
742
+ json=True,
743
+ all=False,
744
+ directory=True,
745
+ )
746
+ assert run(args, db) == 0
747
+ data = json.loads(capsys.readouterr().out)
748
+ assert data[0]["directory"] == "/home/user/proj1"
749
+
750
+ def test_projects_show_paths_and_global(self, db, capsys) -> None:
751
+ from opencode_db.commands.cmd_projects import run
752
+
753
+ args = _ns(json=True, no_global=False, missing=False)
754
+ assert run(args, db) == 0
755
+ data = json.loads(capsys.readouterr().out)
756
+ by_id = {item["id"]: item for item in data}
757
+ assert by_id["proj_002"]["name"] == "proj2"
758
+ assert by_id["proj_002"]["path"] == "/home/user/proj2"
759
+ assert by_id["proj_002"]["exists"] is False
760
+
761
+
762
+ def test_dirs_alias_is_registered(self, db) -> None:
763
+ from opencode_db.cli import build_parser
764
+ from opencode_db.commands import get_handler
765
+
766
+ parser = build_parser()
767
+ args = parser.parse_args(["dirs", "--json"])
768
+ assert args.command == "dirs"
769
+ assert get_handler("dirs") is get_handler("directories")
770
+
771
+ def test_list_directory_table_handles_null(self, db, capsys) -> None:
772
+ from opencode_db.commands.cmd_list import run
773
+
774
+ args = _ns(
775
+ limit=10,
776
+ offset=0,
777
+ project=None,
778
+ agent=None,
779
+ model=None,
780
+ sort="date",
781
+ json=False,
782
+ all=False,
783
+ directory=True,
784
+ )
785
+ assert run(args, db) == 0
786
+ assert "None" not in capsys.readouterr().out
@@ -280,3 +280,102 @@ def test_v2_delete_is_refused(v2db, capsys) -> None:
280
280
  def test_unknown_message_type_is_ignored(v2db) -> None:
281
281
  store = open_store(v2db)
282
282
  assert len(store.get_messages("ses_v2")) == 2
283
+
284
+
285
+ def test_v2_directories_and_global(v2db, capsys) -> None:
286
+ from opencode_db.commands import cmd_directories
287
+
288
+ v2db.execute(
289
+ "INSERT INTO project VALUES (?, ?, ?, ?, ?)",
290
+ ("global", "/", None, TS, TS),
291
+ )
292
+ v2db.execute(
293
+ "INSERT INTO session_v2 (id, directory, project_id, time_created, time_updated) VALUES (?, ?, ?, ?, ?)",
294
+ ("ses_global", "/tmp/v2-global", "global", TS, TS),
295
+ )
296
+
297
+ cmd_directories.run(
298
+ _ns(
299
+ missing=False,
300
+ existing=False,
301
+ project=None,
302
+ sort="path",
303
+ limit=50,
304
+ no_check=True,
305
+ json=True,
306
+ ),
307
+ v2db,
308
+ )
309
+ data = json.loads(capsys.readouterr().out)
310
+ by_path = {item["path"]: item for item in data}
311
+ assert "/" not in by_path
312
+ assert by_path["/tmp/v2-global"]["is_global"] is True
313
+ from opencode_db.i18n import _
314
+
315
+ assert by_path["/tmp/v2-global"]["project"] == _("directories.global")
316
+ assert by_path["/tmp/v2"]["project"] == "V2 Project"
317
+
318
+
319
+ def test_v2_directories_missing_filter(v2db, tmp_path, capsys) -> None:
320
+ from opencode_db.commands import cmd_directories
321
+
322
+ present = tmp_path / "present"
323
+ present.mkdir()
324
+ missing = tmp_path / "gone"
325
+ v2db.execute(
326
+ "INSERT INTO session_v2 (id, directory, project_id, time_created, time_updated) VALUES (?, ?, ?, ?, ?)",
327
+ ("ses_present", str(present), "proj_v2", TS, TS),
328
+ )
329
+ v2db.execute(
330
+ "INSERT INTO session_v2 (id, directory, project_id, time_created, time_updated) VALUES (?, ?, ?, ?, ?)",
331
+ ("ses_missing", str(missing), "proj_v2", TS, TS),
332
+ )
333
+
334
+ cmd_directories.run(
335
+ _ns(
336
+ missing=True,
337
+ existing=False,
338
+ project=None,
339
+ sort="path",
340
+ limit=50,
341
+ no_check=False,
342
+ json=True,
343
+ ),
344
+ v2db,
345
+ )
346
+ data = json.loads(capsys.readouterr().out)
347
+ paths = {item["path"] for item in data}
348
+ assert str(missing) in paths
349
+ assert str(present) not in paths
350
+ assert all(item["exists"] is False for item in data)
351
+
352
+ cmd_directories.run(
353
+ _ns(
354
+ missing=False,
355
+ existing=True,
356
+ project=None,
357
+ sort="path",
358
+ limit=50,
359
+ no_check=False,
360
+ json=True,
361
+ ),
362
+ v2db,
363
+ )
364
+ data = json.loads(capsys.readouterr().out)
365
+ assert str(present) in {item["path"] for item in data}
366
+
367
+
368
+ def test_v2_projects_global_and_paths(v2db, capsys) -> None:
369
+ from opencode_db.commands import cmd_projects
370
+
371
+ v2db.execute(
372
+ "INSERT INTO project VALUES (?, ?, ?, ?, ?)",
373
+ ("global", "/", None, TS, TS),
374
+ )
375
+ cmd_projects.run(_ns(json=True, no_global=False, missing=False), v2db)
376
+ data = json.loads(capsys.readouterr().out)
377
+ by_id = {item["id"]: item for item in data}
378
+ assert by_id["global"]["is_global"] is True
379
+ assert by_id["global"]["path"] is None
380
+ assert by_id["proj_v2"]["path"] == "/tmp/v2"
381
+ assert by_id["proj_v2"]["name"] == "V2 Project"
@@ -1,83 +0,0 @@
1
- """Команда projects: список проектов со статистикой."""
2
-
3
- import argparse
4
- from typing import Literal
5
-
6
- from ..i18n import _
7
- from ..store import open_store
8
- from ..utils import build_help_epilog, format_cost, format_tokens, format_ts
9
-
10
- _PROJECTS_EXAMPLES = [
11
- ("", "help.projects.e0"),
12
- ("--json", "help.projects.e1"),
13
- ]
14
-
15
-
16
- def register(subparsers) -> None:
17
- p = subparsers.add_parser(
18
- "projects",
19
- help=_("help.cmd.projects"),
20
- formatter_class=argparse.RawDescriptionHelpFormatter,
21
- epilog=build_help_epilog("projects", _PROJECTS_EXAMPLES),
22
- )
23
- p.add_argument("--json", action="store_true", help="JSON output")
24
-
25
-
26
- def run(args, db) -> Literal[0]:
27
- rows = open_store(db).get_projects()
28
-
29
- if args.json:
30
- from ..formatters import print_json
31
-
32
- data = []
33
- for r in rows:
34
- data.append(
35
- {
36
- "id": r["id"],
37
- "name": r["name"],
38
- "worktree": r["worktree"],
39
- "time_created": format_ts(r["time_created"]) if r["time_created"] else None,
40
- "time_updated": format_ts(r["time_updated"]) if r["time_updated"] else None,
41
- "sessions": r["session_count"],
42
- "cost": r["total_cost"],
43
- "tokens_input": r["total_ti"],
44
- "tokens_output": r["total_to"],
45
- "tokens_reasoning": r["total_tr"],
46
- "tokens_total": r["total_ti"] + r["total_to"] + r["total_tr"],
47
- }
48
- )
49
- print_json(data)
50
- return 0
51
-
52
- from ..formatters import print_table_simple
53
-
54
- table = []
55
- for r in rows:
56
- name = r["name"] or "—"
57
- updated = format_ts(r["time_updated"], "%Y-%m-%d") if r["time_updated"] else "—"
58
- total_tokens = r["total_ti"] + r["total_to"] + r["total_tr"]
59
-
60
- table.append(
61
- [
62
- r["id"][:12],
63
- name[:20],
64
- updated,
65
- str(r["session_count"]),
66
- format_tokens(total_tokens),
67
- format_cost(r["total_cost"]),
68
- ]
69
- )
70
-
71
- print_table_simple(
72
- [
73
- _("projects.header.id"),
74
- _("projects.header.name"),
75
- _("projects.header.activity"),
76
- _("projects.header.sessions"),
77
- _("projects.header.tokens"),
78
- _("projects.header.cost"),
79
- ],
80
- table,
81
- )
82
-
83
- return 0
File without changes
File without changes