obris-cli 0.6.0__tar.gz → 0.6.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 (49) hide show
  1. {obris_cli-0.6.0 → obris_cli-0.6.2}/PKG-INFO +1 -1
  2. {obris_cli-0.6.0 → obris_cli-0.6.2}/pyproject.toml +1 -1
  3. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/sync.py +23 -14
  4. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/mapping.py +22 -6
  5. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/resolver.py +15 -1
  6. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/runner.py +82 -1
  7. {obris_cli-0.6.0 → obris_cli-0.6.2}/uv.lock +1 -1
  8. {obris_cli-0.6.0 → obris_cli-0.6.2}/.claude/settings.local.json +0 -0
  9. {obris_cli-0.6.0 → obris_cli-0.6.2}/.gitignore +0 -0
  10. {obris_cli-0.6.0 → obris_cli-0.6.2}/README.md +0 -0
  11. {obris_cli-0.6.0 → obris_cli-0.6.2}/ruff.toml +0 -0
  12. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/__init__.py +0 -0
  13. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/api/__init__.py +0 -0
  14. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/api/client.py +0 -0
  15. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/api/knowledge.py +0 -0
  16. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/api/topics.py +0 -0
  17. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/assets/icon.png +0 -0
  18. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/auth/__init__.py +0 -0
  19. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/auth/session.py +0 -0
  20. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/cli.py +0 -0
  21. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/__init__.py +0 -0
  22. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/auth.py +0 -0
  23. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/env.py +0 -0
  24. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/knowledge.py +0 -0
  25. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/save.py +0 -0
  26. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/sync_config.py +0 -0
  27. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/sync_conflicts.py +0 -0
  28. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/topic.py +0 -0
  29. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/config.py +0 -0
  30. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/output.py +0 -0
  31. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/routes.py +0 -0
  32. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/__init__.py +0 -0
  33. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/commands.py +0 -0
  34. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/constants.py +0 -0
  35. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/engine/__init__.py +0 -0
  36. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/engine/filters.py +0 -0
  37. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/engine/manifest.py +0 -0
  38. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/engine/run.py +0 -0
  39. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/engine/subtree.py +0 -0
  40. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/engine/tracked.py +0 -0
  41. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/exclusions.py +0 -0
  42. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/io.py +0 -0
  43. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/models.py +0 -0
  44. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/scanner.py +0 -0
  45. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/state.py +0 -0
  46. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/utils/__init__.py +0 -0
  47. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/utils/capture.py +0 -0
  48. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/utils/notify.py +0 -0
  49. {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/utils/upload.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obris-cli
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Save, organize, and access your knowledge from the command line
5
5
  Project-URL: Homepage, https://obris.ai
6
6
  Project-URL: Repository, https://github.com/obris-dev/obris
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "obris-cli"
3
- version = "0.6.0"
3
+ version = "0.6.2"
4
4
  description = "Save, organize, and access your knowledge from the command line"
5
5
 
6
6
  requires-python = ">=3.10"
@@ -8,7 +8,7 @@ from obris.commands.sync_conflicts import register as _register_conflicts_subgro
8
8
  from obris.output import as_json, is_json
9
9
  from obris.sync.commands import add_file, link_file
10
10
  from obris.sync.resolver import assert_all_roots, find_root_id, resolve_targets
11
- from obris.sync.runner import run_sync_pass
11
+ from obris.sync.runner import preview_first_sync, run_sync_pass
12
12
  from obris.sync.state import SyncState
13
13
 
14
14
 
@@ -66,19 +66,28 @@ def sync(ctx, path, topic_id, item_ids, include_patterns, dry_run, add_all_files
66
66
  sync_dir = Path(path).resolve()
67
67
  patterns = list(include_patterns) if include_patterns else None
68
68
 
69
- targets = resolve_targets(sync_dir, topic_id, no_create=no_create)
70
- assert_all_roots(targets, sync_dir)
71
-
72
- totals = run_sync_pass(
73
- sync_dir,
74
- targets,
75
- item_ids,
76
- patterns,
77
- dry_run=dry_run,
78
- add_all_files=add_all_files,
79
- allow_subtopics=not no_subtopics,
80
- verbose=verbose,
81
- )
69
+ targets = resolve_targets(sync_dir, topic_id, no_create=no_create, dry_run=dry_run)
70
+
71
+ if not targets:
72
+ # dry-run + no state + no --topic: preview the bootstrap +
73
+ # initial-add locally without creating a phantom server topic.
74
+ totals = preview_first_sync(
75
+ sync_dir,
76
+ add_all_files=add_all_files,
77
+ allow_subtopics=not no_subtopics,
78
+ )
79
+ else:
80
+ assert_all_roots(targets, sync_dir)
81
+ totals = run_sync_pass(
82
+ sync_dir,
83
+ targets,
84
+ item_ids,
85
+ patterns,
86
+ dry_run=dry_run,
87
+ add_all_files=add_all_files,
88
+ allow_subtopics=not no_subtopics,
89
+ verbose=verbose,
90
+ )
82
91
 
83
92
  if is_json():
84
93
  as_json(
@@ -118,14 +118,30 @@ _PATH_SEP_TRANSLATE = str.maketrans({"/": "-", "\\": "-"})
118
118
  def slugify_topic_name(name: str) -> str:
119
119
  """Convert a topic name into a safe directory component.
120
120
 
121
- Strips path separators (prevents traversal), drops Windows-hostile
122
- trailing chars, preserves unicode/spaces, no case-folding. Returns
123
- "untitled" if the name slugifies away to nothing.
121
+ Translates path separators to dashes — actual traversal defense, not
122
+ aesthetics and bottoms out at "untitled" when the name leaves
123
+ nothing safe to use (empty, ``.``, or ``..``). Otherwise the topic
124
+ name passes through unchanged: leading dots, trailing dots, leading
125
+ or trailing whitespace, unicode, case — all preserved.
126
+
127
+ Why so minimal: if a topic was created with a particular name (via
128
+ the CLI itself, the web app, or an MCP tool), that name *is* the
129
+ user's intent. Stripping leading dots silently renames ``.claude/``
130
+ to ``claude/`` on first sync; stripping trailing dots breaks
131
+ ``foo./``; trimming whitespace breaks ``" intentional "``.
132
+ Cross-platform pain (Windows-hostile trailing dots, APFS case
133
+ folding) is addressed when it actually comes up, not pre-emptively
134
+ at the cost of common AI-tool config dirs.
124
135
  """
125
- name = (name or "").strip()
136
+ name = name or ""
126
137
  name = name.translate(_PATH_SEP_TRANSLATE)
127
- name = Path(name).name # defense-in-depth against any remaining traversal
128
- name = name.strip(" .")
138
+ name = Path(name).name # blocks `.` and leftover `/foo` traversal
139
+ # ``Path("..").name`` keeps ``..`` verbatim (POSIX quirk: it's a
140
+ # legal path component, just dangerous as a directory name).
141
+ # Catch it explicitly so a topic named ``..`` doesn't materialize
142
+ # as the parent directory at sync time.
143
+ if name == "..":
144
+ name = ""
129
145
  return name or "untitled"
130
146
 
131
147
 
@@ -13,7 +13,13 @@ from .state import SyncState
13
13
  _MAX_ANCESTOR_DEPTH = 64
14
14
 
15
15
 
16
- def resolve_targets(sync_dir: Path, topic_id: str | None, *, no_create: bool = False) -> list[tuple]:
16
+ def resolve_targets(
17
+ sync_dir: Path,
18
+ topic_id: str | None,
19
+ *,
20
+ no_create: bool = False,
21
+ dry_run: bool = False,
22
+ ) -> list[tuple]:
17
23
  """Determine which topic(s) to sync.
18
24
 
19
25
  Returns a list of ``(SyncState | None, topic_id, topic_name)``
@@ -21,6 +27,11 @@ def resolve_targets(sync_dir: Path, topic_id: str | None, *, no_create: bool = F
21
27
  new root topic named after the directory is created automatically.
22
28
  Pass ``no_create=True`` to error instead of bootstrapping — the
23
29
  safety net for AI agents and scripts that don't want surprises.
30
+
31
+ With ``dry_run=True``, the bootstrap branch returns an empty list
32
+ instead of calling ``create_topic`` so a preview of a fresh-dir
33
+ sync doesn't leave a phantom topic on the server. The caller
34
+ emits a local-only preview when this happens.
24
35
  """
25
36
  if topic_id:
26
37
  state = SyncState.load(topic_id, sync_dir)
@@ -42,6 +53,9 @@ def resolve_targets(sync_dir: Path, topic_id: str | None, *, no_create: bool = F
42
53
  f"Run without --no-create to create one, or pass --topic <id> to link an existing topic."
43
54
  )
44
55
 
56
+ if dry_run:
57
+ return []
58
+
45
59
  topic_name = sync_dir.name
46
60
  topic = create_topic(topic_name)
47
61
  click.echo(f'Created topic "{topic_name}" ({topic.id}).')
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from pathlib import Path
5
+ from pathlib import Path, PurePosixPath
6
6
 
7
7
  import click
8
8
 
@@ -168,3 +168,84 @@ def _print_capped(items, cap):
168
168
  click.echo(f" {line}")
169
169
  if cap is not None and len(items) > cap:
170
170
  click.echo(f" ... ({len(items) - cap} more)")
171
+
172
+
173
+ def preview_first_sync(sync_dir: Path, *, add_all_files: bool, allow_subtopics: bool) -> dict:
174
+ """Local-only dry-run preview for a fresh directory.
175
+
176
+ Called when ``resolve_targets`` returned ``[]`` (dry-run + no
177
+ state + no ``--topic``). Side-effect free: walks the dir using a
178
+ synthetic empty ``SyncState`` so ``find_untracked`` reuses the
179
+ same exclusion + symlink logic the live path uses, then prints
180
+ what create_topic / _ensure_subtopic_path / add_all would do.
181
+
182
+ Ignored files are surfaced unconditionally — the user explicitly
183
+ asked for the full picture before committing to a real sync.
184
+
185
+ Returns the same totals dict shape as ``run_sync_pass`` so the
186
+ JSON output stays consistent.
187
+ """
188
+ sync_dir = Path(sync_dir).resolve()
189
+ topic_name = sync_dir.name
190
+ click.echo(" (dry run — no changes will be made)")
191
+ click.echo(f' Would create topic "{topic_name}" at {sync_dir}/')
192
+
193
+ synthetic = SyncState("__preview__", str(sync_dir))
194
+ scan = find_untracked(sync_dir, [(synthetic, "__preview__")])
195
+
196
+ # Subtopic projection: every directory segment under sync_dir that
197
+ # contains an untracked file would become a subtopic level. Mirrors
198
+ # the cache walk in ``scanner._ensure_subtopic_path``.
199
+ subdirs: set[str] = set()
200
+ skipped_subdir = 0
201
+ if allow_subtopics:
202
+ for rel in scan.untracked:
203
+ rel_dir = str(PurePosixPath(rel).parent)
204
+ if rel_dir == ".":
205
+ continue
206
+ walked = ""
207
+ for seg in rel_dir.split("/"):
208
+ walked = f"{walked}/{seg}" if walked else seg
209
+ subdirs.add(walked)
210
+ else:
211
+ skipped_subdir = sum(1 for rel in scan.untracked if "/" in rel)
212
+
213
+ if subdirs:
214
+ click.echo(f"\nWould create {len(subdirs)} subtopic(s):")
215
+ for sd in sorted(subdirs):
216
+ click.echo(f" {sd}/")
217
+
218
+ if scan.untracked:
219
+ if not add_all_files:
220
+ click.echo(f"\n{len(scan.untracked)} untracked file(s) (would NOT add — re-run with --add-all):")
221
+ _print_capped(scan.untracked, None)
222
+ elif not allow_subtopics:
223
+ top_level = [r for r in scan.untracked if "/" not in r]
224
+ click.echo(f"\nWould add {len(top_level)} top-level file(s):")
225
+ _print_capped(top_level, None)
226
+ if skipped_subdir:
227
+ click.echo(f"\nWould skip {skipped_subdir} file(s) in subdirs (--no-subtopics):")
228
+ _print_capped([r for r in scan.untracked if "/" in r], None)
229
+ else:
230
+ click.echo(f"\nWould add {len(scan.untracked)} file(s):")
231
+ _print_capped(scan.untracked, None)
232
+
233
+ if scan.excluded:
234
+ click.echo(f"\n{len(scan.excluded)} file(s) matched ignore rules:")
235
+ _print_capped(scan.excluded, None)
236
+
237
+ if scan.symlinks:
238
+ click.echo(f"\n{len(scan.symlinks)} symlink(s) skipped:")
239
+ _print_capped([f"{p} -> {t}" for p, t in scan.symlinks], None)
240
+
241
+ return {
242
+ "pulled": 0,
243
+ "pushed": 0,
244
+ "conflicts": 0,
245
+ "errors": 0,
246
+ "untracked": list(scan.untracked),
247
+ "excluded_count": len(scan.excluded),
248
+ "symlinks": [{"path": p, "target": t} for p, t in scan.symlinks],
249
+ "conflicts_pending": [],
250
+ "missing_local": [],
251
+ }
@@ -141,7 +141,7 @@ wheels = [
141
141
 
142
142
  [[package]]
143
143
  name = "obris-cli"
144
- version = "0.6.0"
144
+ version = "0.6.2"
145
145
  source = { editable = "." }
146
146
  dependencies = [
147
147
  { name = "click" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes