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.
- {obris_cli-0.6.0 → obris_cli-0.6.2}/PKG-INFO +1 -1
- {obris_cli-0.6.0 → obris_cli-0.6.2}/pyproject.toml +1 -1
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/sync.py +23 -14
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/mapping.py +22 -6
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/resolver.py +15 -1
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/runner.py +82 -1
- {obris_cli-0.6.0 → obris_cli-0.6.2}/uv.lock +1 -1
- {obris_cli-0.6.0 → obris_cli-0.6.2}/.claude/settings.local.json +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/.gitignore +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/README.md +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/ruff.toml +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/__init__.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/api/__init__.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/api/client.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/api/knowledge.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/api/topics.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/assets/icon.png +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/auth/__init__.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/auth/session.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/cli.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/__init__.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/auth.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/env.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/knowledge.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/save.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/sync_config.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/sync_conflicts.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/commands/topic.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/config.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/output.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/routes.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/__init__.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/commands.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/constants.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/engine/__init__.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/engine/filters.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/engine/manifest.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/engine/run.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/engine/subtree.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/engine/tracked.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/exclusions.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/io.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/models.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/scanner.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/sync/state.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/utils/__init__.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/utils/capture.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/utils/notify.py +0 -0
- {obris_cli-0.6.0 → obris_cli-0.6.2}/src/obris/utils/upload.py +0 -0
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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 =
|
|
136
|
+
name = name or ""
|
|
126
137
|
name = name.translate(_PATH_SEP_TRANSLATE)
|
|
127
|
-
name = Path(name).name #
|
|
128
|
-
name
|
|
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(
|
|
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
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|