pyskills API

API details
from fastcore.test import test,test_eq,expect_fail

Overview

A plugin system allowing Python packages to register “skills” — units of LLM-usable functionality — via standard Python entry points. An LLM harness (e.g. solveit) discovers available pyskills without importing them, reads lightweight descriptions via AST inspection, and selectively loads chosen pyskills into context.

Entry Point Convention

Packages register pyskills under the group pyskills:

[project.entry-points.pyskills]
my_skill = "mypackage.skill"

The value is a module path (no :attribute needed). The module’s docstring first paragraph serves as the pyskill description.

Skill Module Contract

A pyskill module MUST have:

  • Docstring — first paragraph is the short description shown to the LLM for pyskill selection; remaining will be read by the LLM to get full details on the pyskill.

A pyskill module MAY have:

  • __all__ — the available symbols imported.

Discovery API

def list_pyskills() -> dict[str, str]

Returns {name: description} for all registered pyskills, using find_spec + AST parsing — no imports.

import mypackage.skill

Standard python native import

doc(mypackage.skill)       # module overview: classes, functions, submodules
doc(SomeClass)             # class detail: bases, __init__, methods, properties
doc(some_func)             # function detail: full signature with docments
xdir(mypackage.skill)      # filtered names for public symbols

Inspect at increasing detail — works on any Python module, not just pyskills.

Host Integration

The harness (e.g. solveit, claude code, codex, …) would:

  1. Call list_pyskills() at startup to build a pyskill catalogue
  2. Include the list with each prompt
  3. Call import {module} followed by doc({module}) for chosen pyskills

Listing pyskills

ep = entry_points()
es = first(ep.select(group='pyskills', name='pyskills.skill'))
es
EntryPoint(name='pyskills.skill', value='pyskills.skill', group='pyskills')

source

ep_desc

def ep_desc(
    ep
):

First paragraph of docstring for entry point ep, without importing it

print(ep_desc(es))
Pyskills is a plugin system allowing Python packages to register "skills" (units of LLM-usable functionality) via standard Python entry points. An LLM host (e.g. solveit) discovers available pyskills without importing them, reads lightweight descriptions via AST inspection, and selectively loads chosen pyskills into context using standard imports.

source

list_pyskills

def list_pyskills():

