diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..60a894a --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,13 @@ +#!/bin/sh +# Workflow counterparts: .github/workflows/ci.yml and .github/workflows/release.yml + +set -eu + +just check +just test + +if command -v npm >/dev/null 2>&1; then + (cd scrybe-app && npm ci && npm run build) +else + echo "npm not found; skipping TypeScript build" +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bef8eb4..b9b2128 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,4 @@ +# Hook parity counterpart: .githooks/pre-push name: CI # Runs on self-hosted ARC runners for fast feedback on lint and tests. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37de7ea..c8ebd15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,4 @@ +# Hook parity counterpart: .githooks/pre-push name: Release on: @@ -97,9 +98,9 @@ jobs: **Python (all platforms):** ```bash - pip install scrybe.ai # full toolkit (library + CLI + MCP server + mermaid) + pip install scrybe.ai # full toolkit (library + CLI + MCP server + mermaid + docx) # or pick individual pieces: - pip install scrybe-cli scrybe-mcp-server scrybe-py scrybe-mermaid + pip install scrybe-cli scrybe-mcp-server scrybe-py scrybe-mermaid scrybe-plugin-docx ``` ### What's new @@ -196,6 +197,29 @@ jobs: name: meta-scrybe-ai path: scrybe-meta/dist/* + # ── Pure-Python docx exporter package ──────────────────────────────────── + + build-docx: + name: Package (scrybe-plugin-docx) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build tooling + run: python -m pip install --upgrade build + + - name: Build sdist + wheel + working-directory: scrybe-plugin-docx + run: python -m build --outdir dist + + - uses: actions/upload-artifact@v4 + with: + name: package-scrybe-plugin-docx + path: scrybe-plugin-docx/dist/* + # ── Publish to PyPI (trusted publishing — OIDC, per-package) ────────────── # # One job per PyPI project so a missing-publisher 403 on one doesn't abort @@ -210,7 +234,7 @@ jobs: publish-pypi: name: Publish ${{ matrix.package.name }} - needs: [build-wheels, build-sdists, build-meta] + needs: [build-wheels, build-sdists, build-meta, build-docx] runs-on: ubuntu-latest environment: pypi permissions: @@ -228,6 +252,7 @@ jobs: - { name: scrybe-py, glob: scrybe_py-* } - { name: scrybe-mcp-server, glob: scrybe_mcp_server-* } - { name: scrybe-mermaid, glob: scrybe_mermaid-* } + - { name: scrybe-plugin-docx, glob: scrybe_plugin_docx-* } steps: - name: Download all artifacts @@ -285,7 +310,7 @@ jobs: publish-crates: name: Publish ${{ matrix.crate.name }} → crates.io if: startsWith(github.ref, 'refs/tags/') - needs: [build-wheels, build-sdists, build-meta] + needs: [build-wheels, build-sdists, build-meta, build-docx] runs-on: scrybe-k3s strategy: fail-fast: false # let independent leaves keep going if one of them fails diff --git a/Cargo.lock b/Cargo.lock index e2aad0a..44a2cf4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3612,10 +3612,12 @@ dependencies = [ "scrybe-render", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", + "which", ] [[package]] diff --git a/README.md b/README.md index 747f0f6..b8fa48a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ as MCP peers. Scrybe is itself an MCP server, drivable by external agents. ### Python (PyPI) ```bash -pip install scrybe.ai # full Python toolkit (library + CLI + MCP server + mermaid) +pip install scrybe.ai # full Python toolkit (library + CLI + MCP server + mermaid + docx) ``` Or pick individual components: @@ -24,6 +24,7 @@ pip install scrybe-py # PyO3 library — exposes `import scrybe` pip install scrybe-cli # `scrybe` command-line tool pip install scrybe-mcp-server # standalone MCP server binary pip install scrybe-mermaid # PNG iTXt codec for embedded Mermaid sources +pip install scrybe-plugin-docx # Word (.docx) exporter ``` ### Rust (crates.io) @@ -77,7 +78,8 @@ git clone https://github.com/hartsock/scrybe cd scrybe just build # all crates just dev # Tauri dev server (requires Node) -just install # build + install to ~/Applications and ~/venv/bin +just install # build + install the app and runtime tools to ~/Applications and ~/venv/bin +just install-app # same app install path, including the Word exporter just check # full lint + test suite ``` @@ -104,11 +106,12 @@ Python on the outside, Rust on the inside. | Package | Install | What | |---|---|---| -| `scrybe.ai` | `pip install scrybe.ai` | Metapackage — pulls in the four wheels below | +| `scrybe.ai` | `pip install scrybe.ai` | Metapackage — pulls in the packages below | | `scrybe-py` | `pip install scrybe-py` | PyO3 library — `import scrybe` | | `scrybe-cli` | `pip install scrybe-cli` | `scrybe` CLI binary | | `scrybe-mcp-server` | `pip install scrybe-mcp-server` | `scrybe-mcp-server` binary | | `scrybe-mermaid` | `pip install scrybe-mermaid` | PNG iTXt codec | +| `scrybe-plugin-docx` | `pip install scrybe-plugin-docx` | Word (.docx) exporter | ### crates.io diff --git a/justfile b/justfile index b39979d..2d6e099 100644 --- a/justfile +++ b/justfile @@ -24,20 +24,37 @@ clean: cargo clean # Full install: build app + all Python packages into ~/venv, bundle to ~/Applications -install: app - rm -f ~/venv/bin/scrybe ~/venv/bin/scrybe-app ~/venv/bin/scrybe-mcp-server +install: install-app + +# Install the desktop app plus its runtime Python tools. +install-app: app install-python-toolkit rm -rf ~/Applications/Scrybe.app + rm -f ~/venv/bin/scrybe-app + mkdir -p ~/venv/bin cp target/release/bundle/macos/Scrybe.app/Contents/MacOS/scrybe-app ~/venv/bin/scrybe-app mkdir -p ~/Applications cp -R target/release/bundle/macos/Scrybe.app ~/Applications/ - cd scrybe-mcp-server && ~/venv/bin/maturin develop --release - cd scrybe-cli && ~/venv/bin/maturin develop --release + +# Alias for people looking for the app-specific install recipe. +app-install: install-app + +# Install the Python toolkit entry points the app shells out to at runtime. +install-python-toolkit: + mkdir -p ~/venv/bin + rm -f ~/venv/bin/scrybe ~/venv/bin/scrybe-mcp-server ~/venv/bin/scrybe-docx + cd scrybe-py && VIRTUAL_ENV="$HOME/venv" ~/venv/bin/maturin develop --release + cd scrybe-mermaid && VIRTUAL_ENV="$HOME/venv" ~/venv/bin/maturin develop --release + cd scrybe-mcp-server && VIRTUAL_ENV="$HOME/venv" ~/venv/bin/maturin develop --release + cd scrybe-cli && VIRTUAL_ENV="$HOME/venv" ~/venv/bin/maturin develop --release + cd scrybe-plugin-docx && ~/venv/bin/python -m pip install -e . # Install all Python packages in editable/dev mode (compiles Rust binaries) editable: - pip install -e . + cd scrybe-py && maturin develop --release + cd scrybe-mermaid && maturin develop --release cd scrybe-mcp-server && maturin develop --release cd scrybe-cli && maturin develop --release + cd scrybe-plugin-docx && python -m pip install -e . # Build the Tauri desktop app (requires npm install first) app: diff --git a/scrybe-app/README.md b/scrybe-app/README.md index 2033907..408b403 100644 --- a/scrybe-app/README.md +++ b/scrybe-app/README.md @@ -94,3 +94,7 @@ cargo test -p scrybe-app On macOS the production build produces `scrybe-app/target/release/bundle/macos/Scrybe.app`. Install to `~/Applications/Scrybe.app` for the CLI launcher to find it automatically. + +From the repository root, `just install-app` builds and installs the desktop app +and the Python runtime tools it shells out to, including the Word (`.docx`) +exporter. diff --git a/scrybe-app/src-tauri/src/lib.rs b/scrybe-app/src-tauri/src/lib.rs index 59de6eb..ed9afea 100644 --- a/scrybe-app/src-tauri/src/lib.rs +++ b/scrybe-app/src-tauri/src/lib.rs @@ -795,14 +795,52 @@ fn poll_set_vim() -> Option { poll_signal("/tmp/scrybe-set-vim.txt") } +fn executable_name(stem: &str) -> String { + if cfg!(windows) { + format!("{stem}.exe") + } else { + stem.to_string() + } +} + +fn home_venv_bin(name: &str) -> Option { + let bin_dir = if cfg!(windows) { "Scripts" } else { "bin" }; + dirs::home_dir().map(|home| home.join("venv").join(bin_dir).join(name)) +} + +fn existing_file(path: std::path::PathBuf) -> Option { + if path.is_file() { + Some(path.to_string_lossy().into_owned()) + } else { + None + } +} + /// Locate the `scrybe-docx` CLI (from the `scrybe-plugin-docx` package). fn which_scrybe_docx() -> Result { - which::which("scrybe-docx") - .map(|p| p.to_string_lossy().into_owned()) - .map_err(|_| { - "scrybe-docx not found on PATH. Install with: pip install scrybe-plugin-docx" - .to_string() - }) + if let Ok(path) = std::env::var("SCRYBE_DOCX_BIN") { + if let Some(path) = existing_file(std::path::PathBuf::from(path)) { + return Ok(path); + } + } + + let name = executable_name("scrybe-docx"); + if let Ok(exe) = std::env::current_exe() { + if let Some(path) = existing_file(exe.with_file_name(&name)) { + return Ok(path); + } + } + if let Ok(path) = which::which(&name) { + return Ok(path.to_string_lossy().into_owned()); + } + if let Some(path) = home_venv_bin(&name).and_then(existing_file) { + return Ok(path); + } + + Err( + "scrybe-docx not found. Reinstall the Scrybe Python toolkit with docx export support or set SCRYBE_DOCX_BIN to the exporter executable." + .to_string(), + ) } /// Export Markdown `content` to a Word (.docx) file at `output` by shelling @@ -1092,4 +1130,26 @@ mod tests { assert!(!bak.exists()); assert!(!bp.exists()); } + + #[test] + fn docx_binary_name_is_platform_specific() { + let name = executable_name("scrybe-docx"); + if cfg!(windows) { + assert_eq!(name, "scrybe-docx.exe"); + } else { + assert_eq!(name, "scrybe-docx"); + } + } + + #[test] + fn existing_file_returns_candidate_path() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(executable_name("scrybe-docx")); + std::fs::write(&path, "#!/bin/sh\n").expect("seed exporter"); + + assert_eq!( + existing_file(path.clone()), + Some(path.to_string_lossy().into_owned()) + ); + } } diff --git a/scrybe-mcp-server/Cargo.toml b/scrybe-mcp-server/Cargo.toml index 37e07d7..fcf49e7 100644 --- a/scrybe-mcp-server/Cargo.toml +++ b/scrybe-mcp-server/Cargo.toml @@ -26,6 +26,10 @@ tracing.workspace = true tracing-subscriber.workspace = true tokio.workspace = true clap.workspace = true +which.workspace = true + +[dev-dependencies] +tempfile = "3" [lints] workspace = true diff --git a/scrybe-mcp-server/src/tools.rs b/scrybe-mcp-server/src/tools.rs index e819bbb..b64466f 100644 --- a/scrybe-mcp-server/src/tools.rs +++ b/scrybe-mcp-server/src/tools.rs @@ -38,6 +38,59 @@ pub const TOOL_NAMES: &[&str] = &[ /// Path shared between the Tauri app's `log_append` command and this tool. const LOG_FILE: &str = "/tmp/scrybe-debug.log"; +fn executable_name(stem: &str) -> String { + if cfg!(windows) { + format!("{stem}.exe") + } else { + stem.to_string() + } +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .or_else(|| std::env::var_os("USERPROFILE").map(std::path::PathBuf::from)) +} + +fn home_venv_bin(name: &str) -> Option { + let bin_dir = if cfg!(windows) { "Scripts" } else { "bin" }; + home_dir().map(|home| home.join("venv").join(bin_dir).join(name)) +} + +fn existing_file(path: std::path::PathBuf) -> Option { + if path.is_file() { + Some(path.to_string_lossy().into_owned()) + } else { + None + } +} + +fn which_scrybe_docx() -> Result { + if let Ok(path) = std::env::var("SCRYBE_DOCX_BIN") { + if let Some(path) = existing_file(std::path::PathBuf::from(path)) { + return Ok(path); + } + } + + let name = executable_name("scrybe-docx"); + if let Ok(exe) = std::env::current_exe() { + if let Some(path) = existing_file(exe.with_file_name(&name)) { + return Ok(path); + } + } + if let Ok(path) = which::which(&name) { + return Ok(path.to_string_lossy().into_owned()); + } + if let Some(path) = home_venv_bin(&name).and_then(existing_file) { + return Ok(path); + } + + Err( + "scrybe-docx not found. Reinstall the Scrybe Python toolkit with docx export support or set SCRYBE_DOCX_BIN to the exporter executable." + .to_string(), + ) +} + /// Registry of all scrybe MCP tools plus the open-document workspace. pub struct ToolRegistry { workspace: Workspace, @@ -698,7 +751,12 @@ impl ToolRegistry { }; let no_diagrams = args["no_diagrams"].as_bool().unwrap_or(false); - let mut cmd = std::process::Command::new("scrybe-docx"); + let bin = match which_scrybe_docx() { + Ok(bin) => bin, + Err(e) => return json!({"error": e}), + }; + + let mut cmd = std::process::Command::new(bin); cmd.arg(&input).arg("-o").arg(&output); if no_diagrams { cmd.arg("--no-diagrams"); @@ -709,7 +767,7 @@ impl ToolRegistry { "error": String::from_utf8_lossy(&out.stderr).trim().to_string() }), Err(e) => json!({ - "error": format!("failed to run scrybe-docx ({e}). Install: pip install scrybe-plugin-docx") + "error": format!("failed to run scrybe-docx ({e})") }), } } @@ -847,6 +905,28 @@ mod tests { assert!(result["error"].as_str().unwrap().contains("input required")); } + #[test] + fn test_docx_binary_name_is_platform_specific() { + let name = executable_name("scrybe-docx"); + if cfg!(windows) { + assert_eq!(name, "scrybe-docx.exe"); + } else { + assert_eq!(name, "scrybe-docx"); + } + } + + #[test] + fn test_existing_file_returns_candidate_path() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(executable_name("scrybe-docx")); + std::fs::write(&path, "#!/bin/sh\n").expect("seed exporter"); + + assert_eq!( + existing_file(path.clone()), + Some(path.to_string_lossy().into_owned()) + ); + } + #[test] fn test_unknown_tool_returns_error() { let mut reg = ToolRegistry::new(); diff --git a/scrybe-mcp-server/tests/docx_packaging.rs b/scrybe-mcp-server/tests/docx_packaging.rs new file mode 100644 index 0000000..ae329cb --- /dev/null +++ b/scrybe-mcp-server/tests/docx_packaging.rs @@ -0,0 +1,73 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf() +} + +fn read_repo_file(path: &str) -> String { + fs::read_to_string(repo_root().join(path)).unwrap_or_else(|e| panic!("read {path}: {e}")) +} + +#[test] +fn metapackage_depends_on_docx_exporter() { + let pyproject = read_repo_file("scrybe-meta/pyproject.toml"); + + assert!( + pyproject.contains("\"scrybe-plugin-docx == 0.3.0\""), + "scrybe.ai should install the Word exporter" + ); +} + +#[test] +fn release_workflow_builds_and_publishes_docx_exporter() { + let workflow = read_repo_file(".github/workflows/release.yml"); + + assert!( + workflow.contains("build-docx:"), + "release workflow should build the pure-Python docx package" + ); + assert!( + workflow.contains("working-directory: scrybe-plugin-docx"), + "release workflow should build from the docx package directory" + ); + assert!( + workflow.contains("{ name: scrybe-plugin-docx, glob: scrybe_plugin_docx-* }"), + "release workflow should publish scrybe-plugin-docx to PyPI" + ); + assert!( + workflow.contains("needs: [build-wheels, build-sdists, build-meta, build-docx]"), + "PyPI publication should wait for the docx package artifacts" + ); +} + +#[test] +fn local_install_wires_docx_exporter() { + let justfile = read_repo_file("justfile"); + + assert!( + justfile.contains("install: install-app"), + "the default install should route through the app install recipe" + ); + assert!( + justfile.contains("install-app: app install-python-toolkit"), + "app install should include the Python runtime tools" + ); + assert!( + justfile.contains( + "cd scrybe-mermaid && VIRTUAL_ENV=\"$HOME/venv\" ~/venv/bin/maturin develop --release" + ), + "local install should install the local Mermaid Python binding before docx" + ); + assert!( + justfile.contains("cd scrybe-plugin-docx && ~/venv/bin/python -m pip install -e ."), + "local install should install the docx exporter entry point" + ); + assert!( + justfile.contains("cd scrybe-plugin-docx && python -m pip install -e ."), + "editable install should install the docx exporter entry point" + ); +} diff --git a/scrybe-meta/README.md b/scrybe-meta/README.md index 92c382b..771252e 100644 --- a/scrybe-meta/README.md +++ b/scrybe-meta/README.md @@ -14,6 +14,7 @@ This pulls in: | [`scrybe-cli`](https://pypi.org/project/scrybe-cli/) | `scrybe` command-line tool — render / lint / mermaid | | [`scrybe-mcp-server`](https://pypi.org/project/scrybe-mcp-server/) | Standalone MCP server binary | | [`scrybe-mermaid`](https://pypi.org/project/scrybe-mermaid/) | PNG iTXt codec — embeds Mermaid source in PNG metadata | +| [`scrybe-plugin-docx`](https://pypi.org/project/scrybe-plugin-docx/) | Word (.docx) exporter used by the desktop Export button and MCP `export` tool | Each component is also installable on its own if you only need one. This metapackage exists so `pip install scrybe.ai` Just Works for users who want the whole kit. diff --git a/scrybe-meta/pyproject.toml b/scrybe-meta/pyproject.toml index ee5e361..3b87d79 100644 --- a/scrybe-meta/pyproject.toml +++ b/scrybe-meta/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "scrybe.ai" version = "0.2.1.dev0" -description = "Scrybe — MCP-native Markdown editor. Metapackage installing the full Python toolkit (library + CLI + MCP server + Mermaid codec)." +description = "Scrybe — MCP-native Markdown editor. Metapackage installing the full Python toolkit (library + CLI + MCP server + Mermaid codec + docx export)." license = { text = "Apache-2.0" } readme = "README.md" requires-python = ">=3.9" @@ -30,6 +30,7 @@ dependencies = [ "scrybe-cli == 0.3.0", "scrybe-mcp-server == 0.3.0", "scrybe-mermaid == 0.3.0", + "scrybe-plugin-docx == 0.3.0", ] [project.urls] diff --git a/scrybe-meta/src/scrybe_ai/__init__.py b/scrybe-meta/src/scrybe_ai/__init__.py index ab0c0ab..e5bb884 100644 --- a/scrybe-meta/src/scrybe_ai/__init__.py +++ b/scrybe-meta/src/scrybe_ai/__init__.py @@ -6,6 +6,7 @@ - ``scrybe-cli`` — the ``scrybe`` command-line tool - ``scrybe-mcp-server`` — standalone MCP server binary - ``scrybe-mermaid`` — PNG iTXt codec for Mermaid source embedding +- ``scrybe-plugin-docx`` — Word (.docx) export CLI The real APIs live in those packages; this module exists only as a distribution anchor so the metapackage has a valid wheel.