allybuild-sdk 1.0.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 (30) hide show
  1. allybuild_sdk-1.0.0/MANIFEST.in +1 -0
  2. allybuild_sdk-1.0.0/PKG-INFO +7 -0
  3. allybuild_sdk-1.0.0/allybuild_sdk/__init__.py +33 -0
  4. allybuild_sdk-1.0.0/allybuild_sdk/agents.py +40 -0
  5. allybuild_sdk-1.0.0/allybuild_sdk/client.py +79 -0
  6. allybuild_sdk-1.0.0/allybuild_sdk/mcp.py +298 -0
  7. allybuild_sdk-1.0.0/allybuild_sdk/memories.py +88 -0
  8. allybuild_sdk-1.0.0/allybuild_sdk/okf.py +59 -0
  9. allybuild_sdk-1.0.0/allybuild_sdk/reactive/__init__.py +206 -0
  10. allybuild_sdk-1.0.0/allybuild_sdk/reactive/actions.py +83 -0
  11. allybuild_sdk-1.0.0/allybuild_sdk/reactive/backend.py +170 -0
  12. allybuild_sdk-1.0.0/allybuild_sdk/reactive/descriptors.py +43 -0
  13. allybuild_sdk-1.0.0/allybuild_sdk/reactive/dollar_api.py +227 -0
  14. allybuild_sdk-1.0.0/allybuild_sdk/reactive/exceptions.py +4 -0
  15. allybuild_sdk-1.0.0/allybuild_sdk/reactive/mutations.py +188 -0
  16. allybuild_sdk-1.0.0/allybuild_sdk/reactive/options_store.py +170 -0
  17. allybuild_sdk-1.0.0/allybuild_sdk/reactive/plugins.py +54 -0
  18. allybuild_sdk-1.0.0/allybuild_sdk/reactive/registry.py +141 -0
  19. allybuild_sdk-1.0.0/allybuild_sdk/reactive/setup_store.py +332 -0
  20. allybuild_sdk-1.0.0/allybuild_sdk/reactive/types.py +42 -0
  21. allybuild_sdk-1.0.0/allybuild_sdk/reporter.py +9 -0
  22. allybuild_sdk-1.0.0/allybuild_sdk/status.py +126 -0
  23. allybuild_sdk-1.0.0/allybuild_sdk/tasks.py +278 -0
  24. allybuild_sdk-1.0.0/allybuild_sdk.egg-info/PKG-INFO +7 -0
  25. allybuild_sdk-1.0.0/allybuild_sdk.egg-info/SOURCES.txt +28 -0
  26. allybuild_sdk-1.0.0/allybuild_sdk.egg-info/dependency_links.txt +1 -0
  27. allybuild_sdk-1.0.0/allybuild_sdk.egg-info/top_level.txt +1 -0
  28. allybuild_sdk-1.0.0/pyproject.toml +14 -0
  29. allybuild_sdk-1.0.0/setup.cfg +4 -0
  30. allybuild_sdk-1.0.0/workflow.py +124 -0