Returns {module: description} for all pyskills. To load a module, use import {module} then view `doc({module}). NB: pyskills are THE critical way to extend functionality. ALWAYS check for pyskills to help with tasks. If unsure whether a particular pyskill might help, load it and grabs its docs to see!

list_pyskills()
{'pyskills.edit': 'Functions for creating, viewing, and modifying files. Each editing operation returns unified diffs showing what changed. Where the `exhash` pyskill is available, prefer it for editing: its hash-verified addressing fails loudly on stale context instead of editing nearby text.',
 'pyskills.ipynb': 'Functions for view/modifying ipynb file notebook cells. Each operation returns unified diffs showing what changed. Where `exhash` is available, prefer its hash-verified editing for cell source changes.',
 'pyskills.skill': 'Pyskills is a plugin system allowing Python packages to register "skills" (units of LLM-usable functionality) via standard Python entry points. An LLM host (e.g. solveit) discovers available pyskills without importing them, reads lightweight descriptions via AST inspection, and selectively loads chosen pyskills into context using standard imports.',
 'tracefunc.skill': "Trace a Python function's execution at AST-line level: per-line hit counts and live variable values, via `sys.monitoring`. Use when debugging *why* code takes a branch, loops, recurses, or computes a wrong value, without editing the code under investigation or using an interactive debugger.",
 'dialoghelper.solveitskill': 'Read, search, edit, and manage Solveit dialogs using dialoghelper.core, including dialog/message addressing, line-numbered inspection, targeted message edits, add/update/delete/copy/paste workflows, and safe editing patterns.',
 'dialoghelper.termskill': 'Read and edit Solveit dialog (or Jupyter) .ipynb files from a CLI / script. Solveit is an online notebook application (like Jupyter with AI integration) where each notebook is called a "dialog" and is stored as an `.ipynb` file containing `code`, `note` (markdown), and `prompt` (markdown with a special delimiter) messages (aka "cells"). The `dialoghelper` package provides tools for reading, searching, adding, updating, and deleting those messages.',
 'ghapi.skill': 'GitHub REST API access via `GhApi`, plus local git operations via `fastgit.Git`. Use this for day-to-day GitHub work: reading/creating issues and PRs, checking CI status, managing releases/branches/gists, and repo-local git operations -- all from Python, no shelling out to `gh`/`git` needed.',
 'rgapi.skill': 'Fast and flexible file discovery and search for Python. Use this when code needs `fd`-style file finding or `rg`-style searching.',
 'exhash.skill': 'Universal hash-verified text editing for local files. Use this when an LLM needs one safe editing interface for reading, previewing, and modifying text files.',
 'cordslite.skill': 'Load this skill when an agent needs to search, summarize, or find information in Discord using cordslite. It covers read-only workflows for connecting to Discord, opening a guild, orienting through channels, searching messages, reading threads, and fetching attachments.',
 'bgtmux.skill': 'Use tmux-backed background terminal sessions from Solveit. Useful to have a persistent terminal session that both you and the user can inspect and edit, and that you can send input to from Solveit.',
 'clikernel.skill': 'Use the persistent `clikernel` MCP session as the default workspace for any task advanced through live Python execution -- stateful inspection, file-editing workflows, debugging, experiments, API probes, data transforms, or notebook-style work. Read this before writing, running, or debugging Python code in a session with `clikernel` connected.'}

allow


source

allow

def allow(
    *c, allow_policy:NoneType=None, # Callable that raises if call not allowed
):

Add all items in c to __pytools__, optionally constrained by allow_policy

__pytools__ is a defaultdict(set) mapping classes, modules, or instances to their allowed method/function names (or ... for all public methods). Values can be plain strings or (name, AllowPolicy) tuples for allow-checked methods. allow registers entries — plain functions are added under their module, methods under their class, callable instances under the instance itself (as '__call__'), and dicts go directly into __pytools__. If an object defines __allow__, that’s called instead and its result registered.

def _test_fn(): pass
_test_fn.__module__ = '__main__'
_test_fn.__name__ = 'my_test_func'
allow(_test_fn)
assert 'my_test_func' in __pytools__[sys.modules['__main__']]
allow({str: ['zfill']})
assert 'zfill' in __pytools__[str]
allow({list: ...})
assert ... in __pytools__[list]
__pytools__[sys.modules['__main__']].discard('my_test_func')

allow(collections.Counter.most_common)
assert 'most_common' in __pytools__[collections.Counter]
__pytools__[collections.Counter].discard('most_common')
import httpx
def chk_url(url, *args, **kwargs):
    if not url.startswith('https://'): raise PermissionError()

allow({httpx.get: chk_url, httpx.post: chk_url})
assert ('get', chk_url) in __pytools__[httpx._api]
assert ('post', chk_url) in __pytools__[httpx._api]
from fastcore.xtras import timed_cache
@timed_cache(60)
def wrapped_tool(): return "ok"

allow(wrapped_tool)
assert 'wrapped_tool' in __pytools__[sys.modules['__main__']]

Callable instances — like the dynamically-generated ops of a spec-driven client — have no useful qualname, so they’re registered under the instance itself with '__call__' (and the class’s __call__ is wrapped for call-tracking). An object can also define __allow__, returning a list of items to register in its place; allow recurses into it, so a container can register all its callables at once.

class _CallableTool:
    async def __call__(self, x): return x

tool = _CallableTool()
allow(tool)
assert '__call__' in __pytools__[tool]
assert getattr(_CallableTool.__call__, '_fastaudit_orig', None) is not None

class _ToolGroup:
    def __init__(self, *ops): self.ops = list(ops)
    def __allow__(self): return self.ops

tool2 = _CallableTool()
allow(_ToolGroup(tool2))
assert '__call__' in __pytools__[tool2]

allow raises a pyskills.allow audit event before registering anything. Outside a sandbox that’s a no-op; inside safepyrun’s audit context it means sandboxed code can’t broaden its own permissions by calling allow, since the event is denied like any other unapproved operation:

seen = []
sys.addaudithook(lambda ev,args: seen.append(args) if ev=='pyskills.allow' else None)
allow(_test_fn)
test_eq(seen[-1], ((_test_fn,),))
__pytools__[sys.modules['__main__']].discard('my_test_func')

Allow policies


source

chk_dest

def chk_dest(
    p, ok_dests
):

Call self as a function.

chk_dest resolves a path and verifies it falls under one of the allowed destination prefixes. Raises PermissionError if not. Used by all AllowPolicy subclasses.

chk_dest('/tmp/foo.txt', ['/tmp'])
chk_dest('~/tmp/foo.txt', [Path.home()/'tmp'])
try: chk_dest('/etc/passwd', ['/tmp'])
except PermissionError: print("Correctly blocked /etc/passwd")
Correctly blocked /etc/passwd

source

OpenWritePolicy

def OpenWritePolicy(
    *args, **kwargs
):

Check open() only when mode is writable


source

PathWritePolicy

def PathWritePolicy(
    target_pos:NoneType=None, target_kw:NoneType=None
):

Check resolved Path self, optionally also target args


source

PosAllowPolicy

def PosAllowPolicy(
    pos:int=0, kw:NoneType=None
):

Check positional/keyword arg is an allowed destination


source

AllowPolicy

def AllowPolicy(
    *args, **kwargs
):

Base for allow destination policies

Three AllowPolicy subclasses handle different allow-checking patterns.

pp = PosAllowPolicy(1, 'dst')
pp(None, ['src', '/tmp/ok'], {}, {'ok_dests': ['/tmp']})
with expect_fail(PermissionError): pp(None, ['src', '/root/bad'], {}, {'ok_dests': ['/tmp']})

pwp = PathWritePolicy()
pwp(Path('/tmp/f.txt'), [], {}, {'ok_dests': ['/tmp']})
with expect_fail(PermissionError): pwp(Path('/etc/f.txt'), [], {}, {'ok_dests': ['/tmp']})

owp = OpenWritePolicy()
owp(None, ['/tmp/f.txt', 'w'], {}, {'ok_dests': ['/tmp']})
owp(None, ['/etc/passwd', 'r'], {}, {'ok_dests': ['/tmp']})
with expect_fail(PermissionError): owp(None, ['/root/f.txt', 'w'], {}, {'ok_dests': ['/tmp']})

Allow policies are stored as (name, AllowPolicy) tuples directly inside __pytools__ sets.

doc / xdir


source

resolve

def resolve(
    sym_nm:str, # Dotted symbol path, with optional [n] indexing, e.g. "module.attr.subattr[1]"
):

Resolve a dotted symbol string to its Python object, with optional [n] indexing


source

SymbolNotFound

def SymbolNotFound(
    *args, **kwargs
):

Common base class for all non-exit exceptions.

with expect_fail(SymbolNotFound): resolve('abc')
resolve('resolve'), resolve('len')
(<function __main__.resolve(sym_nm: str)>, <function len(obj, /)>)
import pyskills.skill
assert _is_own(pyskills.skill, 'skill_test_func')
assert _is_own(pyskills.skill, 'SkillTestClass')
assert not _is_own(pyskills.skill, 'inspect')
assert not _is_own(pyskills.skill, '_private')

xdir returns public names for a module, class, or instance. Modules respect __all__ and include explicitly imported sibling submodules; classes include __init__ and public methods; instances opt in by defining __dir__. Pass q to filter the names with a case-insensitive regex.


source

xdir

def xdir(
    sym:str | object, # Module, class, or instance to inspect
    q:str=None, # Optional case-insensitive regex over names
):

Filtered names for public symbols of a module or class (or anything with __dir__)

x = xdir(pyskills.skill)
test_eq(x, xdir('pyskills.skill'))
x
['SkillTestClass',
 'async_skill_test_func',
 'skill_test_func',
 'pyskills.createskill']

source

doc

def doc(
    sym:str | object, # Object (or dotted name) to document
    *syms:str | object, # More objects: each doc appended as its own blank-line-separated section
)->str:

Docstring of modules, classes, functions, instances or any other Python objects.

For a function, doc renders the docstring and full signature with docments (parameter comments):

d = doc(pyskills.skill.skill_test_func)
test_eq(d, doc('pyskills.skill.skill_test_func'))
d
def skill_test_func(
    x:int=0, # the input
)->str: # the output"""A test function"""

source

fmt_sig

def fmt_sig(
    f, ps:NoneType=None
):

Call self as a function.

This is a simple helper that removes module names from str(signature(...)):

def f(a:Path|str = "aa"): ...
print(fmt_sig(f))
(a: Path | str = 'aa')

For a class, doc shows the class hierarchy, docstring, __init__ signature, and all public methods/properties with their first docstring line:

doc(pyskills.skill.SkillTestClass)
class SkillTestClass(str):
    """Some class.
    More info about it."""
    def __init__(self): ...
    def f(self, x: int = 0) -> str: ...  # A test method
    @property
    def g(self) -> str: ...  # A test prop

For a module, doc shows the docstring, all public classes and functions with their signatures and first docstring line, submodules, and any allow() calls:

print(doc(pyskills.skill)[-450:])
st_func)

## Creating pyskills

`from pyskills import createskill; doc(createskill)` for how to build and register your own pyskill modules.
"""

## types:
- class SkillTestClass(str): ...  # Some class.

## functions:
- async def async_skill_test_func(x: int = 0) -> str: ...  # A test function…
- def skill_test_func(x: int = 0) -> str: ...  # A test function…

## submodules:
  pyskills.createskill: ...  # How to create a pyskills pyskill module.

A trailing on an overview function line marks elided detail: the function has docments, or a docstring beyond its first line, so doc(func) will show more than the overview line did.

def _plain(x): return x
def _rich(x:int=0 # some param
    ): return str(x)
def _long(x):
    """Summary
    More detail."""
_m = types.ModuleType('_m')
_m.__all__ = ['plain','rich','long']
_m.plain,_m.rich,_m.long = _plain,_rich,_long
s = doc(_m)
fl = {l.split()[2].split('(')[0]:l for l in s.splitlines() if l.startswith('- def ')}
test_eq([fl[k].endswith('…') for k in ('plain','rich','long')], [False,True,True])
s
# module _m:


## functions:
- def plain(x)
- def rich(x: int = 0): …  # …
- def long(x): ...  # Summary…

Shared param groups

Toolkit modules often share one parameter vocabulary across many functions: rgapi’s walk filters appear identically in fd, ls, rg, and nbrg, and fastcore’s editing ops repeat each base op’s params in every carrier variant. Rendering those identical blocks in every overview line costs tokens without informing. A module can declare such groups in __pyskill_params__, a dict of group name to param names; the overview then collapses each function whose signature contains a whole group to a **group collector, and documents the group once in a ## shared params: section, with docments taken from the first matching function:

def _fd(root:str='.', hidden:bool=False # Include hidden files?
    ): ...
def _rg(pat, root:str='.', hidden:bool=False): ...
_w = types.ModuleType('_w')
_w.__all__ = ['fd','rg']
_w.__pyskill_params__ = {'walk_params': ('root','hidden')}
_w.fd,_w.rg = _fd,_rg
s = doc(_w)
fl = {l.split()[2].split('(')[0]:l for l in s.splitlines() if l.startswith('- def ')}
assert '**walk_params' in fl['fd'] and '**walk_params' in fl['rg']
assert 'root' not in fl['rg'] and 'pat' in fl['rg']
assert '## shared params:' in s and 'Include hidden files?' in s
s

A function only joins a group when every group param matches the first matching function’s types and defaults exactly. A differing default is information (ls is fd with listing defaults, and the difference is the point), so that function keeps its full inline signature, and the drift is warned about in case it is unintended:

def _ls(root:str='.', hidden:bool=True): ...
_w.ls = _ls
_w.__all__ = ['fd','rg','ls']
with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter('always')
    s2 = doc(_w)
fl2 = {l.split()[2].split('(')[0]:l for l in s2.splitlines() if l.startswith('- def ')}
assert '**walk_params' not in fl2['ls'] and 'hidden' in fl2['ls']
assert '**walk_params' in fl2['fd']
assert w and 'walk_params' in str(w[0].message)
s2
class Pet:
    def __init__(self, name, sound): self.name,self.sound = name,sound
    def speak(self):
        "Make the pet's sound"
        return f'{self.name} says {self.sound}!'
    def __dir__(self): return ['name', 'sound', 'speak']

p = Pet('Rex', 'woof')
test_eq(xdir(p, 'sp'), ['speak'])
doc(p)
Instance of type Pet:
- name: str = 'Rex'
- sound: str = 'woof'
- speak()  # Make the pet's sound

source

docfind

def docfind(
    o:str | object, q:str, n:int=2, _pre:str=''
):

Search doc() recursively through xdir(o), looking at submodules, classes, and functions, to depth n

docfind(pyskills.skill, 'test')
[' // # module pyskills.skill:',
 'SkillTestClass // class SkillTestClass(str):',
 'SkillTestClass.f // def f(',
 'SkillTestClass.g // def g()->str:"""A test prop"""',
 'async_skill_test_func // async def async_skill_test_func(',
 'skill_test_func // def skill_test_func(']
import fastcore.tools
doc(fastcore.tools)
# module pyskills.edit:

"""Functions for creating, viewing, and modifying files. Each editing operation returns unified diffs showing what changed. Where the `exhash` pyskill is available, prefer it for editing: its hash-verified addressing fails loudly on stale context instead of editing nearby text.

## File viewing, creating, and editing

File tools take a filesystem path as the first argument, e.g:

file_view('~/a/b.py', 3)
file_create('~/a/b/c.py', 'content here')
file_str_replace('myfile.py', 'old_name', 'new_name')
file_del_lines('myfile.py', 2, 4)
file_replace_lines('myfile.py', new_content=src)   # no line numbers: replace the entire contents

In `replace_lines` and its `file_`/`cell_` wrappers, `start_line=None` means line 1 and `end_line=None` means the last line, so the defaults replace the whole contents - the idiomatic full-file (or full-notebook-cell) rewrite. `del_lines` is destructive, so it takes no defaults: state the range explicitly (`1, -1` deletes all lines).

## Line filtering

`file_str_replace`, `file_strs_replace`, and `file_del_lines` support `re_filter` and `invert_filter` for targeting only lines matching (or not matching) a regex, like ex's `g//` and `g!//`. Combined with `start_line`/`end_line` to restrict to a region, e.g:

