Metadata-Version: 2.5
Name: toolfuncs
Version: 0.1.0
Summary: Function-first Python tools with matching import and command-line interfaces
Project-URL: Repository, https://github.com/nimashoghi/toolfuncs
Project-URL: Issues, https://github.com/nimashoghi/toolfuncs/issues
Author-email: Nima Shoghi <nima@boltz.bio>
Requires-Python: >=3.11
Requires-Dist: cyclopts<5,>=4.23.2
Requires-Dist: fsspec[github,http]>=2025.3.0
Requires-Dist: packaging>=24
Requires-Dist: pip>=25
Requires-Dist: pydantic-core<3,>=2.20
Description-Content-Type: text/markdown

# toolfuncs

`toolfuncs` is a small function-calling convention for Python tools. A tool is a Python module whose typed public functions are its interface. The same functions can be called directly from Python or projected into a command-line interface without maintaining a second wrapper API.

Discoverable tools live in a project or user-wide tool directory in one of two forms: a PEP 723 Python file or one packaged `src` project layout. The separate `import_path()` utility can load broader Python sources from local paths and fsspec URLs.

## The basic model

Write ordinary typed functions and register the CLI-callable operations on `toolfuncs.App`:

```python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["toolfuncs"]
# [tool.toolfuncs]
# description = "Render values for another program."
# ///

from pathlib import Path

from toolfuncs import App

app = App()


@app.command
def render(value: str, *, output: Path | None = None) -> dict[str, object]:
    """Return a value and its optional output path."""

    return {"value": value, "output": output}


if __name__ == "__main__":
    app()
```

The Python interface is the definition:

```python
from pathlib import Path

from toolfuncs import renderer

result = renderer.render("hello", output=Path("result.txt"))
```

The CLI is derived from that same signature and returns strict JSON:

```console
$ toolfuncs renderer render hello --output result.txt
{"value": "hello", "output": "result.txt"}
```

`toolfuncs` directly depends on Cyclopts and `pydantic-core`. Its `App` is a Cyclopts app with the established strict-JSON result action as its default, so a tool imports only `toolfuncs` rather than importing and configuring those libraries itself. `Parameter`, `Token`, `Group`, `validators`, `types`, and `to_jsonable_python` are also re-exported for tools that need the corresponding advanced behavior. Passing `result_action=` explicitly preserves Cyclopts' ordinary override behavior for streaming or domain-specific status policies.

The decorator registers the original function without wrapping it. Direct Python calls therefore receive the original object and exceptions. CLI calls parse annotated values, emit one JSON value on stdout, and fail nonzero when parsing, execution, or serialization fails.

## Scoped tools

`toolfuncs` discovers tools from two roots:

1. the nearest `.agents/tools` directory at or above the process's initial working directory;
2. `~/.agents/tools`.

An immediate child defines a tool only when it has one of these exact shapes:

```text
name.py                              # PEP 723 script metadata required

name/
  pyproject.toml
  src/
    name/
      __init__.py
```

Both metadata containers require one static field:

```toml
[tool.toolfuncs]
description = "Describe this tool in one line."
```

For the file form, that table is inside the PEP 723 `script` block. For the packaged form, `pyproject.toml` must also define a matching normalized `[project].name` and an explicit `[build-system]`. The tool directory name, scoped name, and Python import name are the same valid non-keyword identifier. Flat projects, direct package directories, single-module projects, namespace roots, legacy `setup.py` projects, artifacts, and remote URLs are not discoverable tool forms.

A project tool shadows a user tool with the same name. The roots are captured when `toolfuncs` is first imported, so a long-running interpreter does not silently change its tool universe after `chdir()`.

This supports the portable dynamic-import form:

```python
from toolfuncs import codexr

agents = codexr.list_agents()
```

`from toolfuncs import name` is intentionally the portable form. `import toolfuncs.name` is not promised because scoped resolution is a dynamic package attribute rather than an installed `toolfuncs` submodule. Use `load_tool("name")` when a tool name conflicts with a `toolfuncs` API member.

Universal CLI dispatch always works after installing `toolfuncs`:

```console
toolfuncs codexr list-agents
toolfuncs run codexr list-agents
```

