opencontextengine-client 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
oce_client/models.py ADDED
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+
6
+
7
+ class FileStatus(str, Enum):
8
+ PRESENT = "present"
9
+ SKIPPED = "skipped"
10
+ DELETED = "deleted"
11
+
12
+
13
+ class BlobStatus(str, Enum):
14
+ LOCAL = "local"
15
+ UPLOADED = "uploaded"
16
+ READY = "ready"
17
+ FAILED = "failed"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class FileRecord:
22
+ path: str
23
+ blob_name: str | None
24
+ status: FileStatus
25
+ content: str | None = None
26
+ size: int = 0
27
+ mtime_ns: int | None = None
28
+ source: str = "filesystem"
29
+ generation: int = 0
30
+ committed_blob_name: str | None = None
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class BlobUpload:
35
+ path: str
36
+ content: str
37
+ blob_name: str
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class BlobDelta:
42
+ checkpoint_id: str | None
43
+ added_blobs: tuple[str, ...] = ()
44
+ deleted_blobs: tuple[str, ...] = ()
45
+
46
+ def to_api_dict(self) -> dict[str, object]:
47
+ return {
48
+ "checkpoint_id": self.checkpoint_id or "",
49
+ "added_blobs": list(self.added_blobs),
50
+ "deleted_blobs": list(self.deleted_blobs),
51
+ }
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class UploadPlan:
56
+ uploads: tuple[BlobUpload, ...]
57
+ delta: BlobDelta
58
+ skipped_paths: tuple[str, ...] = ()
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class UploadResult:
63
+ blob_names: tuple[str, ...]
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class MissingResult:
68
+ unknown_blob_names: tuple[str, ...] = ()
69
+ nonindexed_blob_names: tuple[str, ...] = ()
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class BlobStatusResult:
74
+ unknown_blob_names: tuple[str, ...] = ()
75
+ nonindexed_blob_names: tuple[str, ...] = ()
76
+ checkpoint_not_found: bool = False
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class CheckpointResult:
81
+ new_checkpoint_id: str
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class RetrievalResult:
86
+ formatted_retrieval: str
87
+ elapsed_ms: int = 0
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class SyncResult:
92
+ uploaded_blob_names: tuple[str, ...]
93
+ checkpoint_id: str | None
94
+ added_blobs: tuple[str, ...]
95
+ deleted_blobs: tuple[str, ...]
96
+
97
+
98
+ @dataclass
99
+ class WorkspaceSnapshot:
100
+ files: dict[str, FileRecord] = field(default_factory=dict)
101
+ checkpoint_id: str | None = None
102
+ generation: int = 0
oce_client/ports.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from collections.abc import Callable
5
+ from typing import Protocol, Sequence
6
+
7
+ from .models import (
8
+ BlobStatusResult,
9
+ BlobUpload,
10
+ CheckpointResult,
11
+ MissingResult,
12
+ RetrievalResult,
13
+ UploadResult,
14
+ )
15
+
16
+
17
+ class BlobIdentity(Protocol):
18
+ def calculate(self, path: str, content: str) -> str: ...
19
+
20
+
21
+ class BlobApi(Protocol):
22
+ def find_missing(self, blob_names: Sequence[str]) -> MissingResult: ...
23
+
24
+ def batch_upload(self, blobs: Sequence[BlobUpload]) -> UploadResult: ...
25
+
26
+ def blob_status(
27
+ self, blob_names: Sequence[str], checkpoint_id: str | None = None
28
+ ) -> BlobStatusResult: ...
29
+
30
+ def checkpoint(
31
+ self,
32
+ checkpoint_id: str | None,
33
+ added_blobs: Sequence[str],
34
+ deleted_blobs: Sequence[str],
35
+ ) -> CheckpointResult: ...
36
+
37
+ def retrieve(
38
+ self,
39
+ query: str,
40
+ checkpoint_id: str | None,
41
+ added_blobs: Sequence[str],
42
+ deleted_blobs: Sequence[str],
43
+ ) -> RetrievalResult: ...
44
+
45
+
46
+ class StateStore(Protocol):
47
+ def load_snapshot(self): ...
48
+
49
+
50
+ class WatchHandle(Protocol):
51
+ def stop(self) -> None: ...
52
+
53
+ def join(self, timeout: float | None = None) -> None: ...
54
+
55
+
56
+ class FileSource(Protocol):
57
+ def scan(self, root: Path, matcher: IgnoreMatcher) -> dict[str, str]: ...
58
+
59
+ def read(self, path: Path) -> str: ...
60
+
61
+
62
+ class IgnoreMatcher(Protocol):
63
+ def ignores(self, path: str, *, is_dir: bool = False) -> bool: ...
64
+
65
+
66
+ class Watcher(Protocol):
67
+ def start(self, callback: Callable[[], None]) -> WatchHandle: ...
oce_client/runtime.py ADDED
@@ -0,0 +1,245 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from collections.abc import Iterable, Sequence
7
+
8
+ from .context import WorkspaceContext
9
+ from .defaults import DEFAULT_API_KEY, DEFAULT_API_URL
10
+ from .http import OceHttpClient
11
+
12
+
13
+ class ClientConfigurationError(ValueError):
14
+ """Raised when a CLI/MCP runtime cannot be configured safely."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ClientSettings:
19
+ root: Path
20
+ api_url: str
21
+ api_key: str
22
+ state_path: Path | None = None
23
+ runtime_patterns: tuple[str, ...] = ()
24
+
25
+ @classmethod
26
+ def from_environment(
27
+ cls,
28
+ *,
29
+ root: str | os.PathLike[str] | None = None,
30
+ api_url: str | None = None,
31
+ api_key: str | None = None,
32
+ state_path: str | os.PathLike[str] | None = None,
33
+ runtime_patterns: Iterable[str] | None = None,
34
+ require_api_key: bool = True,
35
+ ) -> "ClientSettings":
36
+ resolved_root = Path(
37
+ root if root is not None else os.environ.get("OCE_WORKSPACE", ".")
38
+ ).expanduser().resolve()
39
+ resolved_url = (
40
+ api_url if api_url is not None else os.environ.get("OCE_API_URL", DEFAULT_API_URL)
41
+ ).strip()
42
+ resolved_key = api_key if api_key is not None else os.environ.get(
43
+ "OCE_API_KEY", DEFAULT_API_KEY
44
+ )
45
+ resolved_state = _resolve_path_option(state_path, "OCE_STATE_PATH")
46
+ if not resolved_url:
47
+ raise ClientConfigurationError("OCE API URL must not be empty")
48
+ if require_api_key and not resolved_key:
49
+ raise ClientConfigurationError(
50
+ "OCE API key is required; set OCE_API_KEY"
51
+ )
52
+ return cls(
53
+ root=resolved_root,
54
+ api_url=resolved_url.rstrip("/"),
55
+ api_key=resolved_key,
56
+ state_path=resolved_state,
57
+ runtime_patterns=tuple(
58
+ runtime_patterns
59
+ if runtime_patterns is not None
60
+ else iter_runtime_patterns((os.environ.get("OCE_IGNORE", ""),))
61
+ ),
62
+ )
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class McpConfiguration:
67
+ """Fully resolved configuration for the long-running MCP process."""
68
+
69
+ client: ClientSettings
70
+ workspace_roots: tuple[Path, ...]
71
+ state_dir: Path | None
72
+ debounce_ms: int
73
+ initial_sync: str
74
+ ready_timeout: float
75
+ log_level: str
76
+
77
+ @classmethod
78
+ def from_environment(
79
+ cls,
80
+ *,
81
+ workspace_roots: Sequence[str | os.PathLike[str]] | None = None,
82
+ api_url: str | None = None,
83
+ state_path: str | os.PathLike[str] | None = None,
84
+ state_dir: str | os.PathLike[str] | None = None,
85
+ runtime_patterns: Iterable[str] | None = None,
86
+ debounce_ms: int | None = None,
87
+ initial_sync: str | None = None,
88
+ ready_timeout: float | None = None,
89
+ log_level: str | None = None,
90
+ ) -> "McpConfiguration":
91
+ roots = _resolve_mcp_roots(workspace_roots)
92
+ if not roots:
93
+ raise ClientConfigurationError(
94
+ "MCP requires at least one workspace; pass --workspace or set "
95
+ "OCE_WORKSPACE/OCE_WORKSPACES"
96
+ )
97
+
98
+ resolved_state_dir = _resolve_path_option(state_dir, "OCE_STATE_DIR")
99
+ client = ClientSettings.from_environment(
100
+ root=roots[0],
101
+ api_url=api_url,
102
+ state_path=state_path,
103
+ runtime_patterns=runtime_patterns,
104
+ require_api_key=True,
105
+ )
106
+ if client.state_path is not None and resolved_state_dir is not None:
107
+ raise ClientConfigurationError(
108
+ "MCP state configuration is ambiguous; choose OCE_STATE_PATH/--state-path "
109
+ "or OCE_STATE_DIR/--state-dir"
110
+ )
111
+ if len(roots) > 1 and client.state_path is not None and resolved_state_dir is None:
112
+ raise ClientConfigurationError(
113
+ "multiple MCP workspaces cannot share OCE_STATE_PATH; use --state-dir "
114
+ "or OCE_STATE_DIR"
115
+ )
116
+
117
+ resolved_debounce = _resolve_int_option(
118
+ debounce_ms, "OCE_DEBOUNCE_MS", 500
119
+ )
120
+ if resolved_debounce < 0:
121
+ raise ClientConfigurationError("debounce-ms must not be negative")
122
+ resolved_initial = (
123
+ initial_sync
124
+ if initial_sync is not None
125
+ else os.environ.get("OCE_INITIAL_SYNC", "background")
126
+ ).strip().lower()
127
+ if resolved_initial not in {"background", "blocking", "off"}:
128
+ raise ClientConfigurationError(
129
+ "initial-sync must be one of: background, blocking, off"
130
+ )
131
+ resolved_timeout = _resolve_float_option(
132
+ ready_timeout, "OCE_READY_TIMEOUT", 3.0
133
+ )
134
+ if resolved_timeout < 0:
135
+ raise ClientConfigurationError("ready-timeout must not be negative")
136
+ resolved_log_level = (
137
+ log_level
138
+ if log_level is not None
139
+ else os.environ.get("OCE_LOG_LEVEL", "warning")
140
+ ).strip().lower()
141
+ if resolved_log_level not in {"debug", "info", "warning", "error", "critical"}:
142
+ raise ClientConfigurationError(
143
+ "log-level must be one of: debug, info, warning, error, critical"
144
+ )
145
+ return cls(
146
+ client=client,
147
+ workspace_roots=roots,
148
+ state_dir=resolved_state_dir,
149
+ debounce_ms=resolved_debounce,
150
+ initial_sync=resolved_initial,
151
+ ready_timeout=resolved_timeout,
152
+ log_level=resolved_log_level,
153
+ )
154
+
155
+
156
+ class ClientRuntime:
157
+ """Lazy, closeable context shared by one CLI command or MCP process."""
158
+
159
+ def __init__(self, settings: ClientSettings) -> None:
160
+ self.settings = settings
161
+ self._context: WorkspaceContext | None = None
162
+
163
+ def context(self) -> WorkspaceContext:
164
+ if self._context is None:
165
+ if not self.settings.root.is_dir():
166
+ raise ClientConfigurationError(
167
+ f"workspace is not a directory: {self.settings.root}"
168
+ )
169
+ api = OceHttpClient(self.settings.api_url, self.settings.api_key)
170
+ try:
171
+ self._context = WorkspaceContext.open(
172
+ self.settings.root,
173
+ api,
174
+ state_path=self.settings.state_path,
175
+ runtime_patterns=self.settings.runtime_patterns,
176
+ )
177
+ except Exception:
178
+ api.close()
179
+ raise
180
+ return self._context
181
+
182
+ def close(self) -> None:
183
+ if self._context is not None:
184
+ self._context.close()
185
+ self._context = None
186
+
187
+ def __enter__(self) -> "ClientRuntime":
188
+ return self
189
+
190
+ def __exit__(self, *_: object) -> None:
191
+ self.close()
192
+
193
+
194
+ def iter_runtime_patterns(values: Iterable[str]) -> tuple[str, ...]:
195
+ patterns: list[str] = []
196
+ for value in values:
197
+ for comma_group in value.replace("\r", "\n").split(","):
198
+ patterns.extend(
199
+ line.strip() for line in comma_group.splitlines() if line.strip()
200
+ )
201
+ return tuple(patterns)
202
+
203
+
204
+ def _resolve_path_option(
205
+ value: str | os.PathLike[str] | None,
206
+ environment_name: str,
207
+ ) -> Path | None:
208
+ raw = value if value is not None else os.environ.get(environment_name)
209
+ return None if raw is None or not str(raw).strip() else Path(raw).expanduser().resolve()
210
+
211
+
212
+ def _resolve_mcp_roots(
213
+ values: Sequence[str | os.PathLike[str]] | None,
214
+ ) -> tuple[Path, ...]:
215
+ if values is None:
216
+ raw_values: Sequence[str | os.PathLike[str]]
217
+ configured = os.environ.get("OCE_WORKSPACES")
218
+ if configured:
219
+ raw_values = tuple(part for part in configured.split(os.pathsep) if part)
220
+ else:
221
+ single = os.environ.get("OCE_WORKSPACE")
222
+ raw_values = () if single is None or not single.strip() else (single,)
223
+ else:
224
+ raw_values = values
225
+ return tuple(dict.fromkeys(Path(value).expanduser().resolve() for value in raw_values))
226
+
227
+
228
+ def _resolve_int_option(value: int | None, environment_name: str, default: int) -> int:
229
+ raw = value if value is not None else os.environ.get(environment_name)
230
+ if raw is None:
231
+ return default
232
+ try:
233
+ return int(raw)
234
+ except (TypeError, ValueError) as exc:
235
+ raise ClientConfigurationError(f"{environment_name} must be an integer") from exc
236
+
237
+
238
+ def _resolve_float_option(value: float | None, environment_name: str, default: float) -> float:
239
+ raw = value if value is not None else os.environ.get(environment_name)
240
+ if raw is None:
241
+ return default
242
+ try:
243
+ return float(raw)
244
+ except (TypeError, ValueError) as exc:
245
+ raise ClientConfigurationError(f"{environment_name} must be a number") from exc
@@ -0,0 +1,136 @@
1
+ ---
2
+ name: oce-client
3
+ description: "Use the oce-client CLI to synchronize one local workspace and retrieve current code context from OpenContextEngine."
4
+ ---
5
+
6
+ # OpenContextEngine CLI
7
+
8
+ This skill documents the `oce-client` command-line interface for an AI agent.
9
+ It is a CLI workflow: invoke a command, read its result, and continue the task.
10
+
11
+ ## 1. Command Reference
12
+
13
+ ### Version and package identity
14
+
15
+ - Distribution package: `opencontextengine-client`
16
+ - Python package: `oce_client`
17
+ - CLI executable: `oce-client`
18
+ - Check the installed CLI version with `oce-client --version`.
19
+
20
+ ### Global options
21
+
22
+ Global options must appear before the subcommand:
23
+
24
+ ```text
25
+ oce-client --root <workspace> --api-url <url> --state-path <file> <command>
26
+ ```
27
+
28
+ - `--root`: workspace directory; otherwise use `OCE_WORKSPACE`, then the
29
+ current directory.
30
+ - `--api-url`: service URL; otherwise use `OCE_API_URL`, then
31
+ `http://127.0.0.1:8986`.
32
+ - `--state-path`: SQLite state file; otherwise use `OCE_STATE_PATH`, then
33
+ `<workspace>/.oce-client/state.sqlite3`.
34
+ - `--ignore PATTERN`: add a runtime ignore pattern; repeat it when needed.
35
+
36
+ The API key has no CLI option. Load it through `OCE_API_KEY`; the local default
37
+ is `sk-opencontextengine`.
38
+
39
+ ### Workspace commands
40
+
41
+ ```text
42
+ oce-client --root <workspace> sync [--json]
43
+ oce-client --root <workspace> status [--json]
44
+ oce-client --root <workspace> retrieve [--scope workspace|working_set] [--json] <query>
45
+ oce-client --root <workspace> observe <path> [--content <text> | --file <file>] [--json]
46
+ oce-client --root <workspace> remove <path> [--json]
47
+ oce-client --root <workspace> watch [--debounce-ms <milliseconds>]
48
+ oce-client skill path [--json]
49
+ oce-client skill install [--target <directory>] [--force] [--json]
50
+ ```
51
+
52
+ - `sync` scans the workspace, uploads new or changed blobs, and commits a new
53
+ server checkpoint. This is the command that makes retrieval reflect the
54
+ current files.
55
+ - `status` reads only the local SQLite inventory and checkpoint. It does not
56
+ contact the service and does not require an API key.
57
+ - `retrieve` sends a natural-language code question to the service using the
58
+ last local checkpoint. Use `--scope` only when the task requires the
59
+ corresponding retrieval scope.
60
+ - `observe` stages explicit editor content in the local state. It does not
61
+ publish the content until `sync` runs.
62
+ - `remove` stages a workspace-relative deletion. It also requires `sync` to
63
+ publish the change.
64
+ - `watch` keeps a foreground process alive and incrementally syncs filesystem
65
+ changes. Run an initial successful `sync` before starting it.
66
+
67
+ `skill path` and `skill install` are installation maintenance commands; they
68
+ are not part of normal code retrieval.
69
+
70
+ ## 2. Workflow
71
+
72
+ ### One-shot retrieval
73
+
74
+ Use this workflow when no long-running watcher is already maintaining the
75
+ workspace:
76
+
77
+ ```text
78
+ 1. Set OCE_WORKSPACE, OCE_API_URL, and OCE_API_KEY in the process environment.
79
+ 2. Run `oce-client --root <workspace> sync --json`.
80
+ 3. Run `oce-client --root <workspace> retrieve --json "<natural-language question>"`.
81
+ 4. Parse JSON from stdout and use `formatted_retrieval` as code context.
82
+ ```
83
+
84
+ Run `sync` again before a later retrieval when files may have changed. The
85
+ client persists inventory and checkpoint state, so unchanged files are not
86
+ uploaded again.
87
+
88
+ ### Session retrieval with a watcher
89
+
90
+ For several questions against the same workspace:
91
+
92
+ ```text
93
+ 1. Run one successful `sync`.
94
+ 2. Start `oce-client --root <workspace> watch` and keep it running.
95
+ 3. Invoke `retrieve --json` for each AI question while the watcher is alive.
96
+ 4. Stop the watcher when the workspace session ends.
97
+ ```
98
+
99
+ The watcher handles changed paths incrementally. If it is stopped, return to
100
+ the one-shot workflow and run `sync` before retrieving.
101
+
102
+ ### Unsaved editor state
103
+
104
+ When the agent has content that is not yet written to disk:
105
+
106
+ ```text
107
+ oce-client --root <workspace> observe src/example.py --content "<text>"
108
+ oce-client --root <workspace> sync --json
109
+ oce-client --root <workspace> retrieve --json "<question>"
110
+ ```
111
+
112
+ Use `remove` followed by `sync` for an unsaved deletion.
113
+
114
+ ## 3. Important Notes
115
+
116
+ - This CLI handles one workspace per invocation. Pass the workspace explicitly
117
+ when the agent knows it; do not accidentally index the agent's own process
118
+ directory.
119
+ - Keep `OCE_API_KEY` in the environment or a secret manager. Never put the key
120
+ in prompts, command arguments, logs, JSON output, or committed files.
121
+ - Use `--json` whenever another program or agent will consume the result.
122
+ Treat stdout as the data channel and stderr as diagnostics.
123
+ - A successful `status` only proves that local state exists; it does not prove
124
+ that the files on disk have been synchronized. Do not claim that retrieval is
125
+ current until `sync` has succeeded or an active `watch` has processed the
126
+ changes.
127
+ - `sync` and `retrieve` can take time on a first run or after a large change.
128
+ Do not retry them concurrently against the same state file.
129
+ - Use only the commands and options documented above. In particular, do not
130
+ invent a background or initial-sync option for this one-shot CLI.
131
+ - Retrieval describes the current code on disk and the selected checkpoint. It
132
+ has no version-control history or knowledge of previous commits.
133
+ - Quote workspace paths and natural-language queries. Use workspace-relative
134
+ paths with `observe` and `remove`.
135
+ - A zero exit code means the command completed successfully. A non-zero exit
136
+ code means the result should not be treated as valid context; inspect stderr.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Oce Client"
3
+ short_description: "Sync and retrieve workspace code"
4
+ default_prompt: "Use $oce-client to retrieve the relevant code context for this task."