A community OpenCode plugin that adds robust support for Jupyter Notebook (.ipynb) files: inspect, read, edit, run, outputs, clean, export, reproducibility reports, and warm Python kernels.
OpenCode 2.x only. The plugin implements the V2 plugin API and does not load on OpenCode 1.x.
The plugin is opt-in, distributed via npm, and does not modify the OpenCode core. It exposes fourteen agent tools that operate on .ipynb files with explicit permissions, granular context control, and a strongly-typed Effect 4.0.6 runtime internally, matching OpenCode 2.
Repository: https://github.com/Restodecoca/opencode-ipynb Issues: https://github.com/Restodecoca/opencode-ipynb/issues
- Notebooks are large, binary-like artifacts. Dumping them into the agent context burns tokens.
- Reading outputs and editing cells are high-trust actions that are gated by the host permission system.
- The execution flow needs a Python runtime, which is not something every OpenCode user wants installed.
- A plugin lets the community iterate on these workflows without forcing changes on the OpenCode core.
OpenCode 2.x loads plugins from the plugins key. Until the V2 line (2.0.0) is published, point OpenCode at a checkout directory:
{
"$schema": "https://opencode.ai/config.json",
"plugins": ["/absolute/path/to/opencode-ipynb"]
}The npm latest tag still points at the V1 0.1.2 package, which does not load in OpenCode 2. After the V2 package is published, the versioned form is:
{
"$schema": "https://opencode.ai/config.json",
"plugins": ["opencode-ipynb@2.0.0"]
}opencode.example.jsonc is a machine-independent local example.
Or run directly from this repo:
git clone https://github.com/Restodecoca/opencode-ipynb
cd opencode-ipynb
bun install
bun run build # requires Bun and Node (Node clears dist/)Then add the absolute path to this checkout to the plugins array in your opencode.json (package-name resolution only works after the V2 package is published).
| Tool | Purpose |
|---|---|
ipynb_inspect |
Compact summary: kernel/language metadata, cell table, output stats, execution-order warnings, optional missing-package checks. |
ipynb_read |
Granular read of one cell or a range, with optional outputs/errors/metadata/images and truncation limits. |
ipynb_edit |
Safe per-cell source edit with a concise confirmation, output clearing policy, permission gate, and per-file lock. |
ipynb_cell_insert |
Insert a code/markdown/raw cell at a target index, or append when the index is omitted. |
ipynb_cell_delete |
Delete one cell by index and return a preview of the deleted source. |
ipynb_cell_move |
Move one cell to a new index. |
ipynb_run |
Execute one cell, a range, from a cell, or a full notebook via the Python helper. Supports save-to-notebook and optional warm kernels. |
ipynb_outputs |
List / read / read_error / clear_cell / clear_all outputs with pagination and permission gates for writes. |
ipynb_clean |
Strip outputs, execution counts, widget state, large images, and normalize source arrays for Git-friendly diffs. |
ipynb_export |
Export to Markdown, Python, or summary; optionally write to disk after permission. Files are written with full text outputs (images are represented by notices); the tool output is capped by maxExportChars. |
ipynb_doctor |
Diagnose Python, helper discovery, and required Jupyter dependencies. |
ipynb_repro |
Reproducibility report: kernel/env info, pip freeze, filesystem reads, random seeds, non-determinism, missing packages. |
ipynb_kernel |
Inspect/restart/shutdown warm kernels when the warmKernel plugin option is enabled. restart stops the current kernel; the next run starts a new one. |
ipynb_lsp |
Run on-demand Python diagnostics, or materialize the virtual .py for per-cell LSP navigation. |
Inspect a notebook before doing anything else:
ipynb_inspect({ filePath: "notebooks/eda.ipynb" })
Read a single cell:
ipynb_read({ filePath: "notebooks/eda.ipynb", cellIndex: 7 })
Read a range including outputs:
ipynb_read({
filePath: "notebooks/eda.ipynb",
start: 0,
end: 5,
includeOutputs: true,
maxOutputChars: 4000
})
Edit one cell, clear outputs automatically:
ipynb_edit({
filePath: "notebooks/eda.ipynb",
cellIndex: 7,
source: "import pandas as pd\ndf = pd.read_csv('data.csv')\ndf.head()"
})
Clean for a clean diff before commit:
ipynb_clean({ filePath: "notebooks/eda.ipynb" })
Export to a Python script next to the notebook:
ipynb_export({
filePath: "notebooks/eda.ipynb",
format: "python",
outputPath: "notebooks/eda.py"
})
ipynb_edit returns a concise confirmation (cell, file, type, and whether outputs were cleared) instead of echoing the old/new source or a diff. The model already has the source it sent, so the extra text only spends tokens.
After code-changing tools, the plugin runs pyright on the notebook's virtual Python document for Python notebooks only and appends errors in the same block shape used by opencode-v2-lsp-support:
LSP errors detected in this file, please fix:
<diagnostics file="/absolute/path/notebook.ipynb">
ERROR [cell 0, line 1:10] Type "Literal['no']" is not assignable to declared type "int"
</diagnostics>
The cell-mapped errors are also available as metadata.notebookDiagnostics; metadata.diagnostics is not emitted for custom tools because OpenCode 2 only feeds that field to its native edit renderer. ipynb_lsp runs the same check on demand and reports whether it was skipped (timeout, missing pyright, invalid output, not-analyzed, temp-unwritable, aborted, unsupported-language) instead of claiming a clean result.
- Every write (
ipynb_edit,ipynb_cell_insert,ipynb_cell_delete,ipynb_cell_move,ipynb_clean,ipynb_outputs clear_*,ipynb_export --outputPath, andipynb_lsp operation=materializefor its sidecar) callsctx.permission.assertbefore touching the disk, and every execution (ipynb_run) asks with rich metadata: file path, mode (cell/range/all/from), cell range, save flag, working directory, timeout. Whensave=true, a secondeditassert covers the notebook write. - Permission requests use V2 action names: writes use
edit, reads useread, and execution usesshell. Configure them with the orderedpermissionsarray, for example[{ "action": "edit", "resource": "*", "effect": "ask" }, { "action": "shell", "resource": "*", "effect": "ask" }, { "action": "lsp", "resource": "*", "effect": "ask" }]. Rules are evaluated in order and the last matching rule wins: put a specificdenyafter a broadallow, otherwise the allow overrides it. The plugin sends absolute, real, and session-relative resource paths so relative rules such assecrets/*also apply. For example:
"permissions": [
{ "action": "read", "resource": "*", "effect": "allow" },
{ "action": "read", "resource": "secrets/*", "effect": "deny" },
{ "action": "edit", "resource": "*", "effect": "allow" },
{ "action": "edit", "resource": "secrets/*", "effect": "deny" },
{ "action": "shell", "resource": "*", "effect": "ask" },
{ "action": "lsp", "resource": "*", "effect": "ask" }
]Note that a tool-level edit: deny also removes tools that declare options.permission: "edit" from the model catalog; mixed tools remain visible and fail when their write branch asserts edit.
- Tools that always mutate (
ipynb_edit,ipynb_cell_insert,ipynb_cell_delete,ipynb_cell_move,ipynb_clean) declareoptions.permission: "edit", andipynb_rundeclares"shell".ipynb_lspdeclares"lsp"; diagnostics asks forlsp/readon the notebook andediton its temporary analysis file; materialize asks forread/lspon the notebook andediton the sidecar path. Read-only tools (ipynb_inspect,ipynb_read,ipynb_repro) declare"read"and also assertreadon the notebook path. When they need Python, they assertshellbefore spawning it:ipynb_reproalways does, andipynb_inspectonly whencheckMissingPackages=true. This ordering means a deniedshellaction prevents the subprocess entirely.ipynb_doctoralso assertsshellfor the interpreter before probing it. Mixed read/write tools (ipynb_outputs,ipynb_export) do not declare a tool-level permission; their read branches assertreadand their writes assertedit.ipynb_doctordeclares no permission action.ipynb_kerneldeclares no tool-level action, butrestart/shutdownassertshellon the kernel's notebook path before stopping it. PathServicerejects paths that resolve outside of the OpenCode worktree, andNotebookFileServicepins the resolved real path inside the worktree before reading or writing. Symlink aliases share a lock and writes update the real target rather than replacing the link. The lock manager keys by real path and serializes per-file writes to prevent accidental corruption.- The Python execution helper is an opt-in, isolated subprocess; it does not auto-install Jupyter. The user installs
python/requirements.txtonly when they want real execution.
Notebooks can be enormous. By default the plugin never dumps a full notebook into context:
ipynb_inspectreturns a table with the first line of every cell, plus aggregate counts. It never includes the full source.ipynb_readdefaults to not including outputs. SetincludeOutputs: trueto opt in. Images/base64 are omitted by default; setincludeImages: trueto receive them as file attachments (the text block still only contains a size notice).maxSourceChars(default 12 000) andmaxOutputChars(default 6 000) cap per-cell source/output; tracebacks have their own 8 000-character cap.- Notebook reads are limited to 50 MB and
ipynb_readranges to 500 cells; useipynb_inspectfor a compact summary of very large notebooks. ipynb_run save=truereportssaved:falsewhen no code cell was actually written (for example a markdown-only target).- Error tracebacks are capped internally at 8 000 characters.
- Truncation appends
... (truncated, use maxXxx to increase); with extremely small budgets the hint itself may exceed the requested maximum, but original content is never included.
Most limits live in src/utils/limits.ts; the traceback cap is fixed in src/format/outputs.ts.
The plugin does not ship or require a language server, but it exposes a stable, provider-agnostic bridge so an OpenCode LSP or a third-party plugin can consume notebook edits.
The compatibility model is the file-based virtual document used by jupyter-lsp, jedi-language-server, Ruff, and Pyrefly: code cells are concatenated into one virtual source file, and positions are mapped back to (cellIndex, line, character). Clients that implement LSP 3.17 notebookDocument synchronization can build on the per-cell text/uri fields; they still need to supply notebook type, versions, and stable cell identity themselves.
opencode-ipynb/lspexportsbuildNotebookVirtualDocument()plustoCellPosition()/toVirtualPosition()/makePositionMapper(). The document also carrieslanguageId,fileExtension,kernelName, and the configuredpythonPathso a client can select the right environment (including a conda or uv environment) before starting pyright/pylsp/jedi.- The
opencode-ipynbRPC contract (opencode-ipynb/rpc) exposes:document({ filePath })→{ filePath, notebookUri, languageId, fileExtension, kernelName, pythonPath, text, sanitizedText, cells }changed({ filePath })→ emitted after tools that modify the notebook (ipynb_edit, cell insert/delete/move,ipynb_clean, output clearing, andipynb_runwhensaveis notfalse).
The RPC document is client-facing and is not routed through model tool permissions. A third-party server plugin can subscribe to changed, call document, and feed sanitizedText to pyright/jedi (text is the raw concatenation; sanitizedText blanks IPython magics and ?/!! help syntax). The per-cell text/uri fields are a starting point for an LSP 3.17 notebook client, which remains responsible for notebook/cell versions and stable cell identity.
The plugin also ships a small built-in consumer so diagnostics are useful without a third-party plugin:
- After code-changing tools (
ipynb_edit, cell insert/delete/move), it runs pyright on the virtual document for Python notebooks and appends mapped errors to the tool result in the same wrapper shape used byopencode-v2-lsp-support(LSP errors detected ... <diagnostics file=...>). The inner error lines add[cell N, line L:C];opencode-v2-lsp-supportdoes not parse blocks produced by other plugins. ipynb_lspruns the same check on demand.- Non-Python notebooks are left alone; missing pyright is skipped silently, while timeout/invalid-output is reported explicitly.
- Set
lspDiagnostics: falseto disable the automatic pass, orpyrightPathto point at a specific executable. Missing pyright is ignored; the notebook tools keep working.
OpenCode 2 has no native LSP runtime; the opencode-v2-lsp-support plugin provides the lsp tool and its servers. To use hover, go-to-definition, find-references, or document symbols on a notebook cell, materialize the notebook's virtual document:
ipynb_lsp({ filePath: "notebooks/eda.ipynb", operation: "materialize" })
It writes a small, visible .py sidecar next to the notebook (still inside the session directory, which is the root used by opencode-v2-lsp-support) and returns the path plus a cell-to-line map. Writing it beside the notebook is what lets pyright resolve sibling imports such as notebooks/helpers.py:
Virtual Python document: /project/notebooks/opencode-ipynb-lsp-4f2c1a9b7d3e5f60.py
Call the lsp tool from opencode-v2-lsp-support with this path. Positions are 1-based.
Cell map:
- cell 0 (code): lines 1-8
- cell 1 (code): lines 9-12
Then call the LSP plugin's lsp tool with that path and a 1-based line/character from the map:
lsp({ operation: "hover", path: "/project/notebooks/opencode-ipynb-lsp-4f2c1a9b7d3e5f60.py", line: 10, character: 12 })
lsp({ operation: "goToDefinition", path: "...", line: 10, character: 12 })
The result is a normal LSP location (for the virtual file, e.g. opencode-ipynb-lsp-4f2c1a9b7d3e5f60.py:10:5); use the cell map to report it as [cell 1, line 2:5]. Coordinate contract: tool cellIndex is 0-based; the external lsp tool's line/character are 1-based; metadata.notebookDiagnostics and RPC cells are 0-based LSP positions; the metadata.cells start/end lines from materialize are 1-based for the external tool (cell indices remain 0-based). The same works for findReferences, documentSymbol, and the other operations supported by that plugin. The cell map contains code cells only. Materialized files are refreshed automatically after plugin notebook edits (ipynb_*); edits made outside the plugin require re-running materialize. ipynb_edit changes that add or remove source lines, and ipynb_cell_insert, ipynb_cell_delete, or ipynb_cell_move, shift code-cell line numbers. Re-run materialize after any such edit if you need an updated cell map. Sidecars are removed when the plugin unloads, so run materialize and the lsp calls in the same session. Add opencode-ipynb-lsp-*.py to .gitignore (this repository already does).
import { NotebookRpc } from "opencode-ipynb/rpc"
export default {
id: "my-notebook-lsp",
async setup(ctx: any) {
const notebook = ctx.rpc(NotebookRpc)
notebook.events.on("changed", async (event) => {
const doc = await notebook.document({ filePath: event.data.filePath })
// Analyze doc.sanitizedText as a virtual .py file, then map diagnostics back with
// the exported position helpers; or use doc.cells with notebook sync.
})
}
}Conda itself does not provide a notebook language server. conda-lsp/YAML language servers target conda/YAML files, not Python cells. Use conda or uv to select the interpreter (pythonPath/kernelName) and a Python language server for the code. Do not add .ipynb to pyright's extensions: pyright must receive the virtual .py text, not the notebook JSON.
Known limitations of the virtual-document model: the whole notebook is re-analyzed when any cell changes; Python-bodied cell magics (%%time, %%capture) keep their body, while shell/non-Python cell magics are blanked; shell assignment forms (x = !ls) are not analyzed; top-level await and kernel-only state are not understood by static servers; diagnostics may differ from runtime behavior.
The plugin never installs Python dependencies automatically. Running ipynb_run is opt-in and requires a Python environment with nbformat, nbclient, jupyter_client, and ipykernel. The plugin only detects what is available and tells you how to fix what is missing.
uv pip install pip nbformat nbclient jupyter_client ipykernel
python -m ipykernel install --userpython -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install nbformat nbclient jupyter_client ipykernel
python -m ipykernel install --userconda install -c conda-forge nbformat nbclient jupyter_client ipykernelOpenCode may use a different Python interpreter than the one in your terminal. The environment variable takes precedence over plugin options; the PATH fallback is used only when neither sets an interpreter. The sections below are ordered from configuration to fallback:
-
Plugin options (in
opencode.json):{ "plugins": [ { "package": "/absolute/path/to/opencode-ipynb", "options": { "pythonPath": "/home/gabriel/.venv/bin/python", "helperRelativePath": "python/ipynb_runner.py", "preferUv": true, "defaultTimeoutMs": 120000, "defaultMaxOutputChars": 6000, "warmKernel": false, "allowOutsideWorktree": false } } ] }pythonPath(string): absolute path to a Python interpreter, or a bare name resolved throughPATH. Path-like relative values are rejected so the permission resource and the spawned executable cannot diverge.helperRelativePath(string, defaultpython/ipynb_runner.py): helper path relative to the plugin package, or an absolute path. It is deliberately never resolved from the project cwd, so a repository cannot shadow the shipped helper.preferUv(boolean, defaulttrue): the doctor and the Python service preferuv pip installoverpip installin their suggestions.defaultTimeoutMs(number, default120000): timeout in milliseconds foripynb_run.defaultMaxOutputChars(number, default6000): fallback foripynb_read,ipynb_outputs read, andipynb_runwhen the user does not passmaxOutputChars.warmKernel(boolean, defaultfalse): keep a long-lived Python subprocess per notebook path for faster repeatedipynb_run mode=all/mode=cellcalls. Manage it withipynb_kernel.lspDiagnostics(boolean, defaulttrue): afteripynb_edit,ipynb_cell_insert,ipynb_cell_delete, oripynb_cell_move, run pyright on the notebook's virtual Python document and append errors to the tool result. Setfalseto disable the extra pass.pyrightPath(string, defaultpyright): pyright executable for the diagnostics pass. Use an absolute path if pyright is not onPATH.diagnosticsTimeoutMs(number, default15000): timeout for the diagnostics pass.allowOutsideWorktree(boolean, defaultfalse): power-user escape hatch — whentrue, the plugin will read and write.ipynbfiles outside the OpenCode worktree. Leave atfalseunless you know why you need it.
-
Environment variables (the shell wins over plugin options):
OPENCODE_IPYNB_PYTHON=C:/Users/Gabriel/miniconda3/envs/data/python.exe opencode OPENCODE_IPYNB_OPTIONS='{"pythonPath":"/usr/bin/python3","defaultMaxOutputChars":4000}' opencodeOPENCODE_IPYNB_PYTHONoverrides the pluginpythonPathoption only.OPENCODE_IPYNB_OPTIONSoverrides the whole plugin options object (a JSON object validated by the same schema). Both are process-level overrides, so prefer thepluginsobject form when you only want to configure one project. -
PATH fallback: the plugin uses the first
pythonif present, otherwisepython3. It does not retry after a broken interpreter, so every spawned interpreter is covered by the shell permission request. Path-likepythonPathvalues must be absolute; bare names are resolved through PATH.
If anything goes wrong, run:
ipynb_doctor
It reports the selected interpreter, every Jupyter dependency ([ok] / [missing]), where the helper script was found, and a copy-pasteable install command. It never installs anything.
- Python execution depends on the user's local Python/Jupyter environment. The plugin diagnoses missing packages but never installs them.
- Warm kernels are intentionally limited to
ipynb_run mode=allandmode=cell;range,from, andenvuse one-shot helper processes. - Large notebooks and rich outputs are truncated in tool responses to protect context. When
ipynb_run save=truecompletes successfully, the executed code cells write their nbformat outputs to disk; a failed run returns its error without writing partial results. allowOutsideWorktreeis a power-user escape hatch. Keep it disabled unless you explicitly need cross-worktree notebook access. Enabling it also bypasses the host'sexternal_directorydeny rules for plugin tools; restrict the plugin tools themselves if that matters.ipynb_runworkingDirectorycontrols the one-shot helper process, not the notebook cell cwd; nbclient always runs cells in the notebook directory. Warm kernels reject any non-undefinedworkingDirectory.
Three small, runnable notebooks live under test/integration/ and double
as both documentation and a smoke test for the plugin's read / run /
export loop:
test/integration/classification/—LogisticRegressiononsklearn.datasets.load_iris(7 cells, no CSV needed).test/integration/timeseries/—numpymoving average over a noisy sine wave (5 cells, stdlib-friendly).test/integration/scraping/—urllib.request+html.parserfetch ofhttps://example.com/(4 cells, stdlib only, gracefultry/excepton network failure).
Each one ships with outputs: [] and a matching README.md that lists
the exact ipynb_inspect / ipynb_read / ipynb_run calls plus the
expected output.
The published npm tarball contains only the runtime: dist/, server.js, python/ipynb_runner.py, python/requirements.txt, README.md, CHANGELOG.md, and LICENSE. Internal developer docs, helper dev scripts, and .github/ workflows are excluded by the files whitelist in package.json (with .npmignore as a second guard). AGENTS.md and TODO.md are local and gitignored; docs/ is tracked for contributors and is not shipped to npm. Contributors track their work through GitHub Issues and Discussions.
Cut a release by tagging the commit and pushing the tag, then publish it on GitHub — the workflow runs only on the published release event:
git tag vX.Y.Z && git push --tagsThen create a GitHub release at https://github.com/Restodecoca/opencode-ipynb/releases/new pointing at vX.Y.Z. The CI runs bun run typecheck, bun run typecheck:tests, bun test, and bun run build before npm publish --provenance --access public. The repository must have a single secret configured: NPM_TOKEN (an npm automation token with publish rights for the package name in package.json).
Before publishing, run a local package check:
bun run typecheck
bun run typecheck:tests
bun test
bun run test:coverage
bun run build
npm pack --dry-runThe 2.0.0 release is the V2-only line. The npm latest tag currently points at the V1 0.1.2 package; publish 2.0.0 before advertising the package name as a V2 install. If you publish under a scope instead, update package.json, the install snippet, and this release tag convention together.
This project uses the Effect Language Service to surface Effect-specific diagnostics at build time and in the editor.
The tsconfig.json already includes the plugin:
{
"compilerOptions": {
"plugins": [{ "name": "@effect/language-service" }]
}
}effect-language-service patch modifies the local node_modules/typescript so that tsc --noEmit reports Effect-specific diagnostics. This is opt-in because it mutates your local install.
bun run prepare:effect-lsp # alias for: effect-language-service patch
bun run typecheck # now also catches "Effect must be yielded" and similarThe project does NOT add this as a prepare script. Patch only when you want it.
For VS Code, Cursor, Zed or NVim, make sure the editor uses the workspace TypeScript (not the bundled one). In VS Code / Cursor, open a .ts file, click the TypeScript version in the status bar, and select Use Workspace Version.
The opencode-v2-lsp-support TypeScript LSP also picks up the workspace TypeScript and therefore respects the @effect/language-service plugin. A minimal opencode.json in the project root enables it:
{
"$schema": "https://opencode.ai/config.json",
"lsp": {
"typescript": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx", ".mts", ".cts"]
}
}
}bun install
bun run typecheck
bun run typecheck:tests
bun test
bun run buildLayout:
server.js # V2 local-directory entrypoint (re-exports dist/index.js)
src/
index.ts # re-exports the plugin
plugin.ts # V2 lifecycle: setup(), RPC, hook install, cleanup
plugin-v2.ts # V2 adapter: JSON Schema inputs, permission.assert, result mapping
plugin-options.ts # per-instance options + env precedence
registrations.ts # tool definitions + permission names
notebook-hooks.ts # pure changed/no-op rules and result helpers
notebook-diagnostics.ts # pyright virtual-document diagnostics + LSP metadata
virtual-materialize.ts # safe notebook-adjacent sidecar writer/cleanup
lsp.ts # virtual notebook document + position mapping for LSPs
rpc.ts # public RPC contract (document method, changed event)
tool.ts # internal zod tool contract shared by the 14 tool implementations
domain/ # zod schemas + types for cells, outputs, execution
services/ # Effect-style services (Path, Permission, File, ...)
format/ # markdown / output / diagnostic / export formatters
tools/ # one file per tool, thin wrapper over services
utils/ # limits, paths, mime, truncate, json helpers
python/
ipynb_runner.py # JSON-stdin/stdout runner using nbformat + nbclient
requirements.txt # runtime execution deps
requirements-test.txt # CI/test deps (numpy, matplotlib, scikit-learn)
test/
fixtures/ # simple, outputs, error, images
unit/ # bun:test suites
docs/
architecture.md # module map, lifecycle, LSP and file-safety invariants
contributing.md # setup, commands, adding tools, review/release checklist
The plugin targets @opencode/plugin 2.2.x (tested with 2.2.2). It is V2-only; there is no V1 server() entrypoint.
Architecture and contribution notes:
- Architecture
- Contributing
opencode.example.jsoncis the versioned, machine-independent local config example. The realopencode.jsoncis intentionally not versioned because it contains machine-specific paths.
Use a native Linux Bun inside WSL, not the Windows npm shim. Running bun install through Windows Bun against a WSL path can corrupt node_modules or fail to replace bun.lock.
curl -fsSL https://bun.sh/install | bash
export PATH="$HOME/.bun/bin:$PATH"
uv venv .venv
uv pip install --python .venv/bin/python pip -r python/requirements-test.txt
.venv/bin/python -m ipykernel install --user --name python3
PATH="$PWD/.venv/bin:$PATH" bun testMIT. See LICENSE.