Run `toolfuncs sync` to add direct command shims for all currently visible tools to `~/.local/bin`, or choose another directory with `--bin-dir`. A shim performs scoped lookup again when invoked, so the same command correctly resolves project overrides. `sync` never overwrites an unmanaged command and does not remove older managed shims.

## Composing tools

A tool can import another scoped tool and call it like any Python module:

```python
from toolfuncs import codexr
from toolfuncs import App

app = App()


@app.command
def active_agent_ids() -> list[str]:
    return [agent.session_id for agent in codexr.list_agents()]
```

Imported or undecorated functions are not exposed accidentally. Only functions registered on `app` become CLI commands; `__all__` retains its ordinary Python export meaning but does not define the command surface.

## Importing arbitrary sources

`import_path()` handles the following v1 source forms:

| Source | Behavior |
| --- | --- |
| `tool.py` | executes directly; reads optional PEP 723 metadata |
| `package/__init__.py` | imports `package` directly |
| `package/` containing `__init__.py` | imports directly with relative imports and resources |
| project containing `pyproject.toml` | builds one wheel, installs it, then imports its selected module |
| project containing `setup.py` | builds one wheel through pip's legacy-project support |
| `.whl` | inspects, installs, and imports the wheel |
| source archive such as `.tar.gz` or `.zip` | builds, installs, and imports one wheel |
| any of the above behind an fsspec URL | materializes the complete source and applies the same classification |

Direct packages may declare dependencies in an adjacent `pyproject.toml` `[project]` table. PEP 723 metadata is read only from single Python files. Projects and artifacts leave dependency handling to their build metadata and pip.

Regular projects are interpreted by their build backend rather than guessed from their directory tree. The resulting wheel is inspected for actual top-level Python imports. This supports conventional flat and `src` layouts for both modules and packages, as well as installed namespace packages. A bare `src/__init__.py` is not treated as a package layout.

Import selection follows this order:

1. explicit `import_name=`;
2. a project directory name that matches an import in the built wheel;
3. the wheel's sole top-level import.

Ambiguous projects fail with their discovered candidates:

```python
from toolfuncs import import_path

pillow = import_path("./vendor/Pillow", import_name="PIL")
```

`import_name=` belongs only to this generic utility. Discoverable packaged tools never configure or infer their import name; the scoped name is passed to `import_path()` internally.

## Identity and execution environment

Within one interpreter, a canonical source and import name identify one module object. Re-importing the same source returns that object, one source cannot be assigned two names, and one name cannot be assigned two sources. Direct dotted imports construct missing namespace parents, support relative imports, clean up after failed execution, and work with `importlib.reload()`.

V1 installs dependencies and project wheels into the running interpreter environment under a process-wide import lock. This is deliberate: direct Python calls must receive the real module and Python objects rather than a proxy transport. It also means dependency conflicts are ordinary environment conflicts. Use a dedicated environment when mutually incompatible tools must coexist.

Credentials in source URLs and opaque URL queries are removed from user-facing diagnostics. `storage_options=` is passed directly to fsspec and is never incorporated into module names.

## Management commands

```console
toolfuncs list
toolfuncs describe TOOL
toolfuncs sync [--bin-dir DIRECTORY]
toolfuncs TOOL [ARGS...]
```

`list`, `describe`, and `sync` emit strict JSON. `list` and `describe` read only static TOML metadata and never import tools, resolve their dependencies, or run build code. Their records contain `name`, `path`, `scope`, `kind`, and `description`. Use `toolfuncs TOOL --help` when runtime command inspection is needed.

## Development

```console
uv sync
uv run pytest
uv run ruff format --check .
uv run ruff check .
uv run basedpyright
```

The detailed v1 guarantees and non-goals are recorded in [the contract](docs/contract.md).

## Releasing

The GitHub Release is the release control point. Set `[project].version`, merge and push that commit, then publish a GitHub Release whose tag is `v<version>`. The release workflow verifies that the tag and package version match, builds the wheel and source distribution, and publishes them to PyPI through the `pypi` environment and PyPI Trusted Publishing. It uses no long-lived PyPI token or repository secret.