file_del_lines('myfile.py', 1, -1, re_filter=r'^\s*#')       # delete all comment lines

## Structural replacement

`ast_replace(text, repls)` and `ast_file(path, repls)` apply ast-grep `(pattern, replacement)` rules with `$VAR` metavariables, e.g. `ast_file(path, [("print($X)", "log($X)")])`. They match by syntax tree rather than text, and require the optional `remold` package.

Docs: https://AnswerDotAI.github.io/pyskills/edit.html.md
"""

## functions:
- def file_insert_line(path: str, insert_line: int, new_str: str): ...  # Insert new_str at specified line number…
- def file_str_replace(path: str, old_str: str, new_str: str, start_line: int = None, end_line: int = None, n_matches: int = None, re_filter: str = None, invert_filter: bool = False, use_regex: bool = False): ...  # Replace occurrence(s) of old_str with new_str…
- def file_strs_replace(path: str, old_strs: list[str], new_strs: list[str], start_line: int = None, end_line: int = None, n_matches: int = None, re_filter: str = None, invert_filter: bool = False, use_regex: bool = False): ...  # Replace multiple strings simultaneously…
- def file_replace_lines(path: str, start_line: int = None, end_line: int = None, new_content: str = ''): ...  # Replace line range with new content; the defaults replace the entire contents…
- def file_del_lines(path: str, start_line: int, end_line: int, re_filter: str = None, invert_filter: bool = False): ...  # Delete line range; deletion is destructive, so both line numbers must be given explicitly (`1, -1` for all lines)…
- def ast_file(path: str, repls: list): ...  # Apply ast-grep structural pattern replacements to python source…
- def file_view(path: str, start_line: int = 1, end_line: int = None): ...  # Read file contents, optionally limited to 1-based line range…
- def file_create(path: str, contents: str): ...  # Create a new file with contents. Error if file exists.…
- def file_edit(f, name=None)
- def insert_line(text: str, insert_line: int, new_str: str): ...  # Insert new_str at specified line number…
- def str_replace(text: str, old_str: str, new_str: str, start_line: int = None, end_line: int = None, n_matches: int = None, re_filter: str = None, invert_filter: bool = False, use_regex: bool = False): ...  # Replace occurrence(s) of old_str with new_str…
- def strs_replace(text: str, old_strs: list[str], new_strs: list[str], start_line: int = None, end_line: int = None, n_matches: int = None, re_filter: str = None, invert_filter: bool = False, use_regex: bool = False): ...  # Replace multiple strings simultaneously…
- def replace_lines(text: str, start_line: int = None, end_line: int = None, new_content: str = ''): ...  # Replace line range with new content; the defaults replace the entire contents…
- def del_lines(text: str, start_line: int, end_line: int, re_filter: str = None, invert_filter: bool = False): ...  # Delete line range; deletion is destructive, so both line numbers must be given explicitly (`1, -1` for all lines)…
- def ast_replace(text: str, repls: list): ...  # Apply ast-grep structural pattern replacements to python source…
doc(fastcore.tools.create_file)
def file_create(
    path:str, # Path to create (expands `~` if needed)
    contents:str, # Contents of file to create
):"""Create a new file with contents. Error if file exists."""

Passing several objects returns each one’s doc as its own blank-line-separated section – so a module overview and the functions you’re about to use can be read in one call:

d2 = doc(fastcore.tools, 'fastcore.tools.create_file')
test_eq(d2, doc(fastcore.tools) + '\n\n' + doc(fastcore.tools.create_file))

Skills registration

Pyskills can be added as standard modules with pyproject entrypoints. But for convenience, they can also be added to a custom pyskills XDG directory, which is automatically added to sys.path.


source

ensure_pyskills_dir

def ensure_pyskills_dir():

Create xdg pyskills dir and .pth file if needed


source

pyskills_dir

def pyskills_dir():

Directory for user pyskills

pyskills_dir returns the XDG data home path for user pyskills. ensure_pyskills_dir creates that directory if needed and writes a .pth file into site-packages so Python automatically adds it to sys.path. You can drop pyskill modules there without manual path configuration and which can be available across venvs.


source

clear_mod

def clear_mod(
    prefix
):

Clear modules starting with prefix from python caches

clear_mod purges all cached modules matching a prefix from sys.modules and invalidates import caches, ensuring a fresh import on next access. Used after enabling/disabling pyskills so changes take effect immediately.


source

register_pyskill

def register_pyskill(
    name, docstr, code:str=''
):

Register a pyskill module name in the xdg pyskills dir


source

enable_pyskill

def enable_pyskill(
    name
):

Enable pyskill name by creating its dist-info entry point

enable_pyskill creates a minimal dist-info directory with an entry point so the pyskill is listed. register_pyskill also writes an actual module file (with docstring and code) into the pyskills directory, and creates any needed __init__.py files for nested packages.

This lets you programmatically create and register a pyskill without a full package install.


source

disable_pyskill

def disable_pyskill(
    name
):

Disable pyskill name by removing its dist-info entry point

disable_pyskill removes the dist-info directory for a pyskill, so it no longer appears in entry point discovery. It also clears the module cache so the pyskill is fully unloaded.


source

delete_pyskill

def delete_pyskill(
    name
):

Delete pyskill name module files and dist-info