obris-cli 0.6.2__tar.gz → 0.7.0__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 (50) hide show
  1. {obris_cli-0.6.2 → obris_cli-0.7.0}/PKG-INFO +1 -1
  2. {obris_cli-0.6.2 → obris_cli-0.7.0}/pyproject.toml +1 -1
  3. obris_cli-0.7.0/src/obris/commands/sync_config.py +290 -0
  4. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/engine/run.py +7 -1
  5. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/exclusions.py +35 -29
  6. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/runner.py +20 -5
  7. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/state.py +43 -21
  8. {obris_cli-0.6.2 → obris_cli-0.7.0}/uv.lock +1 -1
  9. obris_cli-0.6.2/src/obris/commands/sync_config.py +0 -178
  10. {obris_cli-0.6.2 → obris_cli-0.7.0}/.claude/settings.local.json +0 -0
  11. {obris_cli-0.6.2 → obris_cli-0.7.0}/.gitignore +0 -0
  12. {obris_cli-0.6.2 → obris_cli-0.7.0}/README.md +0 -0
  13. {obris_cli-0.6.2 → obris_cli-0.7.0}/ruff.toml +0 -0
  14. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/__init__.py +0 -0
  15. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/api/__init__.py +0 -0
  16. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/api/client.py +0 -0
  17. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/api/knowledge.py +0 -0
  18. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/api/topics.py +0 -0
  19. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/assets/icon.png +0 -0
  20. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/auth/__init__.py +0 -0
  21. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/auth/session.py +0 -0
  22. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/cli.py +0 -0
  23. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/__init__.py +0 -0
  24. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/auth.py +0 -0
  25. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/env.py +0 -0
  26. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/knowledge.py +0 -0
  27. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/save.py +0 -0
  28. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/sync.py +0 -0
  29. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/sync_conflicts.py +0 -0
  30. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/topic.py +0 -0
  31. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/config.py +0 -0
  32. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/output.py +0 -0
  33. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/routes.py +0 -0
  34. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/__init__.py +0 -0
  35. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/commands.py +0 -0
  36. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/constants.py +0 -0
  37. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/engine/__init__.py +0 -0
  38. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/engine/filters.py +0 -0
  39. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/engine/manifest.py +0 -0
  40. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/engine/subtree.py +0 -0
  41. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/engine/tracked.py +0 -0
  42. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/io.py +0 -0
  43. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/mapping.py +0 -0
  44. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/models.py +0 -0
  45. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/resolver.py +0 -0
  46. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/scanner.py +0 -0
  47. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/utils/__init__.py +0 -0
  48. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/utils/capture.py +0 -0
  49. {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/utils/notify.py +0 -0
  50. {obris_cli-0.6.2 → obris_cli-0.7.0}/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.2
3
+ Version: 0.7.0
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.2"
3
+ version = "0.7.0"
4
4
  description = "Save, organize, and access your knowledge from the command line"
5
5
 
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,290 @@
1
+ """Per-checkout sync configuration subcommands.
2
+
3
+ Hosts ``exclude`` / ``include`` / ``untrack`` — commands that modify
4
+ state-file metadata for a synced directory. Kept out of
5
+ ``commands.sync`` so the sync group definition + invocation logic
6
+ stays under the 300-line cap and these subcommands stay together
7
+ because they share the ``_states_for_current_dir`` resolver.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ import click
15
+ import pathspec
16
+ from pathspec.patterns.gitwildmatch import GitWildMatchPattern
17
+
18
+ from obris.sync.exclusions import ExclusionMatcher
19
+ from obris.sync.state import SyncState
20
+
21
+
22
+ def _states_for_current_dir(path):
23
+ """Return ``(sync_dir, states)`` for ``path``, exiting cleanly if unsynced."""
24
+ sync_dir = Path(path).resolve()
25
+ states = SyncState.find_all_for_path(sync_dir)
26
+ if not states:
27
+ raise SystemExit(f"No synced topic found for {sync_dir}/. Run 'obris sync --topic <id>' first.")
28
+ return sync_dir, states
29
+
30
+
31
+ def register(sync):
32
+ """Attach the config subcommands to the given Click group."""
33
+
34
+ @sync.command("exclude")
35
+ @click.argument("patterns", nargs=-1)
36
+ @click.option("--path", "-p", default=".", help="Sync directory (defaults to current directory)")
37
+ @click.option("--list", "list_only", is_flag=True, help="Print current settings")
38
+ def sync_exclude(patterns, path, list_only):
39
+ """Stop syncing files that match a pattern.
40
+
41
+ \b
42
+ obris sync exclude scratch/ # an entire folder
43
+ obris sync exclude notes.draft.md # a specific file
44
+ obris sync exclude '*.draft.md' # all drafts
45
+ obris sync exclude --list # show current settings
46
+
47
+ Wins over any prior ``obris sync include`` for the same
48
+ pattern, so the user-facing rule stays simple: "the last
49
+ thing you said is what happens."
50
+ """
51
+ sync_dir, states = _states_for_current_dir(path)
52
+
53
+ if list_only:
54
+ if patterns:
55
+ raise click.UsageError("Cannot combine --list with patterns.")
56
+ _list_settings(states)
57
+ return
58
+
59
+ if not patterns:
60
+ raise click.UsageError("Provide one or more patterns, or use --list.")
61
+
62
+ results = _apply_pattern_action(states, patterns, action="exclude", sync_dir=sync_dir)
63
+ _emit_outcomes(sync_dir, results, action="exclude")
64
+
65
+ @sync.command("include")
66
+ @click.argument("patterns", nargs=-1, required=True)
67
+ @click.option("--path", "-p", default=".", help="Sync directory (defaults to current directory)")
68
+ def sync_include(patterns, path):
69
+ """Force a file or pattern to sync, overriding any exclusion.
70
+
71
+ Use this whenever ``obris sync`` says a file is being skipped
72
+ and you actually want it. Works whether the file was being
73
+ excluded by a built-in default or by a prior
74
+ ``obris sync exclude``.
75
+
76
+ \b
77
+ obris sync include .env.example # sync this even though
78
+ # .env.* is excluded by default
79
+ obris sync include scratch/draft.md # sync one file inside an
80
+ # excluded folder
81
+ obris sync include '*.draft.md' # un-exclude a pattern you
82
+ # previously excluded
83
+
84
+ Wins over any prior ``obris sync exclude`` for the same pattern
85
+ — last call wins.
86
+ """
87
+ sync_dir, states = _states_for_current_dir(path)
88
+ results = _apply_pattern_action(states, patterns, action="include", sync_dir=sync_dir)
89
+ _emit_outcomes(sync_dir, results, action="include")
90
+
91
+ @sync.command("untrack")
92
+ @click.argument("targets", nargs=-1, required=True)
93
+ @click.option("--path", "-p", default=".", help="Sync directory (defaults to current directory)")
94
+ def sync_untrack(targets, path):
95
+ """Stop syncing items without deleting either side.
96
+
97
+ Each TARGET is a knowledge ID or a tracked filename (basename).
98
+ Removes the sync link; both the local file and the remote item stay
99
+ in place. Subsequent 'obris sync' calls will not re-pull these items.
100
+
101
+ \b
102
+ obris sync untrack abc123
103
+ obris sync untrack notes.md draft.md
104
+
105
+ Re-link later with 'obris sync link <file> -i <id>' or 'obris sync add'.
106
+ Permanently delete the remote with 'obris knowledge delete <id>'.
107
+ """
108
+ sync_dir, states = _states_for_current_dir(path)
109
+
110
+ untracked = []
111
+ not_found = []
112
+ for target in targets:
113
+ matches = _resolve_target(target, states)
114
+ if not matches:
115
+ not_found.append(target)
116
+ continue
117
+ if len(matches) > 1:
118
+ click.echo(f"Ambiguous: '{target}' matches multiple tracked items:")
119
+ for state, kid in matches:
120
+ entry = state.get(kid)
121
+ click.echo(f" {kid} ({entry.filename})")
122
+ click.echo(" Re-run with the specific knowledge ID.")
123
+ raise SystemExit(1)
124
+ state, kid = matches[0]
125
+ entry = state.get(kid)
126
+ state.untrack(kid)
127
+ state.mark_unlinked(kid)
128
+ state.save()
129
+ untracked.append((kid, entry.filename))
130
+
131
+ if untracked:
132
+ click.echo(f"Untracked {len(untracked)} item(s):")
133
+ for kid, name in untracked:
134
+ click.echo(f" {name} ({kid})")
135
+ click.echo(f" Local files and remote items unchanged in {sync_dir}/.")
136
+ if not_found:
137
+ click.echo(f"Not tracked: {', '.join(not_found)}", err=True)
138
+ raise SystemExit(1)
139
+
140
+
141
+ def _apply_pattern_action(states, patterns, *, action, sync_dir):
142
+ """Apply ``exclude`` or ``include`` to each pattern across all states.
143
+
144
+ Returns ``[(pattern, outcome)]`` where outcome is one of
145
+ ``now_skipped``, ``now_synced``, ``already_skipped``,
146
+ ``already_synced``, or ``no_effect``.
147
+
148
+ Strict inverses with last-call-wins semantics *and* exact-cancel:
149
+ when the new action's inverse is already in state we simply remove
150
+ it instead of layering an opposite on top. Net effect per pattern:
151
+
152
+ - prior ``foo`` + ``include foo`` → state minus ``foo``
153
+ - prior ``!foo`` + ``exclude foo`` → state minus ``!foo``
154
+ - empty + ``include foo`` → state plus ``!foo`` (only if
155
+ the pattern actually matches
156
+ a currently-excluded local
157
+ file; otherwise ``no_effect``
158
+ with no state change)
159
+ - empty + ``exclude foo`` → state plus ``foo``
160
+
161
+ Keeps the state at most one entry per pattern, no leftover
162
+ re-include or exclude after a toggle, and no state bloat from
163
+ ``include`` calls that wouldn't have changed anything.
164
+ """
165
+ outcomes = []
166
+ for state, _tid in states:
167
+ for pat in patterns:
168
+ negated = f"!{pat}"
169
+ current = list(state.exclude_patterns)
170
+ if action == "exclude":
171
+ if negated in current:
172
+ # Cancel a prior include — just remove the override,
173
+ # don't also add the exclude. Defaults re-cover the
174
+ # path if the override existed to override one; if
175
+ # not, both calls were no-ops on observable behavior.
176
+ state.remove_excludes([negated])
177
+ state.save()
178
+ outcomes.append((pat, "now_skipped"))
179
+ elif pat in current:
180
+ outcomes.append((pat, "already_skipped"))
181
+ else:
182
+ state.add_excludes([pat])
183
+ state.save()
184
+ outcomes.append((pat, "now_skipped"))
185
+ else:
186
+ if pat in current:
187
+ # Cancel a prior exclude — defaults take over.
188
+ state.remove_excludes([pat])
189
+ state.save()
190
+ outcomes.append((pat, "now_synced"))
191
+ elif negated in current:
192
+ outcomes.append((pat, "already_synced"))
193
+ elif _include_has_effect(sync_dir, pat, current):
194
+ state.add_excludes([negated])
195
+ state.save()
196
+ outcomes.append((pat, "now_synced"))
197
+ else:
198
+ # No currently-excluded file matches this pattern;
199
+ # adding ``!pat`` would be state bloat with zero
200
+ # observable effect. Warn and leave state alone.
201
+ outcomes.append((pat, "no_effect"))
202
+ return outcomes
203
+
204
+
205
+ def _include_has_effect(sync_dir: Path, pattern: str, current_excludes: list[str]) -> bool:
206
+ """Return True if any local file currently excluded would match ``pattern``.
207
+
208
+ Conservative: only files that physically exist under ``sync_dir``
209
+ count. A pattern aimed at a file that doesn't exist yet is treated
210
+ as no-op — the user can re-run after the file lands. Walks
211
+ ``sync_dir`` once per call; cost is negligible compared to a real
212
+ sync's network round trips.
213
+ """
214
+ matcher = ExclusionMatcher(sync_dir, state_excludes=current_excludes)
215
+ pattern_spec = pathspec.PathSpec.from_lines(GitWildMatchPattern, [pattern])
216
+ for path in Path(sync_dir).rglob("*"):
217
+ if path.is_dir() or path.is_symlink():
218
+ continue
219
+ rel = path.relative_to(sync_dir).as_posix()
220
+ if matcher.excludes(rel) and pattern_spec.match_file(rel):
221
+ return True
222
+ return False
223
+
224
+
225
+ def _emit_outcomes(sync_dir, results, *, action):
226
+ """User-facing summary. No mention of state mechanics or `!pattern`."""
227
+ by_outcome: dict[str, set[str]] = {}
228
+ for pat, outcome in results:
229
+ by_outcome.setdefault(outcome, set()).add(pat)
230
+
231
+ changed_any = False
232
+ if action == "exclude":
233
+ if by_outcome.get("now_skipped"):
234
+ click.echo(f"Skipping: {', '.join(sorted(by_outcome['now_skipped']))}")
235
+ changed_any = True
236
+ if by_outcome.get("already_skipped"):
237
+ click.echo(f"Already skipping: {', '.join(sorted(by_outcome['already_skipped']))}")
238
+ else:
239
+ if by_outcome.get("now_synced"):
240
+ click.echo(f"Now syncing: {', '.join(sorted(by_outcome['now_synced']))}")
241
+ changed_any = True
242
+ if by_outcome.get("already_synced"):
243
+ click.echo(f"Already syncing: {', '.join(sorted(by_outcome['already_synced']))}")
244
+ if by_outcome.get("no_effect"):
245
+ pats = sorted(by_outcome["no_effect"])
246
+ click.echo(f"No matching skipped file(s) for: {', '.join(pats)}", err=True)
247
+ click.echo(" Nothing to include. Run again after the file appears, or check spelling.")
248
+
249
+ if changed_any:
250
+ click.echo(f" Takes effect on next 'obris sync' in {sync_dir}/.")
251
+
252
+
253
+ def _list_settings(states):
254
+ """Print currently-configured rules for each state, grouped by intent."""
255
+ for state, tid in states:
256
+ current = state.exclude_patterns
257
+ header = f"Settings for {tid}:" if len(states) > 1 else "Current settings:"
258
+ click.echo(header)
259
+ if not current:
260
+ click.echo(" (none — built-in defaults apply)")
261
+ continue
262
+ skips = [p for p in current if not p.startswith("!")]
263
+ forced_syncs = [p[1:] for p in current if p.startswith("!")]
264
+ if skips:
265
+ click.echo(" Skipping:")
266
+ for p in skips:
267
+ click.echo(f" {p}")
268
+ if forced_syncs:
269
+ click.echo(" Always syncing:")
270
+ for p in forced_syncs:
271
+ click.echo(f" {p}")
272
+
273
+
274
+ def _resolve_target(target, states):
275
+ """Resolve ``target`` (kid or filename) to a list of (state, kid) matches.
276
+
277
+ Filename lookup is by basename only (matches TrackedItem.filename).
278
+ Returns a list because the same filename can exist under different
279
+ topics in the same dir; the caller decides whether to error on
280
+ ambiguity.
281
+ """
282
+ matches = []
283
+ for state, _tid in states:
284
+ if state.is_tracked(target):
285
+ matches.append((state, target))
286
+ continue
287
+ kid, _entry = state.find_by_filename(target)
288
+ if kid:
289
+ matches.append((state, kid))
290
+ return matches
@@ -143,9 +143,15 @@ def run_sync(
143
143
  if filter_item_ids and item.id not in filter_item_ids:
144
144
  continue
145
145
 
146
+ # On a dry-run pass, ``reconcile_topic_dirs`` deliberately doesn't
147
+ # call ``state.set_topic_dir`` (no side effects on the local state
148
+ # file), so ``state.get_topic_dir`` returns None for any subtopic
149
+ # that the live run would create. Fall back to the desired map
150
+ # the manifest already gave us, otherwise dry-run output prints
151
+ # bare basenames with no parent directory context.
146
152
  item_rel = state.get_topic_dir(item.topic_id) if item.topic_id else ""
147
153
  if item_rel is None:
148
- item_rel = ""
154
+ item_rel = desired_topic_dirs.get(item.topic_id, "") if item.topic_id else ""
149
155
 
150
156
  if state.is_tracked(item.id):
151
157
  entry = state.get(item.id)
@@ -1,17 +1,19 @@
1
1
  """File-level exclusion matcher for sync.
2
2
 
3
- Combines three pattern sources, all gitignore-style (``pathspec``'s
4
- ``gitwildmatch``):
5
-
6
- 1. **Built-in defaults** — always applied, hardcoded in this module.
7
- Hides VCS metadata, dependency dirs, OS / editor cruft.
8
- 2. **``.obrisignore``** — user-authored, lives at the sync-dir root.
9
- The CLI reads it; it never writes to it.
10
- 3. **State-level excludes** per-checkout, set via
11
- ``obris sync exclude``. Plumbed through ``state_excludes`` so this
12
- module has no opinion on where they're persisted.
13
-
14
- Supports gitignore semantics including ``!pattern`` re-includes.
3
+ Combines two pattern sources, evaluated in order, gitignore-style
4
+ (``pathspec``'s ``gitwildmatch``):
5
+
6
+ 1. **Built-in defaults** — hardcoded in this module. Hides VCS
7
+ metadata, dependency dirs, OS / editor cruft, and credential-shaped
8
+ files.
9
+ 2. **State-level rules** set via ``obris sync exclude`` and
10
+ ``obris sync include``. Stored as a list of patterns on each
11
+ ``SyncState``; ``!pattern`` entries are re-includes that override
12
+ the defaults. The CLI is the only writer.
13
+
14
+ Last match wins (gitignore semantics), so state-level rules can
15
+ always override defaults. Users never hand-edit a config file —
16
+ state is owned by the CLI.
15
17
  """
16
18
 
17
19
  from __future__ import annotations
@@ -22,6 +24,11 @@ import pathspec
22
24
  from pathspec.patterns.gitwildmatch import GitWildMatchPattern
23
25
 
24
26
  DEFAULT_EXCLUDES = [
27
+ # Obris's own in-dir state. Lives at ``<sync_dir>/.obris/`` and is
28
+ # never knowledge content. Self-excludes to avoid an infinite-loop
29
+ # of "the engine sees state files, would push them, server stores
30
+ # them, sync sees the new items..."
31
+ ".obris/",
25
32
  # VCS
26
33
  ".git/",
27
34
  ".hg/",
@@ -51,21 +58,22 @@ DEFAULT_EXCLUDES = [
51
58
  # Temp
52
59
  "*.tmp",
53
60
  "*.bak",
61
+ # Secrets — common files / dirs that hold credentials. Kept narrow
62
+ # to literal known-bad paths instead of a blanket dotfile rule so
63
+ # AI-tool config dirs (.claude/, .cursor/, .github/, etc.) keep
64
+ # syncing. Users can override any default with
65
+ # ``obris sync include <pattern>``.
66
+ ".env",
67
+ ".env.*",
68
+ ".envrc",
69
+ ".netrc",
70
+ ".npmrc",
71
+ ".pypirc",
72
+ ".aws/",
73
+ ".gnupg/",
74
+ ".ssh/",
54
75
  ]
55
76
 
56
- OBRISIGNORE = ".obrisignore"
57
-
58
-
59
- def _read_obrisignore(sync_dir: Path) -> list[str]:
60
- path = sync_dir / OBRISIGNORE
61
- if not path.exists():
62
- return []
63
- try:
64
- lines = path.read_text(encoding="utf-8").splitlines()
65
- except OSError:
66
- return []
67
- return [line for line in lines if line.strip() and not line.strip().startswith("#")]
68
-
69
77
 
70
78
  class ExclusionMatcher:
71
79
  """Single matcher built once per sync pass, queried per file.
@@ -80,8 +88,6 @@ class ExclusionMatcher:
80
88
  self._sources: list[tuple[str, str]] = []
81
89
  for pat in DEFAULT_EXCLUDES:
82
90
  self._sources.append(("default", pat))
83
- for pat in _read_obrisignore(self.sync_dir):
84
- self._sources.append((OBRISIGNORE, pat))
85
91
  for pat in state_excludes or []:
86
92
  self._sources.append(("state", pat))
87
93
  self._spec = pathspec.PathSpec.from_lines(GitWildMatchPattern, [pat for _, pat in self._sources])
@@ -94,8 +100,8 @@ class ExclusionMatcher:
94
100
  """Return ``[(source_label, pattern), ...]`` for every pattern that matches.
95
101
 
96
102
  A path can match multiple patterns; this returns all of them in
97
- source order (defaults → .obrisignore → state). Used for
98
- diagnostics, not for the actual exclude decision.
103
+ source order (defaults → state). Used for diagnostics, not
104
+ for the actual exclude decision.
99
105
  """
100
106
  matched = []
101
107
  for label, pat in self._sources:
@@ -115,7 +115,7 @@ def run_sync_pass(
115
115
  "remote_updated_at": meta.get("remote_updated_at"),
116
116
  }
117
117
  )
118
- _emit_scan(scan, sync_dir, add_all_files, verbose=verbose)
118
+ _emit_scan(scan, sync_dir, add_all_files, dry_run=dry_run, verbose=verbose)
119
119
 
120
120
  if add_all_files and scan.untracked and not dry_run:
121
121
  # All targets share the same sync_dir; bulk-add against the
@@ -141,14 +141,29 @@ def run_sync_pass(
141
141
  return totals
142
142
 
143
143
 
144
- def _emit_scan(scan, sync_dir, add_all_files, *, verbose=False):
144
+ def _emit_scan(scan, sync_dir, add_all_files, *, dry_run=False, verbose=False):
145
145
  if not scan.untracked and not scan.symlinks and not (verbose and scan.excluded):
146
146
  return
147
147
  cap = None if verbose else _LIST_PREVIEW
148
148
  if scan.untracked:
149
- click.echo(f"\n{len(scan.untracked)} untracked file(s) in {sync_dir}/:")
150
- _print_capped(scan.untracked, cap)
151
- if not add_all_files:
149
+ # Output is shaped by the (dry_run, add_all_files) combination so
150
+ # the user always knows whether the listed files would actually
151
+ # be uploaded:
152
+ # dry-run + --add-all → preview header ("Would add ...")
153
+ # dry-run, no --add-all → preview header ("would not add")
154
+ # live + --add-all → silent here (the real add path prints
155
+ # its own "Added N file(s)" summary)
156
+ # live, no --add-all → the existing "Choose how to proceed"
157
+ # prompt with the add/skip recipes.
158
+ if dry_run and add_all_files:
159
+ click.echo(f"\nWould add {len(scan.untracked)} file(s):")
160
+ _print_capped(scan.untracked, cap)
161
+ elif dry_run:
162
+ click.echo(f"\n{len(scan.untracked)} untracked file(s) (would not add — re-run with --add-all):")
163
+ _print_capped(scan.untracked, cap)
164
+ elif not add_all_files:
165
+ click.echo(f"\n{len(scan.untracked)} untracked file(s) in {sync_dir}/:")
166
+ _print_capped(scan.untracked, cap)
152
167
  click.echo(" Choose how to proceed:")
153
168
  click.echo(" Upload all: obris sync --add-all")
154
169
  click.echo(" Upload some: obris sync add <file>...")
@@ -1,8 +1,14 @@
1
1
  """Sync state persistence and manipulation.
2
2
 
3
- State is stored under ~/.obris/sync/, one file per (root topic, local path).
4
- State is keyed by knowledge_id (the remote item's primary key), not by
5
- filename. Renames on either side are metadata updates, not create+delete.
3
+ State is stored in-place: ``<sync_dir>/.obris/<topic_id>.json``, one
4
+ file per root topic. When the user deletes the sync dir, state goes
5
+ with it clean lifecycle, no orphaned global state, no cross-machine
6
+ collisions when two laptops happen to share a path. Mirrors git's
7
+ ``.git/`` pattern.
8
+
9
+ State is keyed by knowledge_id (the remote item's primary key), not
10
+ by filename. Renames on either side are metadata updates, not
11
+ create+delete.
6
12
 
7
13
  Subtopic support: one state file per *root* topic covers the entire
8
14
  subtree. topic_dirs maps topic_id -> relative directory (POSIX-style)
@@ -10,26 +16,39 @@ under local_path. The CLI only syncs root topics (parent_id = NULL);
10
16
  child topics are not valid sync targets on their own.
11
17
  """
12
18
 
13
- import hashlib
14
19
  import json
15
20
  from pathlib import Path
16
21
 
17
- from obris.config import CONFIG_DIR
18
-
19
22
  from .mapping import now_iso
20
23
  from .models import TrackedItem
21
24
 
22
- SYNC_DIR = CONFIG_DIR / "sync"
25
+ # In-dir state directory name. Excluded by ``DEFAULT_EXCLUDES`` so the
26
+ # engine never tries to sync state files as knowledge.
27
+ STATE_DIR_NAME = ".obris"
28
+
29
+
30
+ def _state_dir(local_path) -> Path:
31
+ return Path(local_path).resolve() / STATE_DIR_NAME
32
+
23
33
 
34
+ def _state_path(topic_id, local_path) -> Path:
35
+ return _state_dir(local_path) / f"{topic_id}.json"
24
36
 
25
- def _state_key(topic_id, local_path):
26
- """Deterministic short hash for a (topic_id, local_path) pair."""
27
- raw = f"{topic_id}:{Path(local_path).resolve()}"
28
- return hashlib.sha256(raw.encode()).hexdigest()[:16]
29
37
 
38
+ def _ensure_state_dir(local_path) -> Path:
39
+ """Create ``<sync_dir>/.obris/`` and seed a self-excluding ``.gitignore``.
30
40
 
31
- def _state_path(topic_id, local_path):
32
- return SYNC_DIR / f"{_state_key(topic_id, local_path)}.json"
41
+ The ``*`` line keeps the state dir out of git/svn checkouts of
42
+ the user's project same trick git uses for ``.git/info``.
43
+ Idempotent: ``mkdir(parents=True, exist_ok=True)`` and we only
44
+ write the gitignore when missing.
45
+ """
46
+ state_dir = _state_dir(local_path)
47
+ state_dir.mkdir(parents=True, exist_ok=True)
48
+ gitignore = state_dir / ".gitignore"
49
+ if not gitignore.exists():
50
+ gitignore.write_text("*\n")
51
+ return state_dir
33
52
 
34
53
 
35
54
  def _read(path):
@@ -74,21 +93,24 @@ class SyncState:
74
93
  def find_all_for_path(cls, local_path):
75
94
  """Find all states for the given local path.
76
95
 
77
- Returns list of (SyncState, topic_id) tuples.
96
+ Walks ``<local_path>/.obris/*.json``. Returns ``[]`` when the
97
+ state dir doesn't exist yet (fresh, never-synced directory).
78
98
  """
79
- resolved = str(Path(local_path).resolve())
80
- if not SYNC_DIR.exists():
99
+ resolved = Path(local_path).resolve()
100
+ state_dir = resolved / STATE_DIR_NAME
101
+ if not state_dir.exists():
81
102
  return []
82
103
  results = []
83
- for f in SYNC_DIR.glob("*.json"):
104
+ for f in sorted(state_dir.glob("*.json")):
84
105
  data = _read(f)
85
- if data and data.get("local_path") == resolved:
86
- topic_id = data.get("topic_id")
87
- results.append((cls(topic_id, local_path, data), topic_id))
106
+ if not data:
107
+ continue
108
+ topic_id = data.get("topic_id") or f.stem
109
+ results.append((cls(topic_id, local_path, data), topic_id))
88
110
  return results
89
111
 
90
112
  def save(self):
91
- SYNC_DIR.mkdir(parents=True, exist_ok=True)
113
+ _ensure_state_dir(self.local_path)
92
114
  path = _state_path(self.topic_id, self.local_path)
93
115
  tmp = path.with_suffix(".tmp")
94
116
  data = {
@@ -141,7 +141,7 @@ wheels = [
141
141
 
142
142
  [[package]]
143
143
  name = "obris-cli"
144
- version = "0.6.2"
144
+ version = "0.7.0"
145
145
  source = { editable = "." }
146
146
  dependencies = [
147
147
  { name = "click" },
@@ -1,178 +0,0 @@
1
- """Per-checkout sync configuration subcommands.
2
-
3
- Hosts ``exclude`` / ``include`` / ``untrack`` — commands that modify
4
- state-file metadata for a synced directory. Kept out of
5
- ``commands.sync`` so the sync group definition + invocation logic
6
- stays under the 300-line cap and these subcommands stay together
7
- because they share the ``_states_for_current_dir`` resolver.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- from pathlib import Path
13
-
14
- import click
15
-
16
- from obris.sync.state import SyncState
17
-
18
-
19
- def _states_for_current_dir(path):
20
- """Return ``(sync_dir, states)`` for ``path``, exiting cleanly if unsynced."""
21
- sync_dir = Path(path).resolve()
22
- states = SyncState.find_all_for_path(sync_dir)
23
- if not states:
24
- raise SystemExit(f"No synced topic found for {sync_dir}/. Run 'obris sync --topic <id>' first.")
25
- return sync_dir, states
26
-
27
-
28
- def register(sync):
29
- """Attach the config subcommands to the given Click group."""
30
-
31
- @sync.command("exclude")
32
- @click.argument("patterns", nargs=-1)
33
- @click.option("--path", "-p", default=".", help="Sync directory (defaults to current directory)")
34
- @click.option("--list", "list_only", is_flag=True, help="Print current exclude patterns and exit")
35
- def sync_exclude(patterns, path, list_only):
36
- """Add file patterns to skip during sync.
37
-
38
- Patterns use gitignore-style syntax. Examples:
39
-
40
- \b
41
- obris sync exclude '*.draft.md' # any markdown draft
42
- obris sync exclude scratch/ # entire directory
43
- obris sync exclude notes/private.md # specific file
44
- obris sync exclude --list # show current patterns
45
-
46
- Patterns apply to all topics syncing this directory. Idempotent —
47
- adding a pattern twice is a no-op. Remove patterns with
48
- 'obris sync include <pattern>'.
49
- """
50
- sync_dir, states = _states_for_current_dir(path)
51
-
52
- if list_only:
53
- if patterns:
54
- raise click.UsageError("Cannot combine --list with patterns.")
55
- for state, tid in states:
56
- current = state.exclude_patterns
57
- header = f"Excludes for {tid}:" if len(states) > 1 else "Current excludes:"
58
- click.echo(header)
59
- if not current:
60
- click.echo(" (none)")
61
- else:
62
- for p in current:
63
- click.echo(f" {p}")
64
- return
65
-
66
- if not patterns:
67
- raise click.UsageError("Provide one or more patterns, or use --list.")
68
-
69
- added_total = []
70
- for state, _tid in states:
71
- added = state.add_excludes(patterns)
72
- if added:
73
- state.save()
74
- added_total.extend(added)
75
-
76
- unique_added = sorted(set(added_total))
77
- skipped = sorted(set(patterns) - set(unique_added))
78
- if unique_added:
79
- click.echo(f"Added {len(unique_added)} pattern(s): {', '.join(unique_added)}")
80
- click.echo(f" Excludes apply on next 'obris sync' in {sync_dir}/.")
81
- if skipped:
82
- click.echo(f"Already present: {', '.join(skipped)}")
83
-
84
- @sync.command("include")
85
- @click.argument("patterns", nargs=-1, required=True)
86
- @click.option("--path", "-p", default=".", help="Sync directory (defaults to current directory)")
87
- def sync_include(patterns, path):
88
- """Remove file patterns from the exclude list (re-enable syncing).
89
-
90
- Inverse of 'obris sync exclude'. Idempotent — removing a pattern
91
- that isn't present is a no-op with a notice.
92
-
93
- obris sync include '*.draft.md'
94
- """
95
- sync_dir, states = _states_for_current_dir(path)
96
-
97
- removed_total = []
98
- for state, _tid in states:
99
- removed = state.remove_excludes(patterns)
100
- if removed:
101
- state.save()
102
- removed_total.extend(removed)
103
-
104
- unique_removed = sorted(set(removed_total))
105
- not_present = sorted(set(patterns) - set(unique_removed))
106
- if unique_removed:
107
- click.echo(f"Removed {len(unique_removed)} pattern(s): {', '.join(unique_removed)}")
108
- click.echo(f" Will sync on next 'obris sync' in {sync_dir}/.")
109
- if not_present:
110
- click.echo(f"Not in exclude list: {', '.join(not_present)}")
111
-
112
- @sync.command("untrack")
113
- @click.argument("targets", nargs=-1, required=True)
114
- @click.option("--path", "-p", default=".", help="Sync directory (defaults to current directory)")
115
- def sync_untrack(targets, path):
116
- """Stop syncing items without deleting either side.
117
-
118
- Each TARGET is a knowledge ID or a tracked filename (basename).
119
- Removes the sync link; both the local file and the remote item stay
120
- in place. Subsequent 'obris sync' calls will not re-pull these items.
121
-
122
- \b
123
- obris sync untrack abc123
124
- obris sync untrack notes.md draft.md
125
-
126
- Re-link later with 'obris sync link <file> -i <id>' or 'obris sync add'.
127
- Permanently delete the remote with 'obris knowledge delete <id>'.
128
- """
129
- sync_dir, states = _states_for_current_dir(path)
130
-
131
- untracked = []
132
- not_found = []
133
- for target in targets:
134
- matches = _resolve_target(target, states)
135
- if not matches:
136
- not_found.append(target)
137
- continue
138
- if len(matches) > 1:
139
- click.echo(f"Ambiguous: '{target}' matches multiple tracked items:")
140
- for state, kid in matches:
141
- entry = state.get(kid)
142
- click.echo(f" {kid} ({entry.filename})")
143
- click.echo(" Re-run with the specific knowledge ID.")
144
- raise SystemExit(1)
145
- state, kid = matches[0]
146
- entry = state.get(kid)
147
- state.untrack(kid)
148
- state.mark_unlinked(kid)
149
- state.save()
150
- untracked.append((kid, entry.filename))
151
-
152
- if untracked:
153
- click.echo(f"Untracked {len(untracked)} item(s):")
154
- for kid, name in untracked:
155
- click.echo(f" {name} ({kid})")
156
- click.echo(f" Local files and remote items unchanged in {sync_dir}/.")
157
- if not_found:
158
- click.echo(f"Not tracked: {', '.join(not_found)}", err=True)
159
- raise SystemExit(1)
160
-
161
-
162
- def _resolve_target(target, states):
163
- """Resolve ``target`` (kid or filename) to a list of (state, kid) matches.
164
-
165
- Filename lookup is by basename only (matches TrackedItem.filename).
166
- Returns a list because the same filename can exist under different
167
- topics in the same dir; the caller decides whether to error on
168
- ambiguity.
169
- """
170
- matches = []
171
- for state, _tid in states:
172
- if state.is_tracked(target):
173
- matches.append((state, target))
174
- continue
175
- kid, _entry = state.find_by_filename(target)
176
- if kid:
177
- matches.append((state, kid))
178
- return matches
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes