agent-pdf-workspace 0.1.1__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.
Files changed (36) hide show
  1. agent_pdf_workspace/__init__.py +48 -0
  2. agent_pdf_workspace/_io.py +97 -0
  3. agent_pdf_workspace/assets/opencode/agent_pdf_workspace.ts +237 -0
  4. agent_pdf_workspace/cli.py +322 -0
  5. agent_pdf_workspace/config.py +39 -0
  6. agent_pdf_workspace/errors.py +33 -0
  7. agent_pdf_workspace/extraction/__init__.py +1 -0
  8. agent_pdf_workspace/extraction/geometry.py +647 -0
  9. agent_pdf_workspace/extraction/liteparse_backend.py +152 -0
  10. agent_pdf_workspace/extraction/pdf_inventory.py +166 -0
  11. agent_pdf_workspace/extraction/worker.py +50 -0
  12. agent_pdf_workspace/ids.py +35 -0
  13. agent_pdf_workspace/models.py +195 -0
  14. agent_pdf_workspace/opencode.py +134 -0
  15. agent_pdf_workspace/schemas/audit-region-v1.schema.json +53 -0
  16. agent_pdf_workspace/schemas/diagnostic-v1.schema.json +45 -0
  17. agent_pdf_workspace/schemas/ingest-config-v1.schema.json +89 -0
  18. agent_pdf_workspace/schemas/page-v1.schema.json +271 -0
  19. agent_pdf_workspace/schemas/page-visual-audit-v1.schema.json +127 -0
  20. agent_pdf_workspace/schemas/search-hit-v1.schema.json +73 -0
  21. agent_pdf_workspace/schemas/table-v1.schema.json +55 -0
  22. agent_pdf_workspace/schemas/text-block-v1.schema.json +78 -0
  23. agent_pdf_workspace/schemas/verification-result-v1.schema.json +44 -0
  24. agent_pdf_workspace/schemas/visual-description-v1.schema.json +99 -0
  25. agent_pdf_workspace/schemas/visual-occurrence-v1.schema.json +65 -0
  26. agent_pdf_workspace/schemas/visual-task-v1.schema.json +153 -0
  27. agent_pdf_workspace/schemas/workspace-v1.schema.json +111 -0
  28. agent_pdf_workspace/search.py +287 -0
  29. agent_pdf_workspace/skill/agent-pdf-workspace/SKILL.md +97 -0
  30. agent_pdf_workspace/skill/agent-pdf-workspace/agents/openai.yaml +4 -0
  31. agent_pdf_workspace/workspace.py +1352 -0
  32. agent_pdf_workspace-0.1.1.dist-info/METADATA +173 -0
  33. agent_pdf_workspace-0.1.1.dist-info/RECORD +36 -0
  34. agent_pdf_workspace-0.1.1.dist-info/WHEEL +4 -0
  35. agent_pdf_workspace-0.1.1.dist-info/entry_points.txt +2 -0
  36. agent_pdf_workspace-0.1.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,48 @@
