diff --git a/README.md b/README.md index 061f41a..85fa064 100644 --- a/README.md +++ b/README.md @@ -221,9 +221,10 @@ Your agent receives the returned excerpts; cloud agents handle them according to that client's configuration. On Windows x64, your agent can use the optional -[CLI/MCP download](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.0/searchi-cli_0.1.0_x64.exe) -with its [own checksum](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.0/CLI-SHA256SUMS.txt). -No Rust build is needed. Install the desktop app first for the model setup and +[CLI/MCP download](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.3/searchi-cli_0.1.0_x64-013.zip) +with its [own checksum](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.3/CLI-SHA256SUMS-0.1.3.txt). +Extract the ZIP alongside the installed desktop runtime helpers; it includes +the document extraction helper. No Rust build is needed. Install the desktop app first for the model setup and runtime helpers, then connect the CLI to that app's existing index. ## Optional answers, your choice diff --git a/docs/AGENT-INTEGRATION.md b/docs/AGENT-INTEGRATION.md index ade0fef..c9bdca5 100644 --- a/docs/AGENT-INTEGRATION.md +++ b/docs/AGENT-INTEGRATION.md @@ -8,10 +8,10 @@ same actual Rust/Google search engine. ## Configure an existing index Windows x64 users can download the optional -[CLI/MCP executable](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.0/searchi-cli_0.1.0_x64.exe) -and verify its [separate checksum file](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.0/CLI-SHA256SUMS.txt). -Keep it alongside the installed desktop's runtime helpers; rename it to -`searchi-cli.exe` if desired. The desktop installer itself is unchanged. +[CLI/MCP ZIP](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.3/searchi-cli_0.1.0_x64-013.zip) +and verify its [separate checksum file](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.3/CLI-SHA256SUMS-0.1.3.txt). +Extract it alongside the installed desktop's runtime helpers. Use +`searchi-cli.exe`; keep `searchi-extract-worker.exe` beside it for PDF/DOCX indexing. Use the desktop to finish model setup and semantic indexing before connecting. Pass that existing `searchi.sqlite`, model cache and `searchi-gemma-worker.exe` explicitly; avoid creating a second database for the same folders. @@ -193,3 +193,16 @@ Default tests use genuine SQLite retrieval without downloaded model assets. Hybrid mode uses the same real Google service; it has no simulated embeddings. Native Linux/macOS MCP execution and cross-platform packaging require their own recorded checks; Windows CPU checks do not establish those releases. + +## Current Windows CLI package + +The optional ZIP published alongside 0.1.3 contains CLI component 0.1.0 and +its verified document helper. Locked release build passed. Actual extracted +CLI indexed PDF/DOCX without helper overrides or extraction failures; MCP +initialization, three read-only tools, document search and cited source reads +passed on CPU. Two packaging tests passed normally and with Python `-O`. +Archive: 8784871 bytes, SHA-256 +`4ae415d6cf7d3c9e566933e1de84a85c08c1a92b3f89b7f285384795bab30d64`. +Public full-byte archive and checksum downloads matched. This does not prove +clean-machine installation or GPU behavior. Earlier evidence describes older +assets. Rebuild with `python tools/package_windows_cli.py --executable target/release/searchi.exe`. diff --git a/tools/package_windows_cli.py b/tools/package_windows_cli.py new file mode 100644 index 0000000..dfde3e6 --- /dev/null +++ b/tools/package_windows_cli.py @@ -0,0 +1,69 @@ +"""Package the optional Windows CLI/MCP client; reuse the installed desktop models.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import tempfile +import zipfile + +from package_windows import pe_machine +from stage_extraction_worker import verify + +ROOT = Path(__file__).resolve().parents[1] +MAX_FILE = 64 * 1024 * 1024 + + +def package(root, executable, resources, output): + root, executable, resources, output = map(Path, (root, executable, resources, output)) + def read(path): + if path.is_symlink() or not path.is_file() or path.absolute() != path.resolve(): + raise ValueError('Package input missing or linked') + with path.open('rb') as stream: + data = stream.read(MAX_FILE + 1) + if not data or len(data) > MAX_FILE: + raise ValueError('Package input empty or oversized') + return data + cli = read(executable) + architecture = {0x8664: 'x86_64', 0xAA64: 'aarch64'}.get(pe_machine(executable)) + if architecture is None: + raise ValueError('Unsupported Windows CLI architecture') + expected = verify(resources, root, 'windows', architecture) + selected = {'searchi-cli.exe': cli, 'LICENSE.txt': read(root / 'LICENSE')} + for name, record in expected.items(): + data = read(resources / name) + if len(data) != record['bytes'] or hashlib.sha256(data).hexdigest() != record['sha256']: + raise ValueError('Extraction resource changed while packaging') + selected[name] = data + # The captured CLI must still match the header used for architecture validation. + if read(executable) != cli: + raise ValueError('CLI changed while packaging') + records = [{'path': name, 'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest()} + for name, data in selected.items()] + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=output.parent, suffix='.partial', delete=False) as temporary: + temporary_path = Path(temporary.name) + try: + with zipfile.ZipFile(temporary_path, 'w', zipfile.ZIP_DEFLATED) as archive: + for name, data in selected.items(): + archive.writestr(name, data) + archive.writestr('PACKAGE.json', json.dumps({'platform': 'windows', 'architecture': architecture, + 'status': 'optional unsigned CLI/MCP client; install desktop for model setup', 'files': records}, indent=2)) + os.replace(temporary_path, output) + finally: + temporary_path.unlink(missing_ok=True) + return architecture + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--executable', type=Path, required=True) + parser.add_argument('--resources', type=Path, default=ROOT / 'apps/desktop/src-tauri/resources') + parser.add_argument('--output', type=Path, default=ROOT / '.searchi/packages/searchi-cli_0.1.0_x64-013.zip') + args = parser.parse_args() + print(f"Packaged Windows {package(ROOT, args.executable, args.resources, args.output)} CLI: {args.output}") + + +if __name__ == '__main__': + main() + diff --git a/tools/test_package_windows_cli.py b/tools/test_package_windows_cli.py new file mode 100644 index 0000000..cb1e790 --- /dev/null +++ b/tools/test_package_windows_cli.py @@ -0,0 +1,64 @@ +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +import zipfile + +from package_windows_cli import package +from test_package_windows import executable +import stage_extraction_worker as extraction + + +class CliPackageTests(unittest.TestCase): + def fixture(self, directory): + root = Path(directory) + (root / 'Cargo.toml').write_text('workspace') + (root / 'Cargo.lock').write_text('lock') + for crate in ('searchi-core', 'searchi-media', 'searchi-extract-worker'): + base = root / 'crates' / crate + (base / 'src').mkdir(parents=True) + (base / 'Cargo.toml').write_text(crate) + (base / 'src' / ('main.rs' if crate == 'searchi-extract-worker' else 'lib.rs')).write_text('// fixture') + (root / 'LICENSE').write_text('license') + cli = root / 'cli.exe' + helper = root / 'helper.exe' + executable(cli) + executable(helper) + resources = root / 'resources' + extraction.stage(helper, extraction.fingerprint(root), resources, root) + return root, cli, resources, root / 'client.zip' + + def test_integrity_and_minimal_contents(self): + with tempfile.TemporaryDirectory() as directory: + root, cli, resources, output = self.fixture(directory) + self.assertEqual(package(root, cli, resources, output), 'x86_64') + with zipfile.ZipFile(output) as archive: + self.assertEqual(set(archive.namelist()), {'searchi-cli.exe', 'searchi-extract-worker.exe', + 'extraction-worker.json', 'LICENSE.txt', 'PACKAGE.json'}) + manifest = json.loads(archive.read('PACKAGE.json')) + for record in manifest['files']: + data = archive.read(record['path']) + self.assertEqual(len(data), record['bytes']) + self.assertEqual(hashlib.sha256(data).hexdigest(), record['sha256']) + + def test_rejected_helper_preserves_output(self): + for mutation in ('missing', 'tampered', 'architecture'): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory: + root, cli, resources, output = self.fixture(directory) + output.write_bytes(b'previous') + helper = resources / 'searchi-extract-worker.exe' + if mutation == 'missing': + helper.unlink() + elif mutation == 'tampered': + helper.write_bytes(helper.read_bytes() + b'changed') + else: + executable(cli, 0xAA64) + with self.assertRaises(ValueError): + package(root, cli, resources, output) + self.assertEqual(output.read_bytes(), b'previous') + self.assertEqual(list(root.glob('*.partial')), []) + + +if __name__ == '__main__': + unittest.main()