@@ -0,0 +1 @@
1
+ include workflow.py
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: allybuild-sdk
3
+ Version: 1.0.0
4
+ Summary: AllyBuild SDK for task scripts — pure-stdlib HTTP facade (client + tasks/mcp/status/reporter/reactive)
5
+ License: MIT
6
+ Keywords: allybuild,sdk,workflow
7
+ Requires-Python: >=3.10
@@ -0,0 +1,33 @@
1
+ """AllyBuild SDK — workflow 平台交互面 + reactive 客户端(纯 HTTP,零第三方依赖)。"""
2
+ from .client import AllyBuildClient, AllyBuildError
3
+ from .reporter import Reporter
4
+ from .tasks import ScriptContext, TasksApi
5
+ from .agents import AgentsApi
6
+ from .memories import MemoriesApi
7
+ from .okf import OkfApi
8
+ from .mcp import McpApi
9
+ from .status import ProjectStatusProxy, fetch_statuses
10
+
11
+ __all__ = [
12
+ "AllyBuildClient", "AllyBuildError", "Reporter",
13
+ "ScriptContext", "TasksApi", "AgentsApi", "MemoriesApi", "OkfApi",
14
+ "McpApi", "ProjectStatusProxy", "fetch_statuses",
15
+ ]
16
+
17
+
18
+ def create_client(base_url: str, token: str, project_id: str):
19
+ """便捷工厂:一次构造 client + 全部域 API(返回 SimpleNamespace)。"""
20
+ from types import SimpleNamespace
21
+
22
+ client = AllyBuildClient(base_url, token, project_id)
23
+ ctx = ScriptContext()
24
+ return SimpleNamespace(
25
+ client=client,
26
+ reporter=Reporter(),
27
+ tasks=TasksApi(client, ctx),
28
+ agents=AgentsApi(client),
29
+ memories=MemoriesApi(client),
30
+ okf=OkfApi(client),
31
+ mcp=McpApi(client),
32
+ status=ProjectStatusProxy(client=client),
33
+ )
@@ -0,0 +1,40 @@
1
+ """Agent and workspace listing — ported from runtime/environment/python/workflow.py."""
2
+ from .client import AllyBuildClient
3
+
4
+
5
+ class AgentsApi:
6
+ """List agents and workspaces in the project."""
7
+
8
+ def __init__(self, client: AllyBuildClient):
9
+ self._client = client
10
+
11
+ def _ensure(self) -> None:
12
+ if not self._client.configured():
13
+ raise RuntimeError("ALLYBUILD_API_URL / PROJECT_ID / TASK_JWT not set")
14
+
15
+ def list_agents(self) -> list:
16
+ """List all agents in this project.
17
+
18
+ Returns:
19
+ List of agent dicts with id, name, description, is_default, agent_type.
20
+
21
+ Example:
22
+ for agent in api.list_agents():
23
+ print(agent["name"], agent["agent_type"])
24
+ """
25
+ self._ensure()
26
+ return self._client.get(f"/projects/{self._client.project_id}/agents/")
27
+
28
+ def list_workspaces(self) -> list:
29
+ """List all workspaces in this project.
30
+
31
+ Returns:
32
+ List of workspace dicts with id, name, slug, type, is_default, artifact_type.
33
+
34
+ Example:
35
+ for ws in api.list_workspaces():
36
+ if ws["artifact_type"] == "knowledge_base":
37
+ print(ws["name"])
38
+ """
39
+ self._ensure()
40
+ return self._client.get(f"/projects/{self._client.project_id}/workspaces/")
@@ -0,0 +1,79 @@
1
+ """AllyBuild SDK HTTP client — stdlib only, transport seam injectable."""
2
+ import json
3
+ import urllib.error
4
+ import urllib.request
5
+
6
+
7
+ class AllyBuildError(RuntimeError):
8
+ """HTTP-level error from the AllyBuild API."""
9
+
10
+ def __init__(self, status: int, message: str):
11
+ super().__init__(message)
12
+ self.status = status
13
+ self.message = message
14
+
15
+
16
+ class AllyBuildClient:
17
+ """Project-scoped HTTP client for the AllyBuild API.
18
+
19
+ All config is explicit (base_url/token/project_id) — the SDK never reads
20
+ environment variables; the container thin-shell supplies env values.
21
+ """
22
+
23
+ def __init__(self, base_url: str, token: str, project_id: str):
24
+ self.base_url = (base_url or "").rstrip("/")
25
+ self.token = token or ""
26
+ self.project_id = project_id or ""
27
+
28
+ def configured(self) -> bool:
29
+ return bool(self.base_url and self.token and self.project_id)
30
+
31
+ def _headers(self, *, json_body: bool) -> dict:
32
+ h = {"Authorization": f"Bearer {self.token}"}
33
+ if json_body:
34
+ h["Content-Type"] = "application/json"
35
+ return h
36
+
37
+ def _build_request(self, method: str, path: str, body) -> urllib.request.Request:
38
+ data = json.dumps(body).encode() if body is not None else None
39
+ return urllib.request.Request(
40
+ f"{self.base_url}{path}",
41
+ data=data,
42
+ headers=self._headers(json_body=body is not None),
43
+ method=method,
44
+ )
45
+
46
+ def _send(self, req, timeout):
47
+ """Transport seam — tests override this; production uses urllib."""
48
+ return urllib.request.urlopen(req, timeout=timeout)
49
+
50
+ def _raise_http_error(self, method: str, path: str, exc: urllib.error.HTTPError):
51
+ detail = exc.read().decode(errors="replace") if exc.fp else ""
52
+ raise AllyBuildError(
53
+ exc.code, f"{method} {path} failed ({exc.code}): {detail}"
54
+ ) from exc
55
+
56
+ def request(self, method: str, path: str, *, body=None, timeout: float = 10):
57
+ req = self._build_request(method, path, body)
58
+ try:
59
+ with self._send(req, timeout) as resp:
60
+ raw = resp.read()
61
+ return json.loads(raw) if raw else None
62
+ except urllib.error.HTTPError as exc:
63
+ self._raise_http_error(method, path, exc)
64
+
65
+ def get(self, path: str, *, timeout: float = 10):
66
+ return self.request("GET", path, timeout=timeout)
67
+
68
+ def post(self, path: str, body=None, *, timeout: float = 10):
69
+ return self.request("POST", path, body=body, timeout=timeout)
70
+
71
+ def request_drain(self, method: str, path: str, *, body=None, timeout: float = 600) -> None:
72
+ """Send a request and drain the (possibly SSE) response — output discarded."""
73
+ req = self._build_request(method, path, body)
74
+ try:
75
+ with self._send(req, timeout) as resp:
76
+ while resp.read(4096):
77
+ pass
78
+ except urllib.error.HTTPError as exc:
79
+ self._raise_http_error(method, path, exc)
@@ -0,0 +1,298 @@
1
+ """MCP JSON-RPC client — ported from runtime/environment/python/workflow.py."""
2
+ import json
3
+ import urllib.error
4
+ import urllib.request
5
+
6
+ from .client import AllyBuildClient
7
+
8
+
9
+ class McpClient:
10
+ """MCP client for calling tools on a single streamable-http server.
11
+
12
+ Implements JSON-RPC 2.0 over HTTP POST, handling both plain JSON and
13
+ SSE-streamed responses transparently.
14
+
15
+ Sync usage:
16
+ exa = mcp("exa")
17
+ result = exa.call("web_search_exa", {"query": "latest MCP news"})
18
+ for item in result["content"]:
19
+ if item["type"] == "text":
20
+ print(item["text"])
21
+
22
+ Async usage (inside an async def run):
23
+ import asyncio
24
+ r1, r2 = await asyncio.gather(
25
+ mcp("exa").acall("web_search_exa", {"query": "python 3.13"}),
26
+ mcp("context7").acall("resolve-library-id",
27
+ {"query": "fastapi", "libraryName": "fastapi"}),
28
+ )
29
+ """
30
+
31
+ def __init__(self, name: str, url: str, headers: dict) -> None:
32
+ self._name = name
33
+ self._url = url
34
+ self._headers = headers
35
+ self._req_id = 0
36
+
37
+ def _send(self, req, timeout):
38
+ """Transport seam — tests override this; production uses urllib."""
39
+ return urllib.request.urlopen(req, timeout=timeout)
40
+
41
+ def _rpc(self, method: str, params: dict, timeout: int = 60) -> dict:
42
+ """Send a JSON-RPC 2.0 request and return the parsed response dict."""
43
+ self._req_id += 1
44
+ body = json.dumps({
45
+ "jsonrpc": "2.0",
46
+ "id": str(self._req_id),
47
+ "method": method,
48
+ "params": params,
49
+ }).encode()
50
+
51
+ req_headers = {
52
+ "Content-Type": "application/json",
53
+ "Accept": "application/json, text/event-stream",
54
+ **self._headers,
55
+ }
56
+ req = urllib.request.Request(
57
+ self._url, data=body, headers=req_headers, method="POST"
58
+ )
59
+ try:
60
+ with self._send(req, timeout) as resp:
61
+ content_type = resp.headers.get("Content-Type", "")
62
+ raw = resp.read()
63
+ # SSE stream: parse the first complete data: line
64
+ if "text/event-stream" in content_type:
65
+ text = raw.decode(errors="replace")
66
+ for line in text.splitlines():
67
+ line = line.strip()
68
+ if not line.startswith("data: "):
69
+ continue
70
+ payload = line[6:].strip()
71
+ if payload in ("", "[DONE]"):
72
+ continue
73
+ try:
74
+ return json.loads(payload)
75
+ except ValueError:
76
+ continue
77
+ raise RuntimeError(
78
+ f"MCP [{self._name}]: no parseable data in SSE stream"
79
+ )
80
+ return json.loads(raw)
81
+ except urllib.error.HTTPError as exc:
82
+ detail = exc.read().decode(errors="replace") if exc.fp else ""
83
+ raise RuntimeError(
84
+ f"MCP [{self._name}] {method} failed (HTTP {exc.code}): {detail}"
85
+ ) from exc
86
+
87
+ def list_tools(self) -> list:
88
+ """List all tools available on this MCP server.
89
+
90
+ Returns:
91
+ List of tool dicts, each with 'name', 'description', 'inputSchema'.
92
+
93
+ Example:
94
+ for tool in mcp("exa").list_tools():
95
+ print(tool["name"], "-", tool["description"])
96
+ """
97
+ resp = self._rpc("tools/list", {})
98
+ err = resp.get("error")
99
+ if err:
100
+ raise RuntimeError(
101
+ f"MCP [{self._name}] tools/list error: {err.get('message', err)}"
102
+ )
103
+ return resp.get("result", {}).get("tools", [])
104
+
105
+ def call(self, tool_name: str, arguments: dict | None = None, timeout: int = 60) -> dict:
106
+ """Call a tool on this MCP server.
107
+
108
+ Args:
109
+ tool_name: Name of the tool to invoke.
110
+ arguments: Tool arguments dict (must match the tool's inputSchema).
111
+ timeout: Request timeout in seconds (default 60).
112
+
113
+ Returns:
114
+ Result dict with a 'content' list of {type, text} items and
115
+ an optional 'isError' flag. Most tools return a single text item.
116
+
117
+ Raises:
118
+ RuntimeError: Server returned a JSON-RPC error.
119
+
120
+ Example:
121
+ result = mcp("exa").call("web_search_exa", {"query": "Python 3.13 changes"})
122
+ print(result["content"][0]["text"])
123
+ """
124
+ resp = self._rpc(
125
+ "tools/call",
126
+ {"name": tool_name, "arguments": arguments or {}},
127
+ timeout=timeout,
128
+ )
129
+ err = resp.get("error")
130
+ if err:
131
+ raise RuntimeError(
132
+ f"MCP [{self._name}] tools/call '{tool_name}' error: "
133
+ f"{err.get('message', err)}"
134
+ )
135
+ return resp.get("result", {})
136
+
137
+ async def acall(
138
+ self, tool_name: str, arguments: dict | None = None, timeout: int = 60
139
+ ) -> dict:
140
+ """Async version of call() — runs the HTTP request in a thread pool.
141
+
142
+ Use inside an ``async def run`` to fire multiple MCP calls concurrently
143
+ with ``asyncio.gather``.
144
+
145
+ Example:
146
+ import asyncio
147
+
148
+ async def run(params, reporter):
149
+ r1, r2 = await asyncio.gather(
150
+ mcp("exa").acall("web_search_exa", {"query": "MCP news"}),
151
+ mcp("context7").acall("resolve-library-id",
152
+ {"query": "fastapi", "libraryName": "fastapi"}),
153
+ )
154
+ print(r1["content"][0]["text"])
155
+ """
156
+ import asyncio
157
+ return await asyncio.to_thread(self.call, tool_name, arguments, timeout)
158
+
159
+ async def alist_tools(self) -> list:
160
+ """Async version of list_tools() — runs in a thread pool.
161
+
162
+ Example:
163
+ tools = await mcp("exa").alist_tools()
164
+ """
165
+ import asyncio
166
+ return await asyncio.to_thread(self.list_tools)
167
+
168
+
169
+ class McpApi:
170
+ """MCP integration — resolve project server configs and call tools."""
171
+
172
+ def __init__(self, client: AllyBuildClient):
173
+ self._client = client
174
+ self._cache: dict | None = None
175
+
176
+ def _fetch_mcp_config(self) -> dict:
177
+ """Fetch project MCP server config from the AllyBuild API.
178
+
179
+ Returns {server_name: {"url": ..., "headers": {...}}} for all configured servers.
180
+ Returns {} on any error (no MCP servers configured, network failure, etc.).
181
+ """
182
+ if not self._client.configured():
183
+ return {}
184
+ try:
185
+ project = self._client.get(f"/projects/{self._client.project_id}", timeout=5)
186
+ mcp_config = project.get("mcp_config") or {}
187
+ return mcp_config.get("mcpServers") or {}
188
+ except Exception:
189
+ return {}
190
+
191
+ def _mcp_config(self) -> dict:
192
+ """Project MCP server config, fetched lazily on first mcp() call."""
193
+ if self._cache is None:
194
+ self._cache = self._fetch_mcp_config()
195
+ return self._cache
196
+
197
+ def mcp(self, server_name: str) -> McpClient:
198
+ """Return an MCP client for the named server.
199
+
200
+ The server must be configured in the project's MCP integration settings
201
+ (integration tab → streamable-http servers).
202
+
203
+ Args:
204
+ server_name: Key in the project's mcpServers config (e.g. "exa").
205
+
206
+ Returns:
207
+ McpClient with list_tools(), call(), alist_tools(), acall() methods.
208
+
209
+ Raises:
210
+ KeyError: Server not found in project MCP config.
211
+ ValueError: Server has no URL (stdio servers are not supported here).
212
+
213
+ Sync example:
214
+ result = mcp("exa").call("web_search_exa", {"query": "Python 3.13"})
215
+ print(result["content"][0]["text"])
216
+
217
+ Async example (inside async def run):
218
+ import asyncio
219
+ r1, r2 = await asyncio.gather(
220
+ mcp("exa").acall("web_search_exa", {"query": "Python 3.13"}),
221
+ mcp("context7").acall("resolve-library-id",
222
+ {"query": "fastapi", "libraryName": "fastapi"}),
223
+ )
224
+ """
225
+ cfg_map = self._mcp_config()
226
+ if server_name not in cfg_map:
227
+ available = list(cfg_map.keys())
228
+ raise KeyError(
229
+ f"MCP server {server_name!r} not found in project config. "
230
+ f"Configured servers: {available}"
231
+ )
232
+ cfg = cfg_map[server_name]
233
+ url = cfg.get("url")
234
+ if not url:
235
+ raise ValueError(
236
+ f"MCP server {server_name!r} has no URL. "
237
+ "Only streamable-http servers (with a URL) are supported in workflow scripts."
238
+ )
239
+ return McpClient(name=server_name, url=url, headers=cfg.get("headers") or {})
240
+
241
+ def mcp_call(
242
+ self,
243
+ server_name: str,
244
+ tool_name: str,
245
+ arguments: dict | None = None,
246
+ timeout: int = 60,
247
+ ) -> dict:
248
+ """Call an MCP tool in one line.
249
+
250
+ Shorthand for mcp(server_name).call(tool_name, arguments).
251
+
252
+ Args:
253
+ server_name: MCP server name (e.g. "exa", "context7").
254
+ tool_name: Tool to invoke.
255
+ arguments: Tool arguments dict.
256
+ timeout: Request timeout in seconds (default 60).
257
+
258
+ Returns:
259
+ Result dict with 'content' list.
260
+
261
+ Example:
262
+ result = mcp_call("exa", "web_search_exa", {"query": "MCP protocol 2025"})
263
+ print(result["content"][0]["text"])
264
+
265
+ result = mcp_call("context7", "resolve-library-id",
266
+ {"query": "sqlalchemy", "libraryName": "sqlalchemy"})
267
+ """
268
+ return self.mcp(server_name).call(tool_name, arguments, timeout=timeout)
269
+
270
+ async def async_mcp_call(
271
+ self,
272
+ server_name: str,
273
+ tool_name: str,
274
+ arguments: dict | None = None,
275
+ timeout: int = 60,
276
+ ) -> dict:
277
+ """Async shorthand: await a single MCP tool call.
278
+
279
+ Equivalent to await mcp(server_name).acall(tool_name, arguments).
280
+ Runs the HTTP request in a thread pool so it doesn't block the event loop.
281
+
282
+ Example (inside async def run):
283
+ import asyncio
284
+
285
+ async def run(params, reporter):
286
+ # Sequential
287
+ result = await async_mcp_call("exa", "web_search_exa",
288
+ {"query": "MCP 2025"})
289
+
290
+ # Concurrent
291
+ r1, r2 = await asyncio.gather(
292
+ async_mcp_call("exa", "web_search_exa", {"query": "python"}),
293
+ async_mcp_call("context7", "resolve-library-id",
294
+ {"query": "fastapi", "libraryName": "fastapi"}),
295
+ )
296
+ print(r1["content"][0]["text"])
297
+ """
298
+ return await self.mcp(server_name).acall(tool_name, arguments, timeout=timeout)
@@ -0,0 +1,88 @@
1
+ """Memory queries — ported from runtime/environment/python/workflow.py."""
2
+ from .client import AllyBuildClient
3
+
4
+
5
+ class MemoriesApi:
6
+ """List / search memories accumulated by meta-tasks and task runs."""
7
+
8
+ def __init__(self, client: AllyBuildClient):
9
+ self._client = client
10
+
11
+ def _ensure(self) -> None:
12
+ if not self._client.configured():
13
+ raise RuntimeError("ALLYBUILD_API_URL / PROJECT_ID / TASK_JWT not set")
14
+
15
+ def list_task_type_memories(
16
+ self,
17
+ task_type: str,
18
+ query: str | None = None,
19
+ k: int = 20,
20
+ order: str = "most",
21
+ limit: int = 20,
22
+ offset: int = 0,
23
+ ) -> list:
24
+ """List or semantically search memories accumulated by a meta-task.
25
+
26
+ Without query: paginated listing ordered by creation time (limit/offset).
27
+ With query: semantic similarity search returning k results.
28
+
29
+ Args:
30
+ task_type: Meta-task identifier string or UUID.
31
+ query: Semantic search query. When provided, switches to similarity
32
+ search and the limit/offset params are ignored.
33
+ k: Number of results for semantic search (default 20, max 100).
34
+ order: "most" — most relevant first (default).
35
+ "least" — least relevant first.
36
+ limit: Page size for paginated listing (default 20, max 100).
37
+ offset: Items to skip for paginated listing (default 0).
38
+
39
+ Returns:
40
+ List of memory dicts. Semantic results include a "score" field.
41
+
42
+ Example:
43
+ # Paginated listing
44
+ for m in api.list_task_type_memories("summariser", limit=10):
45
+ print(m["memory"])
46
+
47
+ # Most relevant
48
+ hits = api.list_task_type_memories("summariser", query="authentication", k=5)
49
+
50
+ # Most novel / least similar to a known topic
51
+ novel = api.list_task_type_memories("summariser", query="authentication",
52
+ k=5, order="least")
53
+ """
54
+ self._ensure()
55
+ if query and query.strip():
56
+ qs = f"query={query.strip()}&k={k}&order={order}"
57
+ else:
58
+ qs = f"limit={limit}&offset={offset}"
59
+ result = self._client.get(
60
+ f"/projects/{self._client.project_id}/task-types/{task_type}/memories?{qs}"
61
+ )
62
+ return result.get("memories", result) if isinstance(result, dict) else result
63
+
64
+ def get_task_memories(
65
+ self,
66
+ task_id: str,
67
+ limit: int = 20,
68
+ offset: int = 0,
69
+ ) -> list:
70
+ """Get memories contributed by a single task run.
71
+
72
+ Args:
73
+ task_id: UUID of the task instance.
74
+ limit: Page size (default 20, max 100).
75
+ offset: Memories to skip for pagination (default 0).
76
+
77
+ Returns:
78
+ List of memory dicts.
79
+
80
+ Example:
81
+ for m in api.get_task_memories(TASK_ID):
82
+ print(m["memory"])
83
+ """
84
+ self._ensure()
85
+ result = self._client.get(
86
+ f"/projects/{self._client.project_id}/tasks/{task_id}/memories?limit={limit}&offset={offset}"
87
+ )
88
+ return result.get("memories", result) if isinstance(result, dict) else result
@@ -0,0 +1,59 @@
1
+ """OKF knowledge-base operations — ported from runtime/environment/python/workflow.py."""
2
+ from .client import AllyBuildClient
3
+
4
+
5
+ class OkfApi:
6
+ """Search and lint the project's OKF knowledge-base workspace."""
7
+
8
+ def __init__(self, client: AllyBuildClient):
9
+ self._client = client
10
+
11
+ def _ensure(self) -> None:
12
+ if not self._client.configured():
13
+ raise RuntimeError("ALLYBUILD_API_URL / PROJECT_ID / TASK_JWT not set")
14
+
15
+ def search(self, query: str, workspace_id: str | None = None) -> list:
16
+ """Search the project's knowledge-base workspace for OKF concepts.
17
+
18
+ Args:
19
+ query: Search query string.
20
+ workspace_id: Knowledge-base workspace UUID. Defaults to the
21
+ project's knowledge-base workspace.
22
+
23
+ Returns:
24
+ List of concept summary dicts (title, description, concept_type, etc.).
25
+
26
+ Example:
27
+ hits = api.search("authentication flow")
28
+ for h in hits:
29
+ print(h["title"], "-", h.get("description", ""))
30
+ """
31
+ self._ensure()
32
+ body: dict = {"query": query}
33
+ if workspace_id:
34
+ body["workspace_id"] = workspace_id
35
+ return self._client.post(f"/projects/{self._client.project_id}/workspace/okf/search", body)
36
+
37
+ def lint(self, workspace_id: str | None = None) -> list:
38
+ """Run OKF health checks on the project's knowledge-base workspace.
39
+
40
+ Checks for broken links, orphan concepts, missing type/citations,
41
+ and duplicate titles.
42
+
43
+ Args:
44
+ workspace_id: Knowledge-base workspace UUID. Defaults to the
45
+ project's knowledge-base workspace.
46
+
47
+ Returns:
48
+ List of issue dicts with issue_type, concept_id, message.
49
+
50
+ Example:
51
+ issues = api.lint()
52
+ for issue in issues:
53
+ print(f"[{issue['issue_type']}] {issue['concept_id']}: {issue['message']}")
54
+ """
55
+ self._ensure()
56
+ body: dict = {}
57
+ if workspace_id:
58
+ body["workspace_id"] = workspace_id
59
+ return self._client.post(f"/projects/{self._client.project_id}/workspace/okf/lint", body)