Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Release: cargo publish + PyPI wheels, on a v* tag (or manual dispatch).
#
# Required secrets (set before the first release):
# CARGO_REGISTRY_TOKEN — crates.io (publishes modulex-core/-cli/-mcp;
# modulex-py is PyPI-only, publish = false)
# PYPI_API_TOKEN — PyPI (wheels: modulex-cli, modulex-mcp bin wheels
# + modulex-py extension wheel)
#
# HOOK PARITY note: this workflow RELEASES, it does not validate — ci.yml is
# the validation pipeline mirrored by .githooks/pre-push.
name: Release

on:
push:
tags: ["v*"]
workflow_dispatch:

jobs:
crates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Publish to crates.io (in dependency order)
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
cargo publish -p modulex-core
cargo publish -p modulex-cli
cargo publish -p modulex-mcp

wheels:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
crate: [modulex-cli, modulex-mcp, modulex-py]
exclude:
# The extension wheel needs per-Python builds only where we test;
# start with Linux and grow the matrix when there's demand.
- os: macos-latest
crate: modulex-py
- os: windows-latest
crate: modulex-py
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: PyO3/maturin-action@v1
with:
command: build
args: --release -m crates/${{ matrix.crate }}/Cargo.toml -o dist
- uses: actions/upload-artifact@v4
with:
name: wheel-${{ matrix.crate }}-${{ matrix.os }}
path: dist/*.whl

publish-pypi:
needs: wheels
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Upload to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: |
pip install twine
twine upload dist/*.whl
93 changes: 93 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
[workspace]
resolver = "2"
members = ["crates/modulex-core", "crates/modulex-cli"]
members = [
"crates/modulex-core",
"crates/modulex-cli",
"crates/modulex-mcp",
"crates/modulex-py",
]

[workspace.package]
version = "0.6.20260605"
Expand All @@ -22,5 +27,5 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
shell-words = "1.1"
thiserror = "2.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "time", "io-util"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "time", "io-util", "io-std"] }
toml = "0.8"
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
<p align="center">
<img src="docs/logos/modulex_logo_256x256.png" alt="modulex logo" width="160" />
</p>

# modulex

**A deterministic, pluggable routine engine for agents — CLI + MCP server.**
Expand All @@ -20,6 +24,67 @@ invocation, one report.

Early development. See `modulex.toml.example` for the configuration surface.

## Quick start

```bash
cp modulex.toml.example ~/.modulex/config.toml # then edit
modulex doctor # config path, leash, tool availability
modulex run morning --dry-run # describe without side effects
modulex run morning # the real thing
```

### As an MCP server

```bash
claude mcp add modulex -- modulex-mcp
```

or in newt's `~/.newt/config.toml`:

```toml
[[mcp_servers]]
name = "modulex"
command = "modulex-mcp"
```

Tools: `routine_run`, `routine_list`, `step_run`, `report_get`, `steps_list`.
Per-step failures are *data inside the report*; `isError` is reserved for
engine faults (unknown routine, config errors, leash denial). Reports are
identified by a monotonic generation counter, never a timestamp.

```bash
modulex-mcp --probe # dry-run the first routine and exit (sanity check)
modulex-mcp --tools # print the tool specs
```

## Extending with Python

Two tiers:

**1. Plugin protocol** (`type = "python"`, any language, leashed subprocess):
the engine writes one JSON object to stdin, reads one from stdout — see
`examples/standup_notes.py` and the `modulex-plugin/1` spec in
`crates/modulex-core/src/steps/python.rs`.

**2. In-process via `modulex-py`** (`pip install modulex-py`) — Python hosts
the engine, so Python handlers run inside routines exactly like builtins,
including over MCP:

```python
import modulex_py

engine = modulex_py.Engine.from_config()

@engine.step("standup-notes")
def standup(spec: dict, ctx: dict) -> dict:
return {"success": True, "output": "- shipped the leash"}

report = engine.run_routine("morning", dry_run=True)
print(report.to_text())

engine.serve_stdio() # MCP on stdio, Python steps included
```

## Design pillars

- **Deterministic**: a routine is config-defined data, not agent improvisation.
Expand Down
26 changes: 26 additions & 0 deletions crates/modulex-cli/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# PyPI bin wheel: `pip install modulex-cli` ships the `modulex` binary.
# bindings = "bin" — pure Rust, never links libpython (scrybe pattern).

[build-system]
requires = ["maturin>=1.7,<2.0"]
build-backend = "maturin"

[project]
name = "modulex-cli"
description = "Deterministic, pluggable routine engine — human CLI"
requires-python = ">=3.9"
license = { text = "MIT OR Apache-2.0" }
authors = [
{ name = "Shawn Hartsock", email = "hartsock@users.noreply.github.com" },
]
dynamic = ["version"]

[project.urls]
Repository = "https://github.com/hartsock/modulex-mcp"

[tool.maturin]
manifest-path = "Cargo.toml"
# Explicit module-name: without it maturin derives an invalid hyphenated
# name for bin bindings (the scrybe-mcp-server gotcha).
module-name = "modulex_cli"
bindings = "bin"
5 changes: 5 additions & 0 deletions crates/modulex-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ license.workspace = true
authors.workspace = true
repository.workspace = true

[features]
# Exposes exec::test_support (MockSpawner, gate_with) to downstream crates'
# tests. Dev-dependencies only.
test-support = []

[dependencies]
agent-bridle-core = { workspace = true }
anyhow = { workspace = true }
Expand Down
14 changes: 12 additions & 2 deletions crates/modulex-core/src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,9 +258,13 @@ pub fn program_available(program: &str) -> bool {
std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
}

#[cfg(test)]
pub(crate) mod test_support {
#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
//! A canned-output spawner for unit tests, plus a gate factory.
//!
//! Available to downstream crates' tests via the `test-support` feature
//! (dev-dependencies only — never enable it in a normal build). House
//! rule: unit tests NEVER spawn real processes; this is how.

use std::collections::VecDeque;
use std::sync::Mutex;
Expand All @@ -278,13 +282,17 @@ pub(crate) mod test_support {
}

impl MockSpawner {
/// A spawner that replies with `outputs`, in order (then empty-ok).
#[must_use]
pub fn with_outputs(outputs: Vec<ExecOutput>) -> Self {
Self {
outputs: Mutex::new(outputs.into()),
calls: Mutex::new(Vec::new()),
}
}

/// A canned success with this stdout.
#[must_use]
pub fn ok(stdout: &str) -> ExecOutput {
ExecOutput {
stdout: stdout.to_string(),
Expand All @@ -293,6 +301,8 @@ pub(crate) mod test_support {
}
}

/// A canned failure with this stderr and exit code.
#[must_use]
pub fn fail(stderr: &str, code: i32) -> ExecOutput {
ExecOutput {
stderr: stderr.to_string(),
Expand Down
Loading
Loading