1
+ """Offline, agent-oriented PDF exploration workspaces."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("agent-pdf-workspace")
6
+
7
+ from .config import IngestConfig
8
+ from .models import (
9
+ AuditRegion,
10
+ Diagnostic,
11
+ ExtractionMethod,
12
+ PageRecord,
13
+ PageVisualAudit,
14
+ SearchHit,
15
+ TableRecord,
16
+ TextBlock,
17
+ VerificationResult,
18
+ VisualDescription,
19
+ VisualOccurrence,
20
+ VisualTask,
21
+ VisualTaskStatus,
22
+ VisualTaskType,
23
+ WorkspaceManifest,
24
+ WorkspaceStatus,
25
+ )
26
+ from .workspace import Workspace
27
+
28
+ __all__ = [
29
+ "AuditRegion",
30
+ "Diagnostic",
31
+ "ExtractionMethod",
32
+ "IngestConfig",
33
+ "PageRecord",
34
+ "PageVisualAudit",
35
+ "SearchHit",
36
+ "TableRecord",
37
+ "TextBlock",
38
+ "VerificationResult",
39
+ "VisualDescription",
40
+ "VisualOccurrence",
41
+ "VisualTask",
42
+ "VisualTaskStatus",
43
+ "VisualTaskType",
44
+ "Workspace",
45
+ "WorkspaceManifest",
46
+ "WorkspaceStatus",
47
+ "__version__",
48
+ ]
@@ -0,0 +1,97 @@
1
+ """Small, deterministic filesystem helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import tempfile
10
+ from collections.abc import Iterable, Mapping
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+
17
+ def sha256_bytes(data: bytes) -> str:
18
+ return hashlib.sha256(data).hexdigest()
19
+
20
+
21
+ def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str:
22
+ digest = hashlib.sha256()
23
+ with path.open("rb") as handle:
24
+ while chunk := handle.read(chunk_size):
25
+ digest.update(chunk)
26
+ return digest.hexdigest()
27
+
28
+
29
+ def atomic_write_bytes(path: Path, data: bytes) -> None:
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
32
+ temporary = Path(temporary_name)
33
+ try:
34
+ with os.fdopen(descriptor, "wb") as handle:
35
+ handle.write(data)
36
+ handle.flush()
37
+ os.fsync(handle.fileno())
38
+ os.replace(temporary, path)
39
+ finally:
40
+ temporary.unlink(missing_ok=True)
41
+
42
+
43
+ def atomic_write_text(path: Path, text: str) -> None:
44
+ atomic_write_bytes(path, text.encode("utf-8"))
45
+
46
+
47
+ def atomic_copy(source: Path, destination: Path) -> None:
48
+ destination.parent.mkdir(parents=True, exist_ok=True)
49
+ descriptor, temporary_name = tempfile.mkstemp(
50
+ prefix=f".{destination.name}.", dir=destination.parent
51
+ )
52
+ temporary = Path(temporary_name)
53
+ try:
54
+ with source.open("rb") as input_handle, os.fdopen(descriptor, "wb") as output_handle:
55
+ shutil.copyfileobj(input_handle, output_handle, length=1024 * 1024)
56
+ output_handle.flush()
57
+ os.fsync(output_handle.fileno())
58
+ os.replace(temporary, destination)
59
+ finally:
60
+ temporary.unlink(missing_ok=True)
61
+
62
+
63
+ def write_json(path: Path, value: Any) -> None:
64
+ if hasattr(value, "model_dump"):
65
+ value = value.model_dump(mode="json")
66
+ payload = json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
67
+ atomic_write_text(path, payload)
68
+
69
+
70
+ def markdown_document(frontmatter: Mapping[str, Any], body: str) -> str:
71
+ yaml_text = yaml.safe_dump(
72
+ dict(frontmatter), allow_unicode=True, sort_keys=False, default_flow_style=False
73
+ ).strip()
74
+ return f"---\n{yaml_text}\n---\n\n{body.rstrip()}\n"
75
+
76
+
77
+ def canonical_paths(root: Path) -> Iterable[Path]:
78
+ excluded_files = {
79
+ "manifest-sha256.txt",
80
+ "log.md",
81
+ ".pdfws.lock",
82
+ }
83
+ for path in sorted(root.rglob("*")):
84
+ if not path.is_file():
85
+ continue
86
+ relative = path.relative_to(root).as_posix()
87
+ if relative in excluded_files or relative.startswith("cache/"):
88
+ continue
89
+ yield path
90
+
91
+
92
+ def write_integrity_manifest(root: Path) -> None:
93
+ lines = [
94
+ f"{sha256_file(path)} {path.relative_to(root).as_posix()}"
95
+ for path in canonical_paths(root)
96
+ ]
97
+ atomic_write_text(root / "manifest-sha256.txt", "\n".join(lines) + "\n")
@@ -0,0 +1,237 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import { unlink } from "node:fs/promises"
3
+ import path from "path"
4
+
5
+ async function runPdfws(args: string[]): Promise<string> {
6
+ const process = Bun.spawnSync(["pdfws", ...args], {
7
+ stdout: "pipe",
8
+ stderr: "pipe",
9
+ })
10
+ const stdout = process.stdout.toString().trim()
11
+ if (process.exitCode !== 0) {
12
+ const stderr = process.stderr.toString().trim()
13
+ throw new Error(stderr || `pdfws exited with status ${process.exitCode}`)
14
+ }
15
+ return stdout
16
+ }
17
+
18
+ async function withJsonInput(
19
+ payload: Record<string, unknown>,
20
+ invoke: (inputPath: string) => Promise<string>,
21
+ ): Promise<string> {
22
+ const temporaryDirectory = Bun.env.TMPDIR || Bun.env.TEMP || Bun.env.TMP || "."
23
+ const inputPath = path.join(
24
+ temporaryDirectory,
25
+ `agent-pdf-workspace-${crypto.randomUUID()}.json`,
26
+ )
27
+ await Bun.write(inputPath, JSON.stringify(payload))
28
+ try {
29
+ return await invoke(inputPath)
30
+ } finally {
31
+ await unlink(inputPath).catch(() => undefined)
32
+ }
33
+ }
34
+
35
+ export const inspect = tool({
36
+ description: "Inspect a local agent-pdf-workspace without loading the full PDF.",
37
+ args: {
38
+ workspace: tool.schema.string().describe("Path to the existing PDF workspace"),
39
+ },
40
+ async execute(args) {
41
+ return runPdfws(["inspect", args.workspace, "--json"])
42
+ },
43
+ })
44
+
45
+ export const search = tool({
46
+ description: "Search a local PDF workspace and return page-grounded hits and snippets.",
47
+ args: {
48
+ workspace: tool.schema.string().describe("Path to the existing PDF workspace"),
49
+ query: tool.schema.string().describe("Text or expression to find"),
50
+ mode: tool.schema.enum(["fts", "exact", "regex"]).default("fts"),
51
+ limit: tool.schema.number().int().min(1).max(100).default(20),
52
+ },
53
+ async execute(args) {
54
+ return runPdfws([
55
+ "search",
56
+ args.workspace,
57
+ args.query,
58
+ "--mode",
59
+ args.mode,
60
+ "--limit",
61
+ String(args.limit),
62
+ "--json",
63
+ ])
64
+ },
65
+ })
66
+
67
+ export const read_page = tool({
68
+ description: "Read one page from a local PDF workspace with bounded output.",
69
+ args: {
70
+ workspace: tool.schema.string().describe("Path to the existing PDF workspace"),
71
+ page: tool.schema.number().int().min(1).describe("One-based page number"),
72
+ max_chars: tool.schema.number().int().min(1).max(100000).default(20000),
73
+ cursor: tool.schema.string().optional().describe("Continuation cursor from a prior read"),
74
+ },
75
+ async execute(args) {
76
+ const command = [
77
+ "read",
78
+ args.workspace,
79
+ "--page",
80
+ String(args.page),
81
+ "--max-chars",
82
+ String(args.max_chars),
83
+ "--json",
84
+ ]
85
+ if (args.cursor) command.push("--cursor", args.cursor)
86
+ return runPdfws(command)
87
+ },
88
+ })
89
+
90
+ export const render_region = tool({
91
+ description: "Render a bounded PDF page region for visual interpretation by the calling agent.",
92
+ args: {
93
+ workspace: tool.schema.string().describe("Path to the existing PDF workspace"),
94
+ page: tool.schema.number().int().min(1).describe("One-based page number"),
95
+ bbox: tool.schema
96
+ .array(tool.schema.number())
97
+ .length(4)
98
+ .describe("Page-coordinate bounding box [x0, y0, x1, y1]"),
99
+ dpi: tool.schema.number().int().min(72).max(600).default(200),
100
+ },
101
+ async execute(args) {
102
+ return runPdfws([
103
+ "render",
104
+ args.workspace,
105
+ "--page",
106
+ String(args.page),
107
+ "--bbox",
108
+ args.bbox.join(","),
109
+ "--dpi",
110
+ String(args.dpi),
111
+ "--json",
112
+ ])
113
+ },
114
+ })
115
+
116
+ export const visuals_next = tool({
117
+ description: "Return the next pending visual-description or page-audit task.",
118
+ args: {
119
+ workspace: tool.schema.string().describe("Path to the existing PDF workspace"),
120
+ },
121
+ async execute(args) {
122
+ const result = await runPdfws(["visuals", "next", args.workspace, "--json"])
123
+ const task = JSON.parse(result)
124
+ if (task?.asset_path) {
125
+ task.asset_path_absolute = path.resolve(args.workspace, task.asset_path)
126
+ }
127
+ return JSON.stringify(task)
128
+ },
129
+ })
130
+
131
+ export const visuals_submit = tool({
132
+ description: "Persist a visual description. For charts, use a Markdown table with one row per data point and explicit labels, values, and units. Never recompute verified values during a formatting-only replacement.",
133
+ args: {
134
+ workspace: tool.schema.string().describe("Path to the existing PDF workspace"),
135
+ task_id: tool.schema.string(),
136
+ visual_id: tool.schema.string(),
137
+ crop_sha256: tool.schema.string(),
138
+ kind: tool.schema.string(),
139
+ decorative: tool.schema.boolean(),
140
+ short_description: tool.schema.string().max(1000),
141
+ long_description: tool.schema.string().max(20000).optional(),
142
+ text_in_visual: tool.schema.string().max(20000).optional().describe("Visible text or data. For charts, provide a Markdown table and preserve exact label/value associations, signs, decimals, and units."),
143
+ model_id: tool.schema.string().max(200).optional(),
144
+ replace: tool.schema.boolean().default(false).describe("Replace a submitted description only after rechecking the unchanged crop and existing JSON."),
145
+ },
146
+ async execute(args) {
147
+ const payload = {
148
+ visual_id: args.visual_id,
149
+ crop_sha256: args.crop_sha256,
150
+ kind: args.kind,
151
+ decorative: args.decorative,
152
+ short_description: args.short_description,
153
+ long_description: args.long_description,
154
+ text_in_visual: args.text_in_visual,
155
+ agent_id: "opencode",
156
+ model_id: args.model_id,
157
+ }
158
+ return withJsonInput(payload, (inputPath) => {
159
+ const command = [
160
+ "visuals",
161
+ "submit",
162
+ args.workspace,
163
+ "--task-id",
164
+ args.task_id,
165
+ "--input",
166
+ inputPath,
167
+ ]
168
+ if (args.replace) command.push("--replace")
169
+ command.push("--json")
170
+ return runPdfws(command)
171
+ })
172
+ },
173
+ })
174
+
175
+ export const visuals_audit = tool({
176
+ description: "Persist a visual page audit and queue any missed regions.",
177
+ args: {
178
+ workspace: tool.schema.string().describe("Path to the existing PDF workspace"),
179
+ task_id: tool.schema.string(),
180
+ page: tool.schema.number().int().min(1),
181
+ regions: tool.schema
182
+ .array(
183
+ tool.schema.object({
184
+ bbox: tool.schema.array(tool.schema.number()).length(4),
185
+ kind: tool.schema.string().default("unknown"),
186
+ short_description: tool.schema.string().max(1000).optional(),
187
+ }),
188
+ )
189
+ .max(100)
190
+ .default([]),
191
+ notes: tool.schema.string().max(10000).optional(),
192
+ model_id: tool.schema.string().max(200).optional(),
193
+ },
194
+ async execute(args) {
195
+ const payload = {
196
+ task_id: args.task_id,
197
+ page: args.page,
198
+ regions: args.regions,
199
+ notes: args.notes,
200
+ agent_id: "opencode",
201
+ model_id: args.model_id,
202
+ }
203
+ return withJsonInput(payload, (inputPath) =>
204
+ runPdfws([
205
+ "visuals",
206
+ "audit",
207
+ args.workspace,
208
+ "--task-id",
209
+ args.task_id,
210
+ "--input",
211
+ inputPath,
212
+ "--json",
213
+ ]),
214
+ )
215
+ },
216
+ })
217
+
218
+ export const visuals_skip = tool({
219
+ description: "Resolve a visual task as skipped with an explicit reason.",
220
+ args: {
221
+ workspace: tool.schema.string().describe("Path to the existing PDF workspace"),
222
+ task_id: tool.schema.string(),
223
+ reason: tool.schema.string().min(1).max(1000),
224
+ },
225
+ async execute(args) {
226
+ return runPdfws([
227
+ "visuals",
228
+ "skip",
229
+ args.workspace,
230
+ "--task-id",
231
+ args.task_id,
232
+ "--reason",
233
+ args.reason,
234
+ "--json",
235
+ ])
236
+ },
237
+ })
@@ -0,0 +1,322 @@
1
+ """Typer command-line interface for agents and humans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import sys
8
+ from importlib.resources import as_file, files
9
+ from pathlib import Path
10
+ from typing import Annotated, Any, Never
11
+
12
+ import typer
13
+
14
+ from agent_pdf_workspace import __version__
15
+ from agent_pdf_workspace.errors import InputValidationError, PdfWorkspaceError
16
+ from agent_pdf_workspace.models import PageVisualAudit, VisualDescription, WorkspaceStatus
17
+ from agent_pdf_workspace.opencode import discover_opencode_config_dir, install_opencode
18
+ from agent_pdf_workspace.workspace import Workspace
19
+
20
+ app = typer.Typer(
21
+ no_args_is_help=True,
22
+ invoke_without_command=True,
23
+ help="Create and explore offline PDF workspaces.",
24
+ )
25
+ visuals_app = typer.Typer(no_args_is_help=True, help="Manage agent visual tasks.")
26
+ skill_app = typer.Typer(no_args_is_help=True, help="Export the bundled agent skill.")
27
+ opencode_app = typer.Typer(no_args_is_help=True, help="Install the OpenCode skill and tools.")
28
+ app.add_typer(visuals_app, name="visuals")
29
+ app.add_typer(skill_app, name="skill")
30
+ app.add_typer(opencode_app, name="opencode")
31
+
32
+
33
+ def _jsonable(value: Any) -> Any:
34
+ if hasattr(value, "model_dump"):
35
+ return value.model_dump(mode="json")
36
+ if isinstance(value, list):
37
+ return [_jsonable(item) for item in value]
38
+ return value
39
+
40
+
41
+ def _emit(value: Any, json_output: bool) -> None:
42
+ payload = _jsonable(value)
43
+ if json_output:
44
+ typer.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
45
+ elif isinstance(payload, str):
46
+ typer.echo(payload)
47
+ else:
48
+ typer.echo(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
49
+
50
+
51
+ def _abort(error: Exception) -> Never:
52
+ typer.echo(str(error), err=True)
53
+ code = error.exit_code if isinstance(error, PdfWorkspaceError) else 2
54
+ raise typer.Exit(code=code)
55
+
56
+
57
+ def _open(target: Path, *, password: str | None = None) -> Workspace:
58
+ try:
59
+ return Workspace.open(target, password=password)
60
+ except (PdfWorkspaceError, ValueError, OSError) as error:
61
+ _abort(error)
62
+
63
+
64
+ @app.callback()
65
+ def main(
66
+ version: bool = typer.Option(False, "--version", help="Show the package version and exit."),
67
+ ) -> None:
68
+ if version:
69
+ typer.echo(__version__)
70
+ raise typer.Exit()
71
+
72
+
73
+ @app.command()
74
+ def ingest(
75
+ source: Annotated[Path, typer.Argument(exists=False, dir_okay=False)],
76
+ target: Annotated[Path, typer.Option("--target", file_okay=False)],
77
+ ocr: str = typer.Option("auto", "--ocr"),
78
+ language: str = typer.Option("eng", "--language"),
79
+ workers: int = typer.Option(4, "--workers", min=1, max=4),
80
+ password_stdin: bool = typer.Option(False, "--password-stdin"),
81
+ force: bool = typer.Option(False, "--force"),
82
+ json_output: bool = typer.Option(False, "--json"),
83
+ ) -> None:
84
+ """Convert one local PDF into a persistent workspace."""
85
+
86
+ password = sys.stdin.readline().rstrip("\r\n") if password_stdin else None
87
+ try:
88
+ workspace = Workspace.ingest(
89
+ source,
90
+ target,
91
+ ocr=ocr,
92
+ ocr_languages=tuple(item for item in language.split("+") if item),
93
+ workers=workers,
94
+ password=password,
95
+ force=force,
96
+ )
97
+ _emit(workspace.inspect(), json_output)
98
+ except (PdfWorkspaceError, ValueError, OSError) as error:
99
+ _abort(error)
100
+
101
+
102
+ @app.command("inspect")
103
+ def inspect_command(
104
+ target: Path,
105
+ json_output: bool = typer.Option(False, "--json"),
106
+ ) -> None:
107
+ workspace = _open(target)
108
+ _emit(workspace.inspect(), json_output)
109
+ if workspace.manifest.status in {
110
+ WorkspaceStatus.BUILDING,
111
+ WorkspaceStatus.PARTIAL,
112
+ WorkspaceStatus.FAILED,
113
+ }:
114
+ raise typer.Exit(code=3)
115
+
116
+
117
+ @app.command()
118
+ def search(
119
+ target: Path,
120
+ query: str,
121
+ mode: str = typer.Option("fts", "--mode"),
122
+ limit: int = typer.Option(20, "--limit", min=1, max=100),
123
+ cursor: str | None = typer.Option(None, "--cursor"),
124
+ json_output: bool = typer.Option(False, "--json"),
125
+ ) -> None:
126
+ try:
127
+ _emit(
128
+ _open(target).search(query, mode=mode, limit=limit, cursor=cursor),
129
+ json_output,
130
+ )
131
+ except (PdfWorkspaceError, ValueError, OSError) as error:
132
+ _abort(error)
133
+
134
+
135
+ @app.command("read")
136
+ def read_command(
137
+ target: Path,
138
+ page: int = typer.Option(..., "--page", min=1),
139
+ max_chars: int = typer.Option(20_000, "--max-chars", min=1, max=100_000),
140
+ cursor: str | None = typer.Option(None, "--cursor"),
141
+ json_output: bool = typer.Option(False, "--json"),
142
+ ) -> None:
143
+ try:
144
+ _emit(
145
+ _open(target).read_page(page, max_chars=max_chars, cursor=cursor),
146
+ json_output,
147
+ )
148
+ except (PdfWorkspaceError, ValueError, OSError) as error:
149
+ _abort(error)
150
+
151
+
152
+ def _bbox(value: str) -> tuple[float, float, float, float]:
153
+ try:
154
+ parts = tuple(float(item) for item in value.split(","))
155
+ except ValueError as error:
156
+ raise InputValidationError("bbox must contain four comma-separated numbers") from error
157
+ if len(parts) != 4:
158
+ raise InputValidationError("bbox must contain four comma-separated numbers")
159
+ return (parts[0], parts[1], parts[2], parts[3])
160
+
161
+
162
+ @app.command()
163
+ def render(
164
+ target: Path,
165
+ page: int = typer.Option(..., "--page", min=1),
166
+ bbox: str = typer.Option(..., "--bbox"),
167
+ dpi: int = typer.Option(200, "--dpi", min=72, max=600),
168
+ password_stdin: bool = typer.Option(False, "--password-stdin"),
169
+ json_output: bool = typer.Option(False, "--json"),
170
+ ) -> None:
171
+ try:
172
+ password = sys.stdin.readline().rstrip("\r\n") if password_stdin else None
173
+ path = _open(target, password=password).render_region(page, _bbox(bbox), dpi=dpi)
174
+ _emit({"path": str(path)}, json_output)
175
+ except (PdfWorkspaceError, ValueError, OSError) as error:
176
+ _abort(error)
177
+
178
+
179
+ @app.command()
180
+ def verify(
181
+ target: Path,
182
+ json_output: bool = typer.Option(False, "--json"),
183
+ ) -> None:
184
+ try:
185
+ result = _open(target).verify()
186
+ _emit(result, json_output)
187
+ if not result.valid:
188
+ raise typer.Exit(code=5)
189
+ except typer.Exit:
190
+ raise
191
+ except (PdfWorkspaceError, ValueError, OSError) as error:
192
+ _abort(error)
193
+
194
+
195
+ @visuals_app.command("list")
196
+ def visuals_list(
197
+ target: Path,
198
+ status: str = typer.Option("pending", "--status"),
199
+ json_output: bool = typer.Option(False, "--json"),
200
+ ) -> None:
201
+ try:
202
+ tasks = _open(target).list_visual_tasks(None if status == "all" else status)
203
+ _emit(tasks, json_output)
204
+ except (PdfWorkspaceError, ValueError, OSError) as error:
205
+ _abort(error)
206
+
207
+
208
+ @visuals_app.command("next")
209
+ def visuals_next(
210
+ target: Path,
211
+ json_output: bool = typer.Option(False, "--json"),
212
+ ) -> None:
213
+ try:
214
+ tasks = _open(target).list_visual_tasks("pending")
215
+ if not tasks:
216
+ tasks = _open(target).list_visual_tasks("stale")
217
+ _emit(tasks[0] if tasks else None, json_output)
218
+ except (PdfWorkspaceError, ValueError, OSError) as error:
219
+ _abort(error)
220
+
221
+
222
+ @visuals_app.command("submit")
223
+ def visuals_submit(
224
+ target: Path,
225
+ task_id: Annotated[str, typer.Option("--task-id")],
226
+ input_path: Annotated[Path, typer.Option("--input", exists=True, dir_okay=False)],
227
+ replace: bool = typer.Option(False, "--replace"),
228
+ json_output: bool = typer.Option(False, "--json"),
229
+ ) -> None:
230
+ try:
231
+ description = VisualDescription.model_validate_json(input_path.read_text(encoding="utf-8"))
232
+ task = _open(target).submit_visual_description(task_id, description, replace=replace)
233
+ _emit(task, json_output)
234
+ except (PdfWorkspaceError, ValueError, OSError) as error:
235
+ _abort(error)
236
+
237
+
238
+ @visuals_app.command("skip")
239
+ def visuals_skip(
240
+ target: Path,
241
+ task_id: Annotated[str, typer.Option("--task-id")],
242
+ reason: Annotated[str, typer.Option("--reason")],
243
+ json_output: bool = typer.Option(False, "--json"),
244
+ ) -> None:
245
+ try:
246
+ task = _open(target).skip_visual_task(task_id, reason=reason)
247
+ _emit(task, json_output)
248
+ except (PdfWorkspaceError, ValueError, OSError) as error:
249
+ _abort(error)
250
+
251
+
252
+ @visuals_app.command("audit")
253
+ def visuals_audit(
254
+ target: Path,
255
+ task_id: Annotated[str, typer.Option("--task-id")],
256
+ input_path: Annotated[Path, typer.Option("--input", exists=True, dir_okay=False)],
257
+ password_stdin: bool = typer.Option(False, "--password-stdin"),
258
+ json_output: bool = typer.Option(False, "--json"),
259
+ ) -> None:
260
+ try:
261
+ audit = PageVisualAudit.model_validate_json(input_path.read_text(encoding="utf-8"))
262
+ password = sys.stdin.readline().rstrip("\r\n") if password_stdin else None
263
+ task = _open(target, password=password).submit_page_audit(task_id, audit)
264
+ _emit(task, json_output)
265
+ except (PdfWorkspaceError, ValueError, OSError) as error:
266
+ _abort(error)
267
+
268
+
269
+ @skill_app.command("export")
270
+ def skill_export(
271
+ target: Path,
272
+ json_output: bool = typer.Option(False, "--json"),
273
+ ) -> None:
274
+ try:
275
+ if target.exists() and target.is_symlink():
276
+ raise InputValidationError("skill export target must not be a symbolic link")
277
+ target.mkdir(parents=True, exist_ok=True)
278
+ destination = target / "agent-pdf-workspace"
279
+ if destination.exists():
280
+ raise InputValidationError(f"skill destination already exists: {destination}")
281
+ resource = files("agent_pdf_workspace").joinpath("skill/agent-pdf-workspace")
282
+ with as_file(resource) as source:
283
+ shutil.copytree(source, destination)
284
+ _emit({"path": str(destination.resolve())}, json_output)
285
+ except (PdfWorkspaceError, ValueError, OSError) as error:
286
+ _abort(error)
287
+
288
+
289
+ @opencode_app.command("path")
290
+ def opencode_path(
291
+ json_output: bool = typer.Option(False, "--json"),
292
+ ) -> None:
293
+ """Report the effective OpenCode config directory."""
294
+
295
+ try:
296
+ location = discover_opencode_config_dir()
297
+ _emit({"config_dir": str(location.path), "source": location.source}, json_output)
298
+ except (PdfWorkspaceError, ValueError, OSError) as error:
299
+ _abort(error)
300
+
301
+
302
+ @opencode_app.command("install")
303
+ def opencode_install(
304
+ config_dir: Annotated[Path | None, typer.Option("--config-dir", file_okay=False)] = None,
305
+ force: bool = typer.Option(False, "--force"),
306
+ json_output: bool = typer.Option(False, "--json"),
307
+ ) -> None:
308
+ """Install the bundled skill and native tools into OpenCode."""
309
+
310
+ try:
311
+ target = config_dir or discover_opencode_config_dir().path
312
+ result = install_opencode(target, force=force)
313
+ _emit(
314
+ {
315
+ "config_dir": str(result.config_dir),
316
+ "skill_path": str(result.skill_path),
317
+ "tool_path": str(result.tool_path),
318
+ },
319
+ json_output,
320
+ )
321
+ except (PdfWorkspaceError, ValueError, OSError) as error:
322
+ _abort(error)