Compare commits

..

No commits in common. "next" and "master" have entirely different histories.
next ... master

138 changed files with 1006 additions and 15656 deletions

View File

@ -1,9 +0,0 @@
# Host-side watchdog for the normal `just test` CraftOS-PC process.
TRAP_CCLIBS_TEST_TIMEOUT_SECONDS=3
# Dedicated `just test-timeout` fixture timings.
TRAP_CCLIBS_TEST_TIMEOUT_WATCHDOG_SECONDS=1
# Test placeholder. The real value is generated in `.env` on first `just install`,
# or after `just clean` / `just reinstall`.
OPENCODE_SERVER_PASSWORD=redacted

8
.gitignore vendored
View File

@ -1,5 +1,3 @@
.craftos
.craftos-vanilla
.env
node_modules/
dist/
.cuberc
.cubestartup
.cubeboot

View File

@ -1,30 +0,0 @@
# ATM10 Expert Context Index
Local context for the `atm10-expert` opencode agent.
## Target Pack
- ATM10 / All The Mods 10 version `7.0` (`0.7.0` is accepted as a user alias).
- Minecraft `1.21.1`.
- NeoForge `21.1.228`.
- CurseForge project id `925200`.
- Client pack file id `8091114`.
- Server pack file id `8094893`.
- Source: [AllTheMods/ATM-10](https://github.com/AllTheMods/ATM-10).
## Local Glossaries
- [CC:Tweaked glossary](glossaries/cc_glossary.md) - globals, modules, peripherals, events, and guides.
- [Advanced Peripherals glossary](glossaries/advanced_peripherals_glossary.md) - Advanced Peripherals 0.7 guides, peripherals, turtles, integrations, and changelog pages.
- [Create CC:Tweaked glossary](glossaries/create_cc_tweaked_glossary.md) - Create CC:Tweaked integration pages.
## Modpack Notes
- [ATM10 7.0 modpack](modpacks/atm10-7.0.md) - pack metadata and generated CurseForge mod ids.
## Update Workflow
- Use local glossaries first when answering in-game questions.
- Use web lookup when local context is missing, stale, or too shallow.
- When a useful durable documentation source is found, update or create a glossary and update this index.
- Refresh the mod id list with `./.opencode/agent-context/atm10-expert/scripts/fetch-atm10-7.0-mods.sh`.

View File

@ -1,20 +0,0 @@
# ATM10 7.0 Modpack
Known facts for ATM10 / All The Mods 10 version `7.0`.
## Pack Metadata
- Minecraft: `1.21.1`.
- NeoForge: `21.1.228`.
- CurseForge project id: `925200`.
- Client pack file id: `8091114`.
- Server pack file id: `8094893`.
- Source: [AllTheMods/ATM-10](https://github.com/AllTheMods/ATM-10).
## Mod List
The mod list is generated by [`../scripts/fetch-atm10-7.0-mods.sh`](../scripts/fetch-atm10-7.0-mods.sh).
Run it with `CURSEFORGE_API_KEY` to resolve project names through the CurseForge API. Without an API key, the generated table preserves raw project/file ids and API URLs for later enrichment.
Generated list: not fetched yet.

View File

@ -1,93 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ID=925200
FILE_ID=8091114
SERVER_FILE_ID=8094893
NEOFORGE_VERSION=21.1.228
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
CONTEXT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
OUTPUT="${CONTEXT_DIR}/modpacks/atm10-7.0.md"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
printf 'missing required command: %s\n' "$1" >&2
exit 1
fi
}
require_cmd curl
require_cmd jq
require_cmd unzip
ARCHIVE="${TMP_DIR}/atm10-${FILE_ID}.zip"
MANIFEST="${TMP_DIR}/manifest.json"
PROJECTS="${TMP_DIR}/projects.json"
download_url=''
if [ -n "${CURSEFORGE_API_KEY:-}" ]; then
download_url="$(curl -fsS \
-H "x-api-key: ${CURSEFORGE_API_KEY}" \
"https://api.curseforge.com/v1/mods/${PROJECT_ID}/files/${FILE_ID}/download-url" \
| jq -r '.data // empty')"
fi
if [ -n "${download_url}" ]; then
curl -fL --retry 3 -o "${ARCHIVE}" "${download_url}"
else
curl -fL --retry 3 -o "${ARCHIVE}" \
"https://www.curseforge.com/api/v1/mods/${PROJECT_ID}/files/${FILE_ID}/download"
fi
unzip -p "${ARCHIVE}" manifest.json > "${MANIFEST}"
if [ -n "${CURSEFORGE_API_KEY:-}" ]; then
mod_ids="$(jq -c '[.files[].projectID] | unique' "${MANIFEST}")"
curl -fsS \
-H "x-api-key: ${CURSEFORGE_API_KEY}" \
-H 'content-type: application/json' \
-d "{\"modIds\":${mod_ids}}" \
'https://api.curseforge.com/v1/mods' > "${PROJECTS}"
else
printf '{"data":[]}\n' > "${PROJECTS}"
fi
{
printf '# ATM10 7.0 Modpack\n\n'
printf 'Generated from CurseForge client file `%s`.\n\n' "${FILE_ID}"
printf 'Last generated: `%s`.\n\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf '## Pack Metadata\n\n'
printf -- '- Minecraft: `%s`.\n' "$(jq -r '.minecraft.version // "1.21.1"' "${MANIFEST}")"
printf -- '- NeoForge: `%s`.\n' "${NEOFORGE_VERSION}"
printf -- '- CurseForge project id: `%s`.\n' "${PROJECT_ID}"
printf -- '- Client pack file id: `%s`.\n' "${FILE_ID}"
printf -- '- Server pack file id: `%s`.\n' "${SERVER_FILE_ID}"
printf -- '- Source: [AllTheMods/ATM-10](https://github.com/AllTheMods/ATM-10).\n\n'
printf '## Manifest\n\n'
jq -r '"- Name: `" + (.name // "unknown") + "`.", "- Version: `" + (.version // "unknown") + "`."' "${MANIFEST}"
printf '\n## Mods\n\n'
printf '| Project id | File id | Name | Reference |\n'
printf '| --- | --- | --- | --- |\n'
jq -r --slurpfile projects "${PROJECTS}" '
def project($id): (($projects[0].data // [])[]? | select(.id == $id)) // {};
def cell: tostring | gsub("\\|"; "\\\\|");
.files[]
| project(.projectID) as $project
| [
(.projectID | tostring),
(.fileID | tostring),
(($project.name // "unresolved") | cell),
(($project.links.websiteUrl // ("https://api.curseforge.com/v1/mods/" + (.projectID | tostring))) | cell)
]
| "| " + join(" | ") + " |"
' "${MANIFEST}"
} > "${OUTPUT}"
printf 'wrote %s\n' "${OUTPUT}"

View File

@ -1,61 +0,0 @@
---
description: Answers in-game ATM10, ComputerCraft, CC:Tweaked, Advanced Peripherals, Create, and TrapOS user questions with concise player-facing replies; use for programs/ai.lua requests when opencc.agent is atm10-expert.
mode: primary
permission:
"*": deny
read: allow
glob: allow
grep: allow
webfetch: allow
websearch: allow
edit: ask
bash:
"*": ask
"just check": allow
computercraft-mcp-bridge_probe-computers: allow
computercraft-mcp-bridge_exec-lua: allow
---
You answer players using TrapOS `ai` from inside ATM10 / All The Mods 10.
Target environment:
- ATM10 / All The Mods 10 pack version `7.0`; accept `0.7.0` as the same user alias.
- Minecraft `1.21.1`.
- NeoForge `21.1.228`.
- ComputerCraft is CC:Tweaked in the ComputerCraft sandbox, not desktop Lua.
Response style:
- Keep replies short for in-game terminals. Prefer 1-4 concise lines.
- Default to practical, non-technical answers unless the user asks for details.
- Answer in the user's language; usually French when the prompt is French.
- When giving commands, make them directly runnable in CraftOS when possible.
- When giving code, keep it minimal and runnable. Avoid markdown fences unless the user clearly asks for a code block.
- If the prompt asks for raw Lua code only, output raw Lua only with no markdown or explanation.
Context workflow:
- Use `.opencode/agent-context/atm10-expert/INDEX.md` first.
- Prefer local glossaries and modpack notes before web lookup.
- Use web search or fetch only for current external facts, missing mod documentation, or stale local references.
- If you find a useful durable mod documentation source, ask before editing, then update or create a glossary and update `INDEX.md`.
MCP bridge safety:
- You may use the ComputerCraft MCP bridge only through `probe-computers` and `exec-lua`.
- Use `probe-computers` before `exec-lua` unless the target computer id is already clear from the hidden caller context or conversation.
- Treat `exec-lua` as privileged in-game execution. Prefer read-only inspection.
- Do not delete files, reboot or shut down computers, move turtles, change inventories, transmit network traffic, mutate peripherals, or run long loops unless the user explicitly asks for that specific effect.
- Keep `exec-lua` snippets small and bounded. Use short timeouts. Avoid blocking pulls, sleeps, infinite loops, and assumptions that a timeout stops code already running in ComputerCraft.
- `print()` and `write()` output is captured in MCP results. To intentionally write to the visible ComputerCraft screen, use terminal APIs such as `term.clear()`, `term.setCursorPos()`, and `term.write()`.
Caller context:
- TrapOS may prepend hidden caller context with the ComputerCraft computer id and label.
- Use that context to choose the right MCP target, but do not expose it unless it helps the user.
Repository scope:
- If the user asks for repo internals, answer only what is needed to unblock them.
- This agent is explicit and player-facing. Do not assume it is the default coding assistant.

View File

@ -1,10 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"computercraft-mcp-bridge": {
"type": "remote",
"url": "http://127.0.0.1:3000",
"enabled": true
}
}
}

View File

@ -1,45 +0,0 @@
import type { Plugin } from "@opencode-ai/plugin"
const SOURCE_EXTENSIONS = [".lua", ".json"]
function isSourcePath(value: unknown) {
return typeof value === "string" && SOURCE_EXTENSIONS.some((extension) => value.endsWith(extension))
}
function patchTouchesSource(value: unknown) {
if (typeof value !== "string") return false
for (const line of value.split("\n")) {
if (!line.startsWith("*** Update File: ") && !line.startsWith("*** Add File: ")) continue
if (isSourcePath(line.slice(line.indexOf(": ") + 2).trim())) return true
}
return false
}
function toolTouchesSource(args: Record<string, unknown>) {
return (
isSourcePath(args.filePath) ||
isSourcePath(args.path) ||
isSourcePath(args.target) ||
patchTouchesSource(args.patchText) ||
patchTouchesSource(args.patch)
)
}
export const FixOnPackageSourceEdit: Plugin = async ({ $ }) => {
let running = false
return {
"tool.execute.after": async (_input, output) => {
if (running) return
const args = output.args
if (!args || typeof args !== "object" || !toolTouchesSource(args as Record<string, unknown>)) return
running = true
try {
await $`just fix`
} finally {
running = false
}
},
}
}

View File

@ -1,55 +0,0 @@
# TRoR Hello World POC Plan
## Goal
Build a small proof-of-concept that drives an in-game ComputerCraft computer (on an ATM10 server) from a local CraftOS-PC instance using TRoR (Terminal Redirection over Rednet).
The POC is intentionally minimal: see "Hello World" printed by an in-game computer rendered locally, and have a local keystroke produce a CC event in-game.
## Background
- `craftos --tror` enables the TRoR renderer but only reads/writes packets over **stdio** — it does not speak real rednet. See `docs/craftos_pc_glossary.md`.
- TRoR is a ComputerCraft standard (oeed/CraftOS-Standards #10): packet format `<Code>:<Meta>;<Payload>` (e.g. `TW` write, `TC` cursor, `EV` event).
- [`lyqyd/cc-netshell`](https://github.com/lyqyd/cc-netshell) already implements a TRoR server/client over rednet inside CC. We piggyback on it instead of reimplementing the protocol.
## Architecture
Three actors:
```text
[ local craftos --tror ] <-- stdio --> [ ws bridge ] <-- ws --> [ in-game relay CC ] <-- rednet --> [ in-game target CC ]
```
1. **Target CC** (in-game): runs `cc-netshell` server, prints "Hello World", reads `key`/`char` events.
2. **Relay CC** (in-game): has wireless modem + uses `http.websocket` to bridge TRoR packets between rednet and an external WS endpoint.
3. **WS bridge** (host): tiny Node/Python WS server that also pipes stdio to/from `craftos --tror`. Could be a single script that spawns `craftos` as a child process.
Why a relay: CC has no raw TCP socket; the only off-world transport is `http`/`websocket`. The ATM10 server must allow outbound HTTP/WS to our host (default CC:Tweaked config does).
## Milestones
1. **In-game baseline**: install `cc-netshell` on two test computers (creative world / single-player first), confirm server/client TRoR shell works over rednet alone.
2. **WS bridge skeleton**: minimal local WS server that logs frames. Verify a CC computer can connect via `http.websocket` and exchange text frames.
3. **Relay program**: in-game program that joins the WS server and forwards every WS frame as a rednet message to a configured target ID, and vice versa. Treat frames as opaque TRoR packets.
4. **Local TRoR client**: spawn `craftos --tror`, pipe its stdout to the WS bridge, pipe WS frames to its stdin. A "Hello World" written by the target CC should render in CraftOS-PC.
5. **Input loopback**: confirm keystrokes typed in local CraftOS-PC reach the target as `key`/`char` events (TRoR `EV` packets).
6. **ATM10 deployment**: copy the relay + target programs to the real server, point relay at the public host running the WS bridge.
## Open Questions
- Does `cc-netshell`'s wire format match the on-the-wire TRoR packet format byte-for-byte, or does it wrap packets in a rednet envelope? If wrapped, the relay must unwrap before forwarding to the WS bridge (and rewrap on the way back).
- ATM10 default CC:Tweaked config: is outbound WS to arbitrary hosts allowed? May need a whitelist entry in `computercraft-server.toml`.
- Auth: any pairing/handshake between local client and target CC, or do we rely on "obscure WS URL" for the POC?
- One target only, or do we want the relay to multiplex by target ID from day one?
## Out Of Scope
- Multi-user, auth, TLS hardening.
- Reconnect logic beyond "restart both ends".
- Packaging into this repo's `programs/` / `servers/` layout — that comes after the POC proves the loop works.
## Deliverables
- `programs/tror-relay.lua` (in-game relay, prototype quality).
- `tools/tror-bridge/` (host-side WS + craftos spawner script, language TBD).
- Notes appended to `docs/craftos_pc_glossary.md` once the stdio contract is verified.

144
.plans/goo-plan.md Normal file
View File

@ -0,0 +1,144 @@
# Goo Program Plan
## Goal
Add a turtle program named `goo` for automating Just Dire Things goo block processing.
The turtle is placed directly below the goo block. It should run forever, adapt to its inventory, feed the goo when it is not alive, place supported process blocks around the goo, and mine the resulting crystal blocks.
## Command
- `goo start` starts the automation loop.
- `goo help` / `goo --help` prints usage.
- `goo version` / `goo --version` prints the version.
## Starting Position
The turtle must start directly below the goo block:
```text
[goo]
[turtle]
[ground]
```
The turtle cannot move down because of the ground. The block below the goo is the turtle's starting cell, so the bottom side must be handled last and from an adjacent position.
## Goo Detection
Use `turtle.inspectUp()` from the starting position.
Supported goo blocks:
- `justdirethings:gooblock_tier1`
- `justdirethings:gooblock_tier2`
- `justdirethings:gooblock_tier3`
- `justdirethings:gooblock_tier4`
Parse the tier from the block name. Re-inspect regularly so the program adapts if the goo tier changes.
## Alive Handling
The goo is ready when:
```lua
inspected.state and inspected.state.alive == true
```
If `alive` is false or nil, the turtle should use the appropriate feeding item directly on the goo with `turtle.placeUp()`.
Feeding items are ordered arrays so preference is data-driven:
```lua
local FEEDING_ITEMS_BY_TIER = {
[1] = { 'minecraft:sugar', 'minecraft:rotten_flesh' },
[2] = { 'minecraft:nether_wart' },
[3] = { 'minecraft:chorus_fruit' },
[4] = { 'minecraft:sculk' },
};
```
Tier 1 naturally prefers sugar because it appears first.
If no valid feeding item is present, log a useful message and wait forever for the player to add one.
## Process Blocks
Supported process blocks and required goo tiers:
```lua
local PROCESS_ITEMS = {
['minecraft:iron_block'] = { tier = 1, label = 'iron' },
['minecraft:coal_block'] = { tier = 1, label = 'coal' },
['mekanism:block_charcoal'] = { tier = 1, label = 'charcoal' },
['minecraft:gold_block'] = { tier = 2, label = 'gold' },
['minecraft:diamond_block'] = { tier = 3, label = 'diamond' },
['minecraft:netherite_block'] = { tier = 4, label = 'netherite' },
};
```
Rules:
- A goo tier can process items whose required tier is less than or equal to the goo tier.
- Lower-tier goo skips higher-tier items and logs the required tier.
- Tier 4 goo can process every supported item.
- Materials may be mixed; a full six-block batch of one material is not required.
- The program should continue forever, even if no eligible process blocks are currently present.
## Six-Side Handling
The program should work all six sides of the goo:
- top
- north
- south
- east
- west
- bottom
Horizontal sides:
- Move from center to adjacent floor position.
- Use `inspectUp`, `digUp`, and `placeUp` to work the side target.
- Return to center.
Top side:
- Move to an adjacent floor position.
- Climb up twice when the side path allows it.
- Face the top target.
- Use `inspect`, `dig`, and `place`.
- Return to center.
Bottom side:
- Ensure the goo is alive before leaving the center.
- Move to an adjacent floor position and face the original center cell.
- Work the original center cell with `inspect`, `dig`, and `place`.
- If a process block was placed in the bottom position, wait outside until it is consumed or transformed.
- Mine the resulting crystal block, then return under the goo when the center is clear.
## Crystal Mining Rule
After placement, the goo transforms process blocks into crystal blocks that should be mined with a pickaxe-equipped turtle.
No tag or exact crystal ID check is required. For any goo target position:
- Empty means available for placement.
- Known process material means already placed and still processing; do not dig.
- Goo block means protected; do not dig.
- Any other block means processed result/crystal; use the appropriate `turtle.dig*` call.
## First-Iteration Assumptions
- The area around the turtle has clear walking space.
- The program does not dig movement-path obstacles.
- The turtle has a suitable pickaxe equipped for crystal mining.
- Runtime validation must happen in CC:Tweaked/CraftOS-PC or in-game; local Lua execution is not available for this repo.
## Integration Notes
- Ship `programs/goo.lua` via `install.lua` `LIST_FILES`.
- Document `goo` in `README.md`.
- Add `turtle` to `.luacheckrc` globals.
- Run `just check` after Lua edits.

View File

@ -1,157 +0,0 @@
# Plan: Full AI CLI Integration Through Real Opencode
## Goal
Run the real ComputerCraft `ai` CLI against a real `opencode serve` process through the WebSocket bridge proxy, while using the fake provider/model fixture proven by `opencode-fake-provider-direct-plan.md`.
This test should cover the actual runtime chain used in-game.
## Dependency
Do not implement this plan until `.plans/opencode-fake-provider-direct-plan.md` has produced a working fake provider fixture.
Update this plan first with the concrete results from plan 1:
- Working fake provider plugin code shape.
- Working opencode startup command/env.
- Working readiness endpoint.
- Any internal prompt behavior discovered.
## Desired Boundary
Real:
- CraftOS-PC harness
- `/programs/ai.lua`
- `/apis/libai.lua`
- `/apis/libhttpws.lua`
- `tools/mcp-bridge` opencode proxy
- `opencode serve`
- opencode sessions/messages/agents/model selection
Fake:
- The provider/model response behavior only, through the reusable fake provider fixture from plan 1
## Runtime Chain
```text
CraftOS /programs/ai.lua
-> libai.lua
-> libhttpws.lua
-> mcp-bridge opencode proxy
-> real opencode serve
-> fake provider/model
```
## Test Fixture
Reuse the fake provider workspace generator from plan 1.
Response mappings needed for the CLI cases:
```json
[
{ "match": "reply with exactly: pong", "reply": "pong" },
{ "match": "fresh start", "reply": "new reply" },
{ "match": "continue please", "reply": "plain reply" }
]
```
Keep the mapping fixture easy to extend so future CLI cases can add entries without changing provider code.
## CraftOS Wrapper
Create or update a Lua wrapper under:
- `tools/mcp-bridge/test-integration/lua/ai-cli-check.lua`
The wrapper should:
1. Accept the WebSocket proxy URL as its first argument.
2. Clear stale settings:
- `opencc.server_url`
- `opencc.session_id`
3. Set:
- `opencc.bridge_url`
- `opencc.request_timeout_seconds`
4. Run:
- `ai sessions`
- `ai ping`
- `ai new fresh start`
- `ai continue please`
5. Print markers around each command.
6. Print persisted session markers after commands.
7. Call `os.shutdown()`.
Expected marker examples:
```lua
print('--- sessions ---');
shell.run('/programs/ai.lua', 'sessions');
print('--- ping ---');
shell.run('/programs/ai.lua', 'ping');
print('SESSION_AFTER_PING=' .. tostring(settings.get('opencc.session_id')));
print('--- new ---');
shell.run('/programs/ai.lua', 'new', 'fresh', 'start');
print('SESSION_AFTER_NEW=' .. tostring(settings.get('opencc.session_id')));
print('--- ask ---');
shell.run('/programs/ai.lua', 'continue', 'please');
print('SESSION_AFTER_ASK=' .. tostring(settings.get('opencc.session_id')));
```
## Node Test Implementation
Add or replace the current CLI integration test under:
- `tools/mcp-bridge/test-integration/ai-cli.test.ts`
Test steps:
1. Create temp fake-provider opencode workspace using the plan 1 fixture.
2. Start `opencode serve` on a random local port.
3. Poll until opencode is ready.
4. Start `startOpencodeProxy({ opencodeUrl })`.
5. Start CraftOS with:
- `mountRepo: true`
- `shellArgs: [proxyUrl]`
- a generous timeout, likely `30_000` or higher depending on measured opencode startup time
6. Assert CLI output includes:
- `pong`
- `new reply`
- `plain reply`
- session markers proving `ai new` replaces the session and plain `ai ...` reuses it
7. Stop CraftOS, proxy, and opencode in `finally`.
## Useful Assertions
- `ai sessions` exits without an opencode transport/config error.
- `ai ping` prints `pong`.
- `ai new fresh start` prints `new reply`.
- Plain `ai continue please` prints `plain reply`.
- `SESSION_AFTER_NEW` is non-empty.
- `SESSION_AFTER_ASK` equals `SESSION_AFTER_NEW`.
- If `SESSION_AFTER_PING` is printed, decide whether ping should persist a session or whether `ai ping` should become non-persistent in a separate behavior change.
## Current Open Questions
- Should `ai ping` persist `opencc.session_id`? Current `programs/ai.lua` calls `ai.ping(askOptions())`, and `libai.ping` behavior must be checked before asserting this too tightly.
- Should `ai sessions` be expected to show no sessions, one session, or just avoid failing before messages are created? Real opencode behavior may differ from the old fake HTTP server.
- Does opencode generate a title/summary for each message during the synchronous `/message` call? If yes, the fake provider fallback must make that harmless.
- What is the most stable way to choose a free opencode port in CI?
## Verification
After implementation, run:
```sh
npx tsx --test test-integration/opencode-fake-provider.test.ts
npx tsx --test test-integration/ai-cli.test.ts
npm run check
just check
```
If the full test is too slow for the default integration suite, keep it as a separately named test command or document why it is excluded from `npm run test:integration`.

View File

@ -1,121 +0,0 @@
# Plan: Direct Fake Provider Integration
## Goal
Prove that a real `opencode serve` process can run with a deterministic fake provider/model and answer HTTP API requests without calling an external LLM.
This plan deliberately stops before CraftOS, the WebSocket bridge, or `/programs/ai.lua`. It validates only the opencode-side fixture that the full integration test will reuse.
## Desired Boundary
Real:
- `opencode serve`
- opencode config validation and loading
- opencode session/message HTTP endpoints
- opencode model/provider selection
- opencode agent/title/summary plumbing as far as it is triggered by simple messages
Fake:
- The provider/model response behavior only
## Proposed Test Fixture
Create a test-only temporary opencode workspace during the test. Do not modify the project `.opencode/opencode.json` for this.
Files generated under a temp directory:
- `opencode.json`
- `fake-provider.ts` or `fake-provider.js`
- `fake-responses.json`
Example response mapping:
```json
[
{ "match": "reply with exactly: pong", "reply": "pong" },
{ "match": "fresh start", "reply": "new reply" },
{ "match": "continue please", "reply": "plain reply" }
]
```
The fake provider should return the first response whose `match` appears in the final model prompt. Unknown prompts should return a deterministic fallback such as `ok` or `unhandled fake prompt`, not fail immediately, because opencode may issue title/summary/internal prompts.
## Config Shape To Validate
Use the published schema as the source of truth before finalizing fields.
Candidate config:
```json
{
"$schema": "https://opencode.ai/config.json",
"model": "traptest/fake",
"small_model": "traptest/fake",
"enabled_providers": ["traptest"],
"plugin": ["./fake-provider.ts"],
"provider": {
"traptest": {
"name": "Trap Test",
"models": {
"fake": {
"id": "fake",
"name": "Trap Test Fake Model",
"limit": { "context": 100000, "output": 10000 },
"cost": { "input": 0, "output": 0 },
"status": "active"
}
}
}
},
"agent": {
"build": { "model": "traptest/fake" },
"title": { "model": "traptest/fake" },
"summary": { "model": "traptest/fake" }
}
}
```
Open question: the exact plugin provider hook shape must be verified against opencode's runtime/plugin API. Do not guess this implementation from the config schema alone.
## Test Implementation
Add a Node integration test, likely under:
- `tools/mcp-bridge/test-integration/opencode-fake-provider.test.ts`
Test steps:
1. Create a temp directory.
2. Write the test `opencode.json`.
3. Write `fake-responses.json`.
4. Write the fake provider plugin.
5. Start `opencode serve` on `127.0.0.1` with a random free port.
6. Wait for readiness by polling an HTTP endpoint such as `GET /session`.
7. Call opencode HTTP directly:
- `POST /session`
- `POST /session/:id/message` with `reply with exactly: pong`
- `POST /session/:id/message` with `fresh start`
8. Assert the responses contain `pong` and `new reply`.
9. Stop the opencode process and clean up the temp directory.
## Useful Assertions
- `opencode serve` starts successfully with the generated config.
- `POST /session` returns a usable session ID.
- `POST /session/:id/message` returns text from the fake mapping.
- Unknown/internal prompts do not break the test fixture.
- No external provider credentials are required.
## Result To Capture For Plan 2
After this plan is run, record:
- Exact working fake provider plugin API shape.
- Exact command/env used to start `opencode serve` reliably.
- Confirmed readiness endpoint and polling logic.
- Whether title/summary/internal model calls happen during simple message requests.
- Any required config fields not listed above.
Plan 2 should be updated with these facts before implementation.

78
.plans/repo-fix-plan.md Normal file
View File

@ -0,0 +1,78 @@
# Repo Fix Plan
## Findings
### Medium: Fresh install does not create `/servers`
Reference: `install.lua:32-38`
`LIST_FILES` downloads files under `servers/`, but `install.lua` only creates `/programs`, `/apis`, and `/startup`. On a fresh ComputerCraft machine, `wget ... servers/ping-server.lua` can fail because the parent directory does not exist.
Fix:
- Add `fs.makeDir('/servers');` before downloading files.
- Bump `install.lua` `_VERSION`.
### Medium: `cube set-boot <machineId>` cannot clear boot safely
Reference: `programs/cube.lua:177-204`, `servers/cube-server.lua:57-60`
The documented behavior says an empty command deletes the boot hook. The client sends `nil` when no command is provided, and the server calls `writeFile('/.cubeboot', startupCommand)`. Writing `nil` can error, and even an empty string currently leaves an empty file instead of deleting `/.cubeboot`.
Fix:
- In `servers/cube-server.lua`, if `startupCommand == nil or startupCommand == ''`, delete `/.cubeboot` and reply `true`.
- Otherwise write the command.
- Bump `servers/cube-server.lua` `_VERSION`.
- Optionally adjust the client message to say `boot DELETED` only when the server replies successfully.
### Medium: `deploy-file` reports success even when writes fail
Reference: `servers/cube-server.lua:62-66`, `programs/cube.lua:242-248`
`deploy-file` ignores the return value from `writeFile` and always replies `true`. If a parent directory is missing, the destination is read-only, or `fs.open` fails, the client counts the file as transferred.
Fix:
- Reply with the boolean result of `writeFile`.
- Have the client keep the existing error print when `res` is false.
- Bump `servers/cube-server.lua` `_VERSION`.
### Medium: Deployment cannot create new nested directories
Reference: `servers/cube-server.lua:24-35`, `programs/cube.lua:20-38`
`cube deploy` sends file paths recursively, but the server writes files directly without creating parent directories. This fails for new directories that do not already exist on the target cube.
Fix:
- Before writing a deployed file, create its parent directory when needed.
- Keep behavior minimal: derive the parent path from `payload.path`, call `fs.makeDir(parent)` if not empty and not present, then write.
- Return false if `payload` is malformed or the file write fails.
### Low: `set-boot` help says `[command]`, but only one argument is accepted
Reference: `programs/cube.lua:6`, `programs/cube.lua:325`
`local cubeCommand, firstArg, secondArg = ...` means `cube set-boot 12 mining turtle start` only sends `mining`; extra words are dropped. This matters for shell commands with spaces.
Fix:
- Use `table.pack(...)` to collect all arguments.
- For `set-boot`, concatenate arguments after the machine id with spaces.
- Preserve existing aliases and help behavior.
- Bump `programs/cube.lua` `_VERSION`.
## Proposed Order
1. Fix `install.lua` to create `/servers`.
2. Fix `cube-server.lua` boot clearing and deploy write reporting.
3. Add parent-directory creation for deployed files.
4. Fix multi-word `cube set-boot` command parsing.
5. Update module versions for changed files.
## Verification
Manual/runtime verification is required inside ComputerCraft or CraftOS-PC because this repo has no runnable local test harness.
Suggested checks:
- On a clean machine, run the installer and confirm `/servers/*.lua` downloads.
- Run `cube set-boot <id>` and confirm `/.cubeboot` is deleted remotely.
- Run `cube set-boot <id> program arg` and confirm the full command is stored.
- Deploy a file inside a new nested directory and confirm it exists remotely.
- Force a bad deploy path or write failure and confirm the client reports an error instead of counting success.

View File

@ -1,51 +0,0 @@
# AGENTS / CLAUDE.md
Concise guidance for agents working in this repository.
## Project
ComputerCraft / CC:Tweaked Lua APIs, servers, and programs for Minecraft 1.21. Code runs in the ComputerCraft sandbox, not standard Lua.
Use [`docs/README.md`](docs/README.md) as the entrypoint for CC:Tweaked, CraftOS-PC, Advanced Peripherals, and Create integration documentation links. Use [`docs/adrs/README.md`](docs/adrs/README.md) for repository architecture decisions.
## Constraints
- Do not add a standalone Lua test harness unless asked. Local execution happens through the CraftOS-PC harness (see [`docs/install-craftos-pc.md`](docs/install-craftos-pc.md), [`docs/craftos_pc_glossary.md`](docs/craftos_pc_glossary.md), and [ADR-0005](docs/adrs/adr-0005-craftos-pc-harness-and-probes.md)); code otherwise executes in-game.
- Do not run `just repl` as an LLM agent; it is a human-only interactive CraftOS-PC wrapper. Use `just trapos-exec '<lua>'` for automated probes against the TrapOS dev environment, or `just craftos-exec '<lua>'` for probes against vanilla CraftOS (no TrapOS mounts). These wrappers shut down the machine and include a host watchdog. Headless probes are the recommended way to verify hypotheses about CC:Tweaked behavior; see [ADR-0005](docs/adrs/adr-0005-craftos-pc-harness-and-probes.md).
- When changing behavior, add as many useful CraftOS-PC tests as practical. It is acceptable to skip tests that require human-only validation, such as complex turtle motion, in-game UX feel, or visual approval, but still add unit-style non-regression tests for deterministic parts when possible.
- Use `/apis/libtest.lua` for test scripts under `tests/`; `/programs/runtest.lua` prints `__TRAPOS_TEST_OK__` only after the suite passes.
- `libtest` cancels each case after `3`s (`--timeout <s>` / `--no-timeout` to override); never commit a hanging test to `tests/`. Slow harness fixtures go in `tests/harness/` behind dedicated recipes. See [ADR-0007](docs/adrs/adr-0007-test-framework.md).
- Git hooks own commit/push verification: pre-commit runs `just check test`, and pre-push runs `just ci`. When explicitly asked to commit and/or push, do not run `just test` manually first; rely on the hooks. See [ADR-0011](docs/adrs/adr-0011-repo-conventions.md).
- After editing Lua or Markdown, run `just check` and fix all `luacheck` warnings and `lychee` broken-link reports (markdown link validation is offline-only via `just lint-markdown`).
- Use 2-space indent, semicolons, and `local function`.
- `require` paths are absolute ComputerCraft paths, for example `require('/apis/net')()`.
- Most API modules return factories; call the required module once before use.
## Architecture
- `apis/eventloop.lua` is the single-threaded event loop around `os.pullEventRaw`; consider using it everywhere async behavior is needed. A handler that returns `api.STOP` auto-unregisters.
- `startup/servers.lua` creates a single boot eventloop at `_G.bootEventLoop` and runs it alongside the shell via `parallel.waitForAny`. Servers register handlers on it and return; programs call services via `os.pullEvent` without touching the loop. See [ADR-0002](docs/adrs/adr-0002-eventloop-and-service-bus.md).
- `apis/libtest.lua` is the lightweight test helper used by scripts under `tests/`; `/programs/runtest.lua` discovers tests, renders suite output, and owns the `__TRAPOS_TEST_OK__` success marker.
- `apis/net.lua` is a service-name bus on a single channel (`10`). `net.serve(name, handler)` registers a server handler; `net.call(name, payload, { destId, timeout })` and `net.send(name, payload, { destId })` are the client surface. `require('/apis/net')()` returns a singleton bound to `_G.bootEventLoop` when present, otherwise an ephemeral instance.
- A router (`/programs/router.lua`) must be running somewhere on the network; it registers a `modem_message` handler that stamps `routerId`, resolves label-addressed packets via a TTL map populated by `servers/net-registrar.lua`, and rebroadcasts. Without it, packets stay unrouted and consumers ignore them.
- `servers/` register handlers on the boot eventloop and return; `programs/` are clients that exit.
- Single well-known channel: `10` (the bus). Service multiplexing happens inside the packet body.
## Boot And Install
- `startup/servers.lua` creates the boot eventloop, runs autostart server files (which register handlers and return), then runs the shell and the eventloop in parallel via `parallel.waitForAny`.
- Preserve `periphemu` guards used for CraftOS-PC emulation; see [`docs/craftos_pc_glossary.md`](docs/craftos_pc_glossary.md) for upstream emulator references.
- TrapOS ships as packages, each described by `packages/<name>/ccpm.json` (`name`, `version`, `dependencies`, `files`, `autostart`); `packages/index.json` lists them. Source files stay in place — descriptors only reference them. To ship a new file, add it to the right package's `files` (and `autostart` if it is a server). `packages/trapos/ccpm.json` is the full OS meta-package. See [ADR-0010](docs/adrs/adr-0010-ccpm-package-manager.md).
- `install-ccpm.lua` is the one-time wget bootstrap. It installs only `trapos-core`/`ccpm`, seeds the default `guillaumearm/cc-libs` registry as a `gitea` registry on `git.trapcloud.fr` tracking `master` (or `next` with `--beta`), and tells users to run `ccpm update` then `ccpm install trapos`. The legacy `github` registry type still resolves but is deprecated.
- `ccpm` (in `trapos-core`) is the package manager: `apis/libccpm.lua` is the testable core (factory with injectable `http`/`stateDir`/`installRoot`), `programs/ccpm.lua` the CLI. State: `/trapos/ccpm.json` (registries), `/trapos/ccpm.lock.json` (installed packages), and `/trapos/ccpm.cache.json` (available packages from `ccpm update`). Never use the word "manifest" in ccpm — it is reserved for the OS manifest.
- Add new servers to `startup/servers.lua` as needed.
## Conventions
- Bump the owning `packages/<name>/ccpm.json` version (and mirror it in `packages/index.json`) when changing module behavior; programs report it at runtime via `require('/apis/libversion')().forSelf()`. `install-ccpm.lua` is the only file that still carries its own `_VERSION` because it is the wget bootstrap and lives outside the package system.
- Programs support `-version`/`--version` and `-help`/`--help`; router also supports `-silent`/`--silent`.
- French or English comments are fine; match surrounding code.
- Commit messages use lightweight conventional style: `topic(scope): description` or `topic: description`.
- Reference other `.md` files (and `ADR-####`) with `[text](path)` link syntax so `just lint-markdown` (lychee) can validate them. See [ADR-0011](docs/adrs/adr-0011-repo-conventions.md).
See [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.

View File

@ -1,109 +0,0 @@
# ATM Expert Agent Plan
## Goal
Replace `.opencode/agent/computercraft.md` with `atm10-expert`.
The agent targets ATM10 / All The Mods 10, Minecraft `1.21.1`, NeoForge, pack version `7.0` (`0.7.0` accepted as user alias).
## Known Facts
CurseForge project id: `925200`.
ATM10 `7.0` file id: `8091114`.
Server pack file id: `8094893`.
NeoForge version: `21.1.228`.
GitHub source: <https://github.com/AllTheMods/ATM-10>
## Files To Create
Create `.opencode/agent/atm10-expert.md`.
Create `.opencode/agent-context/atm10-expert/INDEX.md`.
Create `.opencode/agent-context/atm10-expert/glossaries/`.
Create `.opencode/agent-context/atm10-expert/scripts/`.
Create `.opencode/agent-context/atm10-expert/modpacks/atm10-7.0.md`.
## Files To Move
Move in-game glossaries only:
`docs/cc_glossary.md` to agent context.
`docs/create_cc_tweaked_glossary.md` to agent context.
`docs/advanced_peripherals_glossary.md` to agent context.
Keep `docs/craftos_pc_glossary.md` in `docs/`.
Update `docs/README.md` links.
## Agent Behavior
Delete old `computercraft` agent.
New agent answers players concisely for in-game terminals.
Use local glossaries first, then websearch/webfetch.
Default to non-technical answers.
Adapt language to the user, usually French.
If a new useful mod documentation source is found, update or create a glossary and update `INDEX.md`.
Keep replies short unless the user asks for detail.
Use MCP bridge carefully: probe first, execute only bounded/read-only Lua unless explicitly asked.
## Permissions
Allow local read/search and web lookup.
Keep MCP bridge `probe-computers` and `exec-lua`.
Make edit/bash approval-gated or tightly limited, because this agent is player-facing.
Allow `just check` for markdown validation.
## ATM10 Mod List Script
Add `.opencode/agent-context/atm10-expert/scripts/fetch-atm10-7.0-mods.sh`.
Script should download CurseForge file `8091114`.
Extract `manifest.json`.
Write raw project/file ids and best available names to `modpacks/atm10-7.0.md`.
If `CURSEFORGE_API_KEY` exists, resolve project names through the CurseForge API.
Without API key, preserve ids and URLs for later glossary enrichment.
## TrapOS AI Context
Update `programs/ai.lua` / `apis/libai.lua` so each prompt includes hidden caller context:
computer id.
computer label, if any.
This helps `atm10-expert` know which in-game computer is calling.
Add tests in `tests/ai.lua`.
Bump `packages/trapos-ai/ccpm.json` and `packages/index.json`.
## Verification
Run `just check`.
Fix Lua lint and markdown link issues.
Restart opencode after agent/config changes.

41
CLAUDE.md Normal file
View File

@ -0,0 +1,41 @@
# CLAUDE.md
Concise guidance for agents working in this repository.
## Project
ComputerCraft / CC:Tweaked Lua APIs, servers, and programs for Minecraft 1.21. Code runs in the ComputerCraft sandbox, not standard Lua.
Use `docs/README.md` as the entrypoint for CC:Tweaked, Advanced Peripherals, and Create integration documentation links. Use `docs/adrs/README.md` for repository architecture decisions.
## Constraints
- Do not run Lua locally or add a test harness unless asked; code executes in-game or CraftOS-PC.
- After editing Lua, run `just check` and fix all `luacheck` warnings.
- Use 2-space indent, semicolons, and `local function`.
- `require` paths are absolute ComputerCraft paths, for example `require('/apis/net')()`.
- Most API modules return factories; call the required module once before use.
## Architecture
- `apis/eventloop.lua` is the single-threaded event loop around `os.pullEventRaw`; consider using it everywhere async behavior is needed. A handler that returns `api.STOP` auto-unregisters.
- `apis/net.lua` builds modem packet messaging, routing, and request/response RPC on the event loop. `sendRequest` returns `ok, result, packet` and defaults to a 0.5s timeout.
- A router (`/programs/router.lua`) must be running somewhere on the network; without it, packets lack `routerId`, `isPacketOk` rejects them, and cross-machine messaging silently fails.
- `servers/` listen for requests and start loops; `programs/` are clients that send requests and exit.
- Well-known channels: `9` ping, `10` router/default routing. Keep duplicated constants in sync.
## Boot And Install
- `startup/servers.lua` starts `/programs`, the shell, and configured servers via `parallel.waitForAll`.
- Preserve `periphemu` guards used for CraftOS-PC emulation.
- `install.lua` downloads files listed in `LIST_FILES` from `master` by default, or from `next` with `--beta`; add shipped files there.
- Add new servers to `startup/servers.lua` as needed.
## Conventions
- Bump `local _VERSION = '...'` when changing module behavior.
- Programs support `-version`/`--version` and `-help`/`--help`; router also supports `-silent`/`--silent`.
- French or English comments are fine; match surrounding code.
- Commit messages use lightweight conventional style: `topic(scope): description` or `topic: description`.
See `DEVELOPMENT.md` for local setup.

View File

@ -1,18 +1,13 @@
# Development
## Installation
Requirements:
- `just`
- `luacheck`
Install `just`, `jq`, `luacheck`, `openssl`, `lychee` (`brew install lychee`, or `cargo install lychee` if you already have a Rust toolchain), and [CraftOS-PC v2.8.3+](docs/install-craftos-pc.md), then run:
After cloning the repository, run:
```sh
just install trapos-install && echo ok
just install
```
This installs local Git hooks, checks required tools, generates `.env`, and verifies a full TrapOS install on a fresh CraftOS-PC state.
## Cleaning
- `just clean` — clears the `mcp-bridge` build caches (`node_modules/.cache/tsc`, `node_modules/.cache/eslint`). Safe to run anytime; `just reinstall` chains it with `just install`.
- `just clean-env` — deletes `.env`. Run only when you actually want to drop locally generated tokens.
See [`docs/README.md`](docs/README.md) for repository docs and [`docs/adrs/README.md`](docs/adrs/README.md) for architecture decisions.
This installs the local Git hooks, including a pre-push hook that runs `just check`.

View File

@ -1,14 +1,20 @@
# Justfile for cc-libs
# Run `just ci` for full local verification.
import 'just/deps.just'
import 'just/install.just'
import 'just/npm.just'
import 'just/craftos.just'
import 'just/opencode.just'
import 'just/test.just'
import 'just/check.just'
# Run `just check` to lint all Lua code with luacheck.
# List available recipes.
default:
@just --list
# Install local development tooling.
install: install-git-hooks
# Install Git hooks for this repository.
install-git-hooks:
@mkdir -p .git/hooks
@printf '%s\n' '#!/bin/sh' '' 'just check' > .git/hooks/pre-push
@chmod +x .git/hooks/pre-push
@printf '%s\n' 'Installed .git/hooks/pre-push'
# Lint all Lua source with luacheck.
check:
luacheck .

View File

@ -4,70 +4,22 @@ A small in-game operating system for ComputerCraft / CC:Tweaked, built around a
## Installation
Install `ccpm` first. This is the only step that needs `wget` with a URL:
```
wget run https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/master/install-ccpm.lua
wget run https://raw.githubusercontent.com/guillaumearm/cc-libs/master/install.lua
```
Then sync the default registry (`guillaumearm/cc-libs`) and install TrapOS:
Install the beta branch (one-time opt-in, asks for confirmation):
```
ccpm update
ccpm install trapos
wget run https://raw.githubusercontent.com/guillaumearm/cc-libs/next/install.lua --beta
```
Install individual packages instead if you want to cherry-pick:
```
> ccpm install trapos-net
> ccpm install trapos-ui
```
Install `ccpm` from the beta branch (one-time opt-in, asks for confirmation):
```
wget run https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/install-ccpm.lua --beta
```
Once `ccpm` is installed from beta, the default registry tracks `next`; `ccpm update` and `ccpm upgrade` keep using that branch.
Once a machine is on beta, `upgrade` keeps it on beta — `--beta` is not needed again. Use `upgrade --stable` to go back to the stable branch.
After install, every boot shows a colored MOTD with the installed version and branch (lime for stable, orange + `[BETA]` for beta).
## Packages
## Manifest
TrapOS is split into packages, each described by a `packages/<name>/ccpm.json`:
- `trapos-core`: the package manager (`ccpm`), event loop, `upgrade`, `events`.
- `trapos-test`: the test framework (`libtest`) and suite runner (`runtest`).
- `trapos-boot`: the startup MOTD and autostart server launcher.
- `trapos-net`: routed modem networking (`net`, `router`, `ping`, `ping-server`).
- `trapos-ui`: the terminal UI toolkit (`libtui`, `tuidemo`).
- `trapos-ai`: the AI client for `opencode serve`.
- `trapos`: full TrapOS meta-package (`trapos-boot`, `trapos-net`, `trapos-ui`, `trapos-test`, and `trapos-ai` during beta).
The `trapos` meta-package is the user-facing full install. Package descriptors list
files and autostart servers; installed state is tracked under `/trapos`.
## ccpm
`ccpm` is the TrapOS package manager. `install-ccpm.lua` installs it by installing
the required `trapos-core` package and configures the default registry
(`guillaumearm/cc-libs`).
```
ccpm install <package> ccpm reinstall <package> ccpm uninstall <package>
ccpm update ccpm upgrade
ccpm ls ccpm available [term] ccpm search [term]
ccpm info <package>
ccpm registry ls ccpm registry add <name> [--branch <b>] [--type gitea|github|http]
ccpm registry rm <name>
```
`ccpm update` fetches registry package indexes into `/trapos/ccpm.cache.json`.
`ccpm available` lists cached packages and marks installed packages as up-to-date or
updatable. `ccpm upgrade` upgrades installed packages based on that cache.
Registries default to GitHub (`owner/repo`); `http`/`https` base URLs are also
supported. State lives in `/trapos/ccpm.json` (registries),
`/trapos/ccpm.lock.json` (installed packages), and `/trapos/ccpm.cache.json`
(available packages). See [ADR-0010](docs/adrs/adr-0010-ccpm-package-manager.md).
The installed file list, version, autostart servers, and current branch all live in `manifest.json` at the repo root. A copy is persisted locally at `/trapos/manifest.json` after install, and drives `upgrade`, `startup/motd.lua`, and `startup/servers.lua`.
## APIs
- `/apis/eventloop`: a simple event loop API.
@ -82,7 +34,7 @@ Servers listed in `manifest.autostart` are launched at boot by `startup/servers.
- `router`: routes messages. You need to set up a router to use all `apis/net`-based programs and libraries.
- `ping`: pings machines using `apis/net`.
- `events`: emits and logs computer events.
- `upgrade`: alias for `ccpm upgrade`.
- `upgrade`: upgrades the machine. Reads `/trapos/manifest.json` to stay on the current branch; use `--beta` to opt in or `--stable` to opt out.
## Development
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development installation.
See [DEVELOPMENT.md](./DEVELOPMENT.md) for development setup and workflow.

View File

@ -1,3 +1,5 @@
local _VERSION = '2.0.0'
-- Basic event loop library for computer craft
--
-- Example usage:

View File

@ -1,622 +0,0 @@
local PING_PROMPT = 'reply with exactly: pong';
local DEFAULT_TIMEOUT_SECONDS = 60;
local MAX_TIMEOUT_SECONDS = 60;
local DEFAULT_REQUEST_TIMEOUT_SECONDS = 600;
local MAX_REQUEST_TIMEOUT_SECONDS = 600;
local DEFAULT_LUA_EXEC_MAX_RETRIES = 2;
local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5;
local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id';
local DEFAULT_AGENT_SETTING_KEY = 'opencc.agent';
local DEFAULT_VARIANT_SETTING_KEY = 'opencc.variant';
local DEFAULT_BRIDGE_SETTING_KEY = 'opencc.bridge_url';
local createHttp = require('/apis/libhttp');
local createHttpWs = require('/apis/libhttpws');
local function isWsUrl(url)
return type(url) == 'string' and string.match(url, '^wss?://') ~= nil;
end
local function isBlank(s)
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
end
local function extractTextParts(parts)
if type(parts) ~= 'table' then
return '';
end
local texts = {};
for _, part in ipairs(parts) do
if type(part) == 'table' and part.type == 'text' and type(part.text) == 'string' then
texts[#texts + 1] = part.text;
end
end
return table.concat(texts, '');
end
local function tablePack(...)
return { n = select('#', ...), ... };
end
local function endsWithNewline(s)
return type(s) == 'string' and string.sub(s, -1) == '\n';
end
local function valuesToLine(values, first, last)
local parts = {};
for i = first, last do
parts[#parts + 1] = tostring(values[i]);
end
return table.concat(parts, '\t');
end
local function classifyLuaRuntimeError(err)
local text = tostring(err or '');
if string.find(text, 'attempt to', 1, true) and string.find(text, 'nil value', 1, true) then
return 'identifier';
end
if string.find(text, 'global', 1, true) and string.find(text, 'nil', 1, true) then
return 'identifier';
end
return 'other';
end
local function renderOutput(output)
if output == nil or output == '' then
return '(no output)';
end
return output;
end
local function buildLuaExecPrompt(userPrompt)
return table.concat({
'Write ComputerCraft Lua code to answer this user request.',
'Reply with raw Lua code only. Do not use markdown fences or explanations.',
'The code runs locally with normal ComputerCraft globals available.',
'Use print() or write() for values that should be sent back. Returned values are captured too.',
'',
'User request:',
userPrompt,
}, '\n');
end
local function buildLuaCorrectionPrompt(userPrompt, code, err, errorKind)
return table.concat({
'The previous ComputerCraft Lua failed.',
'Reply with corrected raw Lua code only. Do not use markdown fences or explanations.',
'',
'Original user request:',
userPrompt,
'',
'Error kind: ' .. tostring(errorKind),
'Error:',
tostring(err),
'',
'Previous code:',
code,
}, '\n');
end
local function buildLuaOutputPrompt(userPrompt, output)
return table.concat({
'The Lua executed successfully.',
'Answer the original user request in natural language using the output below.',
'Do not write more Lua unless the user explicitly asked for code.',
'',
'Original user request:',
userPrompt,
'',
'Lua output:',
renderOutput(output),
}, '\n');
end
local function readOsValue(osLib, name)
if type(osLib) ~= 'table' or type(osLib[name]) ~= 'function' then
return nil;
end
local ok, value = pcall(osLib[name]);
if not ok then return nil; end
return value;
end
local function buildPromptWithCallerContext(prompt, osLib)
local lines = {
'<caller-context hidden="true">',
'Use this context silently to identify the in-game ComputerCraft caller.',
};
local computerId = readOsValue(osLib, 'getComputerID');
local computerLabel = readOsValue(osLib, 'getComputerLabel');
if computerId ~= nil then
lines[#lines + 1] = 'computer id: ' .. tostring(computerId);
end
if not isBlank(computerLabel) then
lines[#lines + 1] = 'computer label: ' .. tostring(computerLabel);
end
lines[#lines + 1] = '</caller-context>';
lines[#lines + 1] = '';
lines[#lines + 1] = 'User prompt:';
lines[#lines + 1] = prompt;
return table.concat(lines, '\n');
end
local function sessionTime(session)
if type(session) ~= 'table' or type(session.time) ~= 'table' then
return 0;
end
return tonumber(session.time.updated or session.time.created) or 0;
end
local function createAi(opts)
opts = opts or {};
local httpLib = opts.http or http;
local settingsLib = opts.settings or settings;
local osLib = opts.os or os;
local function resolveRequestTimeout()
local raw = settingsLib.get('opencc.request_timeout_seconds');
local n = tonumber(raw);
if not n or n <= 0 then n = DEFAULT_REQUEST_TIMEOUT_SECONDS; end
if n > MAX_REQUEST_TIMEOUT_SECONDS then n = MAX_REQUEST_TIMEOUT_SECONDS; end
return n;
end
-- A ws:// bridge routes opencode calls through the mcp-bridge proxy, escaping
-- ComputerCraft's hard ~60s http timeout. Falls back to direct http otherwise.
local bridgeUrl = opts.bridgeUrl or settingsLib.get(DEFAULT_BRIDGE_SETTING_KEY);
if isBlank(bridgeUrl) and isWsUrl(settingsLib.get('opencc.server_url')) then
bridgeUrl = settingsLib.get('opencc.server_url');
end
local bridgeActive = not isBlank(bridgeUrl);
local httpClient = opts.httpClient;
if not httpClient then
if bridgeActive then
httpClient = createHttpWs({
http = httpLib,
bridgeUrl = bridgeUrl,
receiveTimeout = resolveRequestTimeout(),
});
else
httpClient = createHttp({ http = httpLib });
end
end
local api = {};
local function resolveTimeout(options)
local raw = options.timeoutSeconds;
if raw == nil then raw = settingsLib.get('opencc.timeout_seconds'); end
local n = tonumber(raw);
if not n or n <= 0 then n = DEFAULT_TIMEOUT_SECONDS; end
if n > MAX_TIMEOUT_SECONDS then n = MAX_TIMEOUT_SECONDS; end
return n;
end
local function resolveLuaExecMaxRetries(options)
local n = tonumber(options.maxRetries);
if n and n >= 0 then return math.floor(n); end
return DEFAULT_LUA_EXEC_MAX_RETRIES;
end
local function resolveLuaExecTimeout(options)
if options.luaTimeoutSeconds == false then return nil; end
local n = tonumber(options.luaTimeoutSeconds);
if n and n > 0 then return n; end
return DEFAULT_LUA_EXEC_TIMEOUT_SECONDS;
end
local function resolveModel(options)
local providerId = options.providerID or settingsLib.get('opencc.provider_id');
local modelId = options.modelID or settingsLib.get('opencc.model_id');
if isBlank(providerId) or isBlank(modelId) then
return nil, nil;
end
return providerId, modelId;
end
local function resolveAgent(options)
local agent = options.agent;
if agent == nil then agent = settingsLib.get(DEFAULT_AGENT_SETTING_KEY); end
if isBlank(agent) then return nil; end
return agent;
end
local function resolveVariant(options)
local variant = options.variant;
if variant == nil then variant = settingsLib.get(DEFAULT_VARIANT_SETTING_KEY); end
if isBlank(variant) then return nil; end
return variant;
end
local function resolveConfig(options)
local url = options.serverUrl or settingsLib.get('opencc.server_url');
-- In bridge mode the proxy holds the real opencode URL, so server_url is optional.
if isBlank(url) then
if not bridgeActive then
return nil, 'missing opencc.server_url; run: set opencc.server_url <url>';
end
url = '';
end
local username = options.username or settingsLib.get('opencc.username') or 'opencode';
local password = options.password or settingsLib.get('opencc.password') or '';
local directory = options.directory or settingsLib.get('opencc.directory');
local providerId, modelId = resolveModel(options);
return {
url = httpClient.trimTrailingSlash(url),
username = username,
password = password,
directory = directory,
providerID = providerId,
modelID = modelId,
agent = resolveAgent(options),
variant = resolveVariant(options),
timeoutSeconds = resolveTimeout(options),
};
end
local function buildMessageBody(cfg, prompt)
local body = {
parts = { { type = 'text', text = prompt } },
};
if cfg.providerID and cfg.modelID then
body.model = { providerID = cfg.providerID, modelID = cfg.modelID };
end
if cfg.agent then
body.agent = cfg.agent;
end
if cfg.variant then
body.variant = cfg.variant;
end
return body;
end
local function decodeMessage(value)
local decoded = value;
if type(value) == 'string' then
decoded = textutils.unserializeJSON(value);
end
if type(decoded) ~= 'table' or type(decoded.parts) ~= 'table' then
return nil, 'reponse message invalide';
end
return decoded, nil;
end
local function handleMissingSession(persist, sessionSettingKey)
if persist then
settingsLib.unset(sessionSettingKey or DEFAULT_SESSION_SETTING_KEY);
if settingsLib.save then settingsLib.save(); end
end
return false, 'session introuvable; lance: ai new <prompt>';
end
local function doGet(cfg, path)
return httpClient.getJson(cfg, path);
end
local function doPost(cfg, path, payload)
return httpClient.postJson(cfg, path, payload);
end
local function askBlocking(cfg, sessionId, prompt, persist, sessionSettingKey, log)
log('sending blocking message');
local body, code = doPost(cfg, '/session/' .. sessionId .. '/message', buildMessageBody(cfg, prompt));
if not body then return false, code; end
if code == 404 then
return handleMissingSession(persist, sessionSettingKey);
end
if code and code ~= 200 then
return false, 'erreur message: HTTP ' .. tostring(code);
end
local decoded, decodeErr = decodeMessage(body);
if not decoded then return false, decodeErr; end
local reply = extractTextParts(decoded.parts);
if reply == '' then return false, 'reponse vide'; end
return true, {
reply = reply,
sessionId = sessionId,
messageId = type(decoded.info) == 'table' and decoded.info.id or nil,
};
end
local function listSessionsWithDirectory(cfg, directory)
return doGet(cfg, '/session' .. httpClient.queryString({ { 'directory', directory } }));
end
local function decodeSessionList(body, log)
local decoded = textutils.unserializeJSON(body);
if type(decoded) ~= 'table' then
log('list sessions failed: invalid response');
return nil, 'reponse invalide';
end
table.sort(decoded, function(a, b)
return sessionTime(a) > sessionTime(b);
end);
return decoded, nil;
end
function api.clearSession(options)
options = options or {};
settingsLib.unset(options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY);
if settingsLib.save then settingsLib.save(); end
end
function api.listSessions(options)
options = options or {};
local log = options.log or function() end;
local cfg, err = resolveConfig(options);
if not cfg then return false, err; end
local directory = cfg.directory;
local sessionSettingKey = options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY;
log('listing sessions from ' .. cfg.url);
local body, code;
if isBlank(directory) then
body, code = doGet(cfg, '/session');
else
log('listing sessions for directory ' .. tostring(directory));
body, code = listSessionsWithDirectory(cfg, directory);
end
if not body then
log('list sessions failed: ' .. tostring(code));
return false, code;
end
if code and code ~= 200 then
log('list sessions failed: HTTP ' .. tostring(code));
return false, 'erreur serveur: HTTP ' .. tostring(code);
end
local decoded, decodeErr = decodeSessionList(body, log);
if not decoded then return false, decodeErr; end
if #decoded == 0 and isBlank(directory) then
local sessionId = options.sessionId or settingsLib.get(sessionSettingKey);
if not isBlank(sessionId) then
log('session list empty; resolving directory from ' .. tostring(sessionId));
local sessionBody, sessionCode = doGet(cfg, '/session/' .. sessionId);
if sessionBody and (not sessionCode or sessionCode == 200) then
local session = textutils.unserializeJSON(sessionBody);
if type(session) == 'table' and not isBlank(session.directory) then
log('retrying sessions for directory ' .. tostring(session.directory));
local scopedBody, scopedCode = listSessionsWithDirectory(cfg, session.directory);
if scopedBody and (not scopedCode or scopedCode == 200) then
local scoped, scopedErr = decodeSessionList(scopedBody, log);
if not scoped then return false, scopedErr; end
decoded = scoped;
end
end
end
end
end
log('sessions returned: ' .. tostring(#decoded));
return true, decoded;
end
function api.ask(prompt, options)
options = options or {};
local log = options.log or function() end;
if isBlank(prompt) then
return false, 'missing prompt; usage: ai <prompt>';
end
local cfg, err = resolveConfig(options);
if not cfg then return false, err; end
local persist = options.persist ~= false;
local sessionSettingKey = options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY;
local sessionId = options.sessionId;
if persist and sessionId == nil then
sessionId = settingsLib.get(sessionSettingKey);
end
if not sessionId or sessionId == '' then
log('creating session');
local body, code = doPost(cfg, '/session', { title = options.sessionTitle or 'cc-ai' });
if not body then return false, code; end
if code and code ~= 200 then
return false, 'impossible de creer une session: HTTP ' .. tostring(code);
end
local decoded = textutils.unserializeJSON(body);
if type(decoded) ~= 'table' or type(decoded.id) ~= 'string' then
return false, 'reponse session invalide';
end
sessionId = decoded.id;
if persist then
settingsLib.set(sessionSettingKey, sessionId);
if settingsLib.save then settingsLib.save(); end
end
else
log('reusing session ' .. sessionId);
end
local promptWithContext = prompt;
if options.includeCallerContext ~= false then
promptWithContext = buildPromptWithCallerContext(prompt, osLib);
end
-- Synchronous /message only: opencode's prompt_async loops the model on some
-- builds and strands sessions as busy. The bridge ws transport removes the
-- ~60s http cap that previously made blocking impractical.
return askBlocking(cfg, sessionId, promptWithContext, persist, sessionSettingKey, log);
end
function api.createLuaExecutor(options)
options = options or {};
local baseEnv = options.env or _G;
local live = options.live ~= false;
local livePrint = options.print or print;
local liveWrite = options.write or write;
local timeoutSeconds = resolveLuaExecTimeout(options);
return function(code)
local buffer = {};
local function append(text)
buffer[#buffer + 1] = text;
end
local function capturedPrint(...)
local values = tablePack(...);
local line = valuesToLine(values, 1, values.n);
append(line .. '\n');
if live then livePrint(...); end
end
local function capturedWrite(text)
text = tostring(text or '');
append(text);
if live then liveWrite(text); end
end
local env = setmetatable({
print = capturedPrint,
write = capturedWrite,
}, { __index = baseEnv });
local chunk, loadErr = load(code, 'ai-lua-exec', 't', env);
if not chunk then
return false, tostring(loadErr), 'syntax';
end
local result;
local finished = false;
local function runner()
result = tablePack(pcall(chunk));
finished = true;
end
if timeoutSeconds then
parallel.waitForAny(runner, function() sleep(timeoutSeconds); end);
else
runner();
end
if not finished then
return false, 'lua execution timed out after ' .. tostring(timeoutSeconds) .. 's', 'other';
end
if not result[1] then
return false, tostring(result[2]), classifyLuaRuntimeError(result[2]);
end
if result.n > 1 then
if #buffer > 0 and not endsWithNewline(buffer[#buffer]) then
append('\n');
end
append(valuesToLine(result, 2, result.n) .. '\n');
end
return true, table.concat(buffer), nil;
end;
end
function api.luaExec(userPrompt, options)
options = options or {};
if isBlank(userPrompt) then
return false, { error = 'missing prompt; usage: ai lua-exec <prompt>', attempts = 0 };
end
local log = options.log or function() end;
local executor = options.executor or api.createLuaExecutor(options);
local maxRetries = resolveLuaExecMaxRetries(options);
local maxAttempts = maxRetries + 1;
local sessionId;
local function askOptions()
return {
persist = false,
sessionId = sessionId,
sessionTitle = 'cc-ai lua-exec',
serverUrl = options.serverUrl,
username = options.username,
password = options.password,
providerID = options.providerID,
modelID = options.modelID,
agent = options.agent,
variant = options.variant,
timeoutSeconds = options.timeoutSeconds,
};
end
log('requesting Lua from AI');
local ok, result = api.ask(buildLuaExecPrompt(userPrompt), askOptions());
if not ok then
return false, { error = result, attempts = 0, errorKind = 'ai' };
end
sessionId = result.sessionId;
log('session: ' .. sessionId);
local code = result.reply;
for attempt = 1, maxAttempts do
log('attempt ' .. tostring(attempt) .. '/' .. tostring(maxAttempts));
log('code:\n' .. code);
local execOk, outputOrErr, errorKind = executor(code);
if execOk then
local output = outputOrErr or '';
log('output:\n' .. renderOutput(output));
log('requesting final reply');
local finalOk, finalResult = api.ask(buildLuaOutputPrompt(userPrompt, output), askOptions());
if not finalOk then
return false, {
error = finalResult,
attempts = attempt,
errorKind = 'ai',
code = code,
output = output,
sessionId = sessionId,
};
end
log('final reply received');
return true, {
reply = finalResult.reply,
output = output,
code = code,
attempts = attempt,
sessionId = sessionId,
};
end
errorKind = errorKind or 'other';
log('error (' .. tostring(errorKind) .. '):\n' .. tostring(outputOrErr));
if (errorKind ~= 'syntax' and errorKind ~= 'identifier') or attempt >= maxAttempts then
return false, {
error = outputOrErr,
attempts = attempt,
errorKind = errorKind,
code = code,
sessionId = sessionId,
retryExhausted = attempt >= maxAttempts,
};
end
log('requesting corrected Lua');
local correctionOk, correctionResult = api.ask(
buildLuaCorrectionPrompt(userPrompt, code, outputOrErr, errorKind),
askOptions()
);
if not correctionOk then
return false, {
error = correctionResult,
attempts = attempt,
errorKind = 'ai',
code = code,
sessionId = sessionId,
};
end
code = correctionResult.reply;
end
return false, { error = 'lua-exec failed unexpectedly', attempts = maxAttempts };
end
function api.ping(options)
options = options or {};
options.includeCallerContext = false;
return api.ask(PING_PROMPT, options);
end
return api;
end
return createAi;

View File

@ -1,171 +0,0 @@
local DEFAULT_SIZE = 8;
local DEFAULT_CHAR = '#';
local RANDOM_CHARS = { '#', '*', '+', 'x', '@', '=' };
local function firstChar(value)
value = tostring(value or '');
if value == '' then return nil; end
return string.sub(value, 1, 1);
end
local function parsePositiveInteger(value, name)
local number = tonumber(value);
if not number or number < 1 or number ~= math.floor(number) then
return nil, name .. ' doit etre un entier positif';
end
return number;
end
local function parseNonNegativeNumber(value, name)
local number = tonumber(value);
if not number or number < 0 then
return nil, name .. ' doit etre un nombre positif ou nul';
end
return number;
end
local function clamp(value, minValue, maxValue)
if value < minValue then return minValue; end
if value > maxValue then return maxValue; end
return value;
end
local function createCarre(opts)
opts = opts or {};
local random = opts.random or math.random;
local api = {};
function api.parseArgs(args)
args = args or {};
local config = {
size = DEFAULT_SIZE,
char = DEFAULT_CHAR,
fill = false,
random = false,
count = 1,
delay = 0,
clear = false,
explicit = {},
};
local i = 1;
while i <= (args.n or #args) do
local arg = args[i];
if arg == '-size' then
local value, err = parsePositiveInteger(args[i + 1], '-size');
if not value then return nil, err; end
config.size = value;
config.explicit.size = true;
i = i + 1;
elseif arg == '-x' then
local value, err = parsePositiveInteger(args[i + 1], '-x');
if not value then return nil, err; end
config.x = value;
config.explicit.x = true;
i = i + 1;
elseif arg == '-y' then
local value, err = parsePositiveInteger(args[i + 1], '-y');
if not value then return nil, err; end
config.y = value;
config.explicit.y = true;
i = i + 1;
elseif arg == '-char' then
local value = firstChar(args[i + 1]);
if not value then return nil, '-char doit recevoir un caractere'; end
config.char = value;
config.explicit.char = true;
i = i + 1;
elseif arg == '-fill' then
config.fill = true;
elseif arg == '-random' then
config.random = true;
elseif arg == '-count' then
local value, err = parsePositiveInteger(args[i + 1], '-count');
if not value then return nil, err; end
config.count = value;
i = i + 1;
elseif arg == '-delay' then
local value, err = parseNonNegativeNumber(args[i + 1], '-delay');
if not value then return nil, err; end
config.delay = value;
i = i + 1;
elseif arg == '-clear' then
config.clear = true;
else
return nil, 'option inconnue: ' .. tostring(arg);
end
i = i + 1;
end
return config;
end
function api.computeSquare(config, width, height)
width = math.max(1, tonumber(width) or 1);
height = math.max(1, tonumber(height) or 1);
config = config or {};
local explicit = config.explicit or {};
local maxSize = math.max(1, math.min(width, height));
local size = config.size or DEFAULT_SIZE;
local char = config.char or DEFAULT_CHAR;
if config.random then
if not explicit.size then
size = random(1, maxSize);
end
if not explicit.char then
char = RANDOM_CHARS[random(1, #RANDOM_CHARS)];
end
end
size = clamp(size, 1, maxSize);
local maxX = math.max(1, width - size + 1);
local maxY = math.max(1, height - size + 1);
local x = config.x;
local y = config.y;
if config.random and not explicit.x then
x = random(1, maxX);
elseif not x then
x = math.floor((width - size) / 2) + 1;
end
if config.random and not explicit.y then
y = random(1, maxY);
elseif not y then
y = math.floor((height - size) / 2) + 1;
end
return {
x = clamp(x, 1, maxX),
y = clamp(y, 1, maxY),
size = size,
char = firstChar(char) or DEFAULT_CHAR,
fill = config.fill == true,
};
end
function api.drawSquare(termLib, square)
for row = 1, square.size do
termLib.setCursorPos(square.x, square.y + row - 1);
for column = 1, square.size do
local isBorder = row == 1 or row == square.size or column == 1 or column == square.size;
if square.fill or isBorder then
termLib.write(square.char);
else
termLib.write(' ');
end
end
end
end
return api;
end
return createCarre;

View File

@ -1,479 +0,0 @@
-- libccpm: the testable core of the TrapOS package manager (ccpm).
--
-- A factory: `local createCcpm = require('/apis/libccpm'); local ccpm = createCcpm();`
--
-- State lives under `opts.stateDir` (default `/trapos`):
-- - ccpm.json -> { registries = { { name, type, branch }, ... } }
-- - ccpm.lock.json -> { packages = { <name> = { version, registry, files,
-- dependencies, autostart } } }
-- - ccpm.cache.json -> { packages = { <name> = { version, registry } } }
--
-- Files are written under `opts.installRoot` (default '' -> filesystem root), so
-- tests can sandbox downloads. `opts.http` overrides the global `http` for tests.
local DEFAULT_STATE_DIR = '/trapos';
local function normalizePath(path)
path = path:gsub('//+', '/');
if path:sub(1, 1) ~= '/' then
path = '/' .. path;
end
return path;
end
local function parentDir(path)
return path:match('^(.*)/[^/]+$');
end
local function createCcpm(opts)
opts = opts or {};
local httpLib = opts.http or http;
local stateDir = opts.stateDir or DEFAULT_STATE_DIR;
local installRoot = opts.installRoot or '';
local configPath = stateDir .. '/ccpm.json';
local lockPath = stateDir .. '/ccpm.lock.json';
local cachePath = stateDir .. '/ccpm.cache.json';
local manifestPath = stateDir .. '/manifest.json';
local api = {};
-- ---------- JSON file helpers ----------
local function readJsonFile(path)
if not fs.exists(path) then return nil; end
local f = fs.open(path, 'r');
if not f then return nil; end
local data = f.readAll();
f.close();
if not data or data == '' then return nil; end
return textutils.unserializeJSON(data);
end
local function writeJsonFile(path, value)
local dir = parentDir(path);
if dir then fs.makeDir(dir); end
local f = fs.open(path, 'w');
if not f then return false; end
f.write(textutils.serializeJSON(value));
f.close();
return true;
end
-- ---------- config (registries) ----------
function api.readConfig()
return readJsonFile(configPath) or { registries = {} };
end
function api.writeConfig(cfg)
return writeJsonFile(configPath, cfg);
end
function api.listRegistries()
return api.readConfig().registries or {};
end
function api.addRegistry(name, registryOpts)
registryOpts = registryOpts or {};
local cfg = api.readConfig();
cfg.registries = cfg.registries or {};
for _, r in ipairs(cfg.registries) do
if r.name == name then
return false, 'registry already exists: ' .. name;
end
end
local registry = {
name = name,
type = registryOpts.type or 'gitea',
branch = registryOpts.branch or 'master',
};
cfg.registries[#cfg.registries + 1] = registry;
api.writeConfig(cfg);
return true, registry;
end
function api.removeRegistry(name)
local cfg = api.readConfig();
cfg.registries = cfg.registries or {};
local kept = {};
local found = false;
for _, r in ipairs(cfg.registries) do
if r.name == name then
found = true;
else
kept[#kept + 1] = r;
end
end
if not found then
return false, 'registry not found: ' .. name;
end
cfg.registries = kept;
api.writeConfig(cfg);
return true;
end
-- ---------- lock (installed packages) ----------
function api.readLock()
return readJsonFile(lockPath) or { packages = {} };
end
function api.writeLock(lock)
return writeJsonFile(lockPath, lock);
end
function api.list()
return api.readLock().packages or {};
end
local function writeOsState(lock)
local files = {};
local seenFile = {};
local autostart = {};
local seenAuto = {};
for _, entry in pairs(lock.packages or {}) do
for _, filePath in ipairs(entry.files or {}) do
if not seenFile[filePath] then
seenFile[filePath] = true;
files[#files + 1] = filePath;
end
end
for _, server in ipairs(entry.autostart or {}) do
if not seenAuto[server] then
seenAuto[server] = true;
autostart[#autostart + 1] = server;
end
end
end
table.sort(files);
table.sort(autostart);
local cfg = api.readConfig();
local registry = cfg.registries and cfg.registries[1] or {};
local trapos = lock.packages and lock.packages.trapos;
return writeJsonFile(manifestPath, {
name = 'TrapOS',
version = trapos and trapos.version or '?',
branch = registry.branch or 'master',
files = files,
autostart = autostart,
});
end
-- ---------- cache (available packages) ----------
function api.readCache()
return readJsonFile(cachePath) or { packages = {} };
end
function api.writeCache(cache)
return writeJsonFile(cachePath, cache);
end
-- ---------- URL resolution ----------
function api.registryBaseUrl(registry)
if registry.type == 'github' then
local branch = registry.branch or 'master';
return 'https://raw.githubusercontent.com/' .. registry.name .. '/' .. branch .. '/';
elseif registry.type == 'gitea' then
local branch = registry.branch or 'master';
return 'https://git.trapcloud.fr/' .. registry.name .. '/raw/branch/' .. branch .. '/';
end
local base = registry.name;
if base:sub(-1) ~= '/' then base = base .. '/'; end
return base;
end
function api.descriptorUrl(registry, pkg)
return api.registryBaseUrl(registry) .. 'packages/' .. pkg .. '/ccpm.json';
end
function api.indexUrl(registry)
return api.registryBaseUrl(registry) .. 'packages/index.json';
end
function api.fileUrl(registry, filePath)
return api.registryBaseUrl(registry) .. filePath;
end
-- ---------- HTTP ----------
local function httpGetBody(url)
local res = httpLib.get(url);
if not res then return nil; end
local body = res.readAll();
res.close();
return body;
end
function api.fetchJson(url)
local body = httpGetBody(url);
if not body or body == '' then return nil; end
return textutils.unserializeJSON(body);
end
-- Find a package descriptor across the configured registries.
-- Returns descriptor, registry (or nil when not found).
function api.findPackage(pkg)
local cfg = api.readConfig();
for _, registry in ipairs(cfg.registries or {}) do
local desc = api.fetchJson(api.descriptorUrl(registry, pkg));
if desc then return desc, registry; end
end
return nil;
end
function api.info(pkg)
return api.findPackage(pkg);
end
function api.search(term)
local results = {};
local cache = api.readCache();
for name, entry in pairs(cache.packages or {}) do
if not term or term == '' or string.find(name, term, 1, true) then
results[#results + 1] = { name = name, version = entry.version, registry = entry.registry };
end
end
table.sort(results, function(a, b) return a.name < b.name; end);
return results;
end
function api.update()
local cfg = api.readConfig();
local cache = { packages = {} };
for _, registry in ipairs(cfg.registries or {}) do
local index = api.fetchJson(api.indexUrl(registry));
if index and index.packages then
for name, version in pairs(index.packages) do
if not cache.packages[name] then
cache.packages[name] = { version = version, registry = registry.name };
end
end
end
end
api.writeCache(cache);
return cache;
end
local function compareVersions(a, b)
a = tostring(a or '');
b = tostring(b or '');
if a == b then return 0; end
local aparts = {};
local bparts = {};
for part in string.gmatch(a, '[^%.]+') do aparts[#aparts + 1] = part; end
for part in string.gmatch(b, '[^%.]+') do bparts[#bparts + 1] = part; end
local max = math.max(#aparts, #bparts);
local hitBreak = false;
for i = 1, max do
local apart = aparts[i] or '0';
local bpart = bparts[i] or '0';
local anum = tonumber(apart);
local bnum = tonumber(bpart);
if not anum or not bnum then hitBreak = true; break; end
if anum < bnum then return -1; end
if anum > bnum then return 1; end
end
if not hitBreak then return 0; end
if a < b then return -1; end
return 1;
end
function api.available(term)
local cache = api.readCache();
local installed = api.list();
local results = {};
for name, entry in pairs(cache.packages or {}) do
if not term or term == '' or string.find(name, term, 1, true) then
local status = 'available';
local installedVersion = nil;
if installed[name] then
installedVersion = installed[name].version;
if compareVersions(installedVersion, entry.version) < 0 then
status = 'updatable';
else
status = 'up-to-date';
end
end
results[#results + 1] = {
name = name,
version = entry.version,
installedVersion = installedVersion,
registry = entry.registry,
status = status,
};
end
end
table.sort(results, function(a, b) return a.name < b.name; end);
return results;
end
-- ---------- dependency resolution ----------
-- Resolve `pkg` and all of its dependencies into install order (deps first,
-- target last). Returns an ordered list of { name, desc, registry } or nil, err.
function api.resolve(pkg)
local ordered = {};
local state = {}; -- name -> 'visiting' | 'done'
local errMsg = nil;
local function visit(name, chain)
if errMsg then return; end
if state[name] == 'done' then return; end
if state[name] == 'visiting' then
errMsg = 'dependency cycle detected: ' .. table.concat(chain, ' -> ') .. ' -> ' .. name;
return;
end
state[name] = 'visiting';
local desc, registry = api.findPackage(name);
if not desc then
errMsg = 'package not found: ' .. name;
return;
end
chain[#chain + 1] = name;
for _, dep in ipairs(desc.dependencies or {}) do
visit(dep, chain);
if errMsg then return; end
end
chain[#chain] = nil;
state[name] = 'done';
ordered[#ordered + 1] = { name = name, desc = desc, registry = registry };
end
visit(pkg, {});
if errMsg then return nil, errMsg; end
return ordered;
end
-- ---------- install / uninstall ----------
local function installTarget(filePath)
return normalizePath(installRoot .. '/' .. filePath);
end
function api.downloadFile(registry, filePath)
local body = httpGetBody(api.fileUrl(registry, filePath));
if not body then
return false, 'failed to download ' .. filePath;
end
local target = installTarget(filePath);
local dir = parentDir(target);
if dir then fs.makeDir(dir); end
fs.delete(target);
local f = fs.open(target, 'w');
if not f then
return false, 'failed to write ' .. target;
end
f.write(body);
f.close();
return true;
end
-- installOpts: { force = bool, log = function(msg) }
function api.install(pkg, installOpts)
installOpts = installOpts or {};
local log = installOpts.log or function() end;
local lock = api.readLock();
lock.packages = lock.packages or {};
if lock.packages[pkg] and not installOpts.force then
return false, "package already installed, use 'ccpm reinstall " .. pkg .. "' instead.";
end
local ordered, err = api.resolve(pkg);
if not ordered then return false, err; end
for _, item in ipairs(ordered) do
local isTarget = item.name == pkg;
local alreadyInstalled = lock.packages[item.name] ~= nil;
if alreadyInstalled and not (isTarget and installOpts.force) then
log('skip ' .. item.name .. ' (already installed)');
else
log('install ' .. item.name .. ' v' .. tostring(item.desc.version or '?'));
for _, filePath in ipairs(item.desc.files or {}) do
log(' ' .. filePath);
local ok, derr = api.downloadFile(item.registry, filePath);
if not ok then return false, derr; end
end
lock.packages[item.name] = {
version = item.desc.version,
registry = item.registry.name,
files = item.desc.files or {},
dependencies = item.desc.dependencies or {},
autostart = item.desc.autostart or {},
};
end
end
api.writeLock(lock);
writeOsState(lock);
return true, lock.packages[pkg];
end
function api.upgrade(upgradeOpts)
upgradeOpts = upgradeOpts or {};
local log = upgradeOpts.log or function() end;
local lock = api.readLock();
local cache = api.readCache();
if not cache.packages or not next(cache.packages) then
return false, "package cache is empty, run 'ccpm update' first.";
end
local names = {};
for name, entry in pairs(lock.packages or {}) do
local cached = cache.packages[name];
if cached and compareVersions(entry.version, cached.version) < 0 then
names[#names + 1] = name;
end
end
table.sort(names);
for _, name in ipairs(names) do
log('upgrade ' .. name);
local ok, err = api.install(name, { force = true, log = log });
if not ok then return false, err; end
end
return true, names;
end
-- uninstallOpts: { force = bool, log = function(msg) }
function api.uninstall(pkg, uninstallOpts)
uninstallOpts = uninstallOpts or {};
local log = uninstallOpts.log or function() end;
local lock = api.readLock();
lock.packages = lock.packages or {};
local entry = lock.packages[pkg];
if not entry then
return false, 'package not installed: ' .. pkg;
end
local dependents = {};
for name, e in pairs(lock.packages) do
if name ~= pkg then
for _, dep in ipairs(e.dependencies or {}) do
if dep == pkg then dependents[#dependents + 1] = name; end
end
end
end
if #dependents > 0 and not uninstallOpts.force then
table.sort(dependents);
return false, 'cannot uninstall ' .. pkg .. ': required by ' .. table.concat(dependents, ', ');
end
for _, filePath in ipairs(entry.files or {}) do
log('remove ' .. filePath);
fs.delete(installTarget(filePath));
end
lock.packages[pkg] = nil;
api.writeLock(lock);
writeOsState(lock);
return true;
end
return api;
end
return createCcpm;

View File

@ -1,122 +0,0 @@
local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
local function base64encode(s)
local pad = (3 - #s % 3) % 3;
s = s .. string.rep('\0', pad);
local r = {};
for i = 1, #s, 3 do
local a, b, c = s:byte(i), s:byte(i + 1), s:byte(i + 2);
local n = a * 65536 + b * 256 + c;
r[#r + 1] = B64:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)
.. B64:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)
.. B64:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1)
.. B64:sub(n % 64 + 1, n % 64 + 1);
end
local result = table.concat(r);
if pad > 0 then
result = result:sub(1, #result - pad) .. string.rep('=', pad);
end
return result;
end
local function trimTrailingSlash(s)
return (s:gsub('/+$', ''));
end
local function urlEncode(s)
return (tostring(s):gsub('[^%w%-_%.~]', function(c)
return string.format('%%%02X', string.byte(c));
end));
end
local function isBlank(s)
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
end
local function queryString(params)
local parts = {};
for _, item in ipairs(params) do
if not isBlank(item[2]) then
parts[#parts + 1] = urlEncode(item[1]) .. '=' .. urlEncode(item[2]);
end
end
if #parts == 0 then return ''; end
return '?' .. table.concat(parts, '&');
end
local function readAllAndClose(response)
local body = response.readAll();
response.close();
return body;
end
local function statusCode(response)
if response.getResponseCode then
return response.getResponseCode();
end
return nil;
end
local function createHttp(opts)
opts = opts or {};
local httpLib = opts.http or http;
local textutilsLib = opts.textutils or textutils;
local api = {
base64encode = base64encode,
trimTrailingSlash = trimTrailingSlash,
urlEncode = urlEncode,
queryString = queryString,
};
function api.basicAuth(username, password)
return 'Basic ' .. base64encode(tostring(username or '') .. ':' .. tostring(password or ''));
end
function api.jsonHeaders(options)
options = options or {};
local headers = {
['Content-Type'] = 'application/json',
['Accept'] = 'application/json',
};
if options.password and options.password ~= '' then
headers['Authorization'] = api.basicAuth(options.username, options.password);
end
return headers;
end
function api.call(method, request)
local ok, response, httpErr, errorResponse = pcall(httpLib[method], request);
if not ok then
return nil, 'http ' .. method .. ' threw: ' .. tostring(response);
end
response = response or errorResponse;
if not response then
return nil, 'serveur injoignable: ' .. tostring(httpErr or 'unknown error');
end
local code = statusCode(response);
local body = readAllAndClose(response);
return body, code;
end
function api.getJson(cfg, path)
return api.call('get', {
url = cfg.url .. path,
headers = api.jsonHeaders(cfg),
timeout = cfg.timeoutSeconds,
});
end
function api.postJson(cfg, path, payload)
return api.call('post', {
url = cfg.url .. path,
body = textutilsLib.serializeJSON(payload),
headers = api.jsonHeaders(cfg),
timeout = cfg.timeoutSeconds,
});
end
return api;
end
return createHttp;

View File

@ -1,140 +0,0 @@
-- WebSocket transport for the opencode bridge proxy.
--
-- Implements the same client surface as libhttp (getJson/postJson plus the
-- trimTrailingSlash/queryString helpers libai calls), but performs each call as
-- a blocking websocket round-trip to the mcp-bridge opencode proxy instead of a
-- direct http.get/post. ComputerCraft's http API caps requests at ~60s; a
-- websocket round-trip has no such cap, so the bridge can run a synchronous
-- opencode request server-side for as long as it needs.
local DEFAULT_RECEIVE_TIMEOUT_SECONDS = 600;
local function isBlank(s)
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
end
local function trimTrailingSlash(s)
return (s:gsub('/+$', ''));
end
local function urlEncode(s)
return (tostring(s):gsub('[^%w%-_%.~]', function(c)
return string.format('%%%02X', string.byte(c));
end));
end
local function queryString(params)
local parts = {};
for _, item in ipairs(params) do
if not isBlank(item[2]) then
parts[#parts + 1] = urlEncode(item[1]) .. '=' .. urlEncode(item[2]);
end
end
if #parts == 0 then return ''; end
return '?' .. table.concat(parts, '&');
end
local function createHttpWs(opts)
opts = opts or {};
local httpLib = opts.http or http;
local textutilsLib = opts.textutils or textutils;
local bridgeUrl = opts.bridgeUrl;
local receiveTimeout = tonumber(opts.receiveTimeout) or DEFAULT_RECEIVE_TIMEOUT_SECONDS;
local api = {
trimTrailingSlash = trimTrailingSlash,
urlEncode = urlEncode,
queryString = queryString,
};
local activeWs = nil;
local function ensureSocket()
if activeWs then return activeWs, nil; end
if isBlank(bridgeUrl) then
return nil, 'missing opencc.bridge_url';
end
local ws, err = httpLib.websocket(bridgeUrl);
if not ws then
return nil, 'bridge unreachable: ' .. tostring(err);
end
activeWs = ws;
return ws, nil;
end
local function closeSocket()
if activeWs then
pcall(function() activeWs.close(); end);
activeWs = nil;
end
end
local idCounter = 0;
local function nextId()
idCounter = idCounter + 1;
local stamp = os.epoch and os.epoch('utc') or os.clock();
return 'req_' .. tostring(stamp) .. '_' .. tostring(idCounter) .. '_' .. tostring(math.random(100000, 999999));
end
local function trySend(ws, text)
return pcall(function() ws.send(text); end);
end
local function roundtrip(method, path, payload)
local ws, err = ensureSocket();
if not ws then return nil, err; end
local id = nextId();
local frame = { type = 'http', id = id, method = method, path = path };
if payload ~= nil then
frame.body = textutilsLib.serializeJSON(payload);
end
local text = textutilsLib.serializeJSON(frame);
if not trySend(ws, text) then
-- Socket went away; drop it and try one fresh reconnect.
closeSocket();
ws, err = ensureSocket();
if not ws then return nil, err; end
if not trySend(ws, text) then
closeSocket();
return nil, 'bridge send failed';
end
end
while true do
local ok, message = pcall(function() return ws.receive(receiveTimeout); end);
if not ok then
closeSocket();
return nil, 'bridge connection closed';
end
if message == nil then
return nil, 'timeout waiting for bridge response';
end
local decoded = textutilsLib.unserializeJSON(message);
if type(decoded) == 'table' and decoded.type == 'http-response' and decoded.id == id then
if decoded.status == nil or decoded.status == 0 then
return nil, decoded.error or 'bridge error';
end
return decoded.body, decoded.status;
end
-- Ignore frames that don't correlate (stale/other ids) and keep waiting.
end
end
function api.getJson(_cfg, path)
return roundtrip('GET', path, nil);
end
function api.postJson(_cfg, path, payload)
return roundtrip('POST', path, payload);
end
function api.close()
closeSocket();
end
return api;
end
return createHttpWs;

View File

@ -1,310 +0,0 @@
local function defaultOs()
return os;
end
local function formatLabel(label)
if type(label) ~= 'string' or label == '' then
return 'null';
end
return label;
end
local function appendOutput(output, value)
output[#output + 1] = tostring(value);
end
local function serializeReturn(value)
local valueType = type(value);
if valueType == 'nil' then
return { type = 'nil' };
end
if valueType == 'string' or valueType == 'number' or valueType == 'boolean' then
return { type = valueType, value = value };
end
local ok, serialized = pcall(textutils.serialize, value);
if ok then
return { type = valueType, repr = serialized };
end
return { type = valueType, repr = tostring(value) };
end
local function createExecEnv(output)
local env = setmetatable({}, { __index = _G });
env.write = function(value)
appendOutput(output, value);
end;
env.print = function(...)
local values = table.pack(...);
for i = 1, values.n do
if i > 1 then
appendOutput(output, '\t');
end
appendOutput(output, values[i]);
end
appendOutput(output, '\n');
end;
return env;
end
local function createMcpComputer()
local api = {};
api.formatLabel = formatLabel;
function api.formatPong(osLike)
osLike = osLike or defaultOs();
return 'pong from ' .. tostring(osLike.getComputerID())
.. ' (Label: ' .. formatLabel(osLike.getComputerLabel()) .. ')';
end
function api.parseArgs(args)
args = args or {};
local count = args.n or #args;
local url = nil;
if count == 0 then
return nil, 'missing websocket URL';
end
local i = 1;
while i <= count do
local arg = args[i];
if arg == '-url' then
if not args[i + 1] or args[i + 1] == '' then
return nil, 'missing value for -url';
end
url = args[i + 1];
i = i + 1;
elseif string.sub(tostring(arg), 1, 1) == '-' then
return nil, 'unknown option: ' .. tostring(arg);
elseif not url then
url = arg;
else
return nil, 'unexpected argument: ' .. tostring(arg);
end
i = i + 1;
end
if not url or url == '' then
return nil, 'missing websocket URL';
end
return { url = url };
end
function api.hello(osLike)
osLike = osLike or defaultOs();
return {
type = 'hello',
computerId = osLike.getComputerID(),
computerLabel = osLike.getComputerLabel(),
};
end
function api.executeLua(code)
local output = {};
if type(code) ~= 'string' or code == '' then
return { ok = false, error = 'code must be a non-empty string', output = '' };
end
local fn, loadErr = load(code, 'mcp-exec', 't', createExecEnv(output));
if not fn then
return { ok = false, error = tostring(loadErr), output = table.concat(output) };
end
local values = table.pack(pcall(fn));
if not values[1] then
return { ok = false, error = tostring(values[2]), output = table.concat(output) };
end
local returns = {};
for i = 2, values.n do
returns[#returns + 1] = serializeReturn(values[i]);
end
return { ok = true, returns = returns, output = table.concat(output) };
end
function api.writeFile(path, content, fsLike)
fsLike = fsLike or fs;
if type(path) ~= 'string' or path == '' then
return { ok = false, error = 'path must be a non-empty string' };
end
if type(content) ~= 'string' then
return { ok = false, error = 'content must be a string' };
end
local handle, openErr = fsLike.open(path, 'w');
if not handle then
return { ok = false, error = tostring(openErr or 'failed to open file') };
end
local ok, writeErr = pcall(function()
handle.write(content);
end);
local closeOk, closeErr = pcall(function()
handle.close();
end);
if not ok then
return { ok = false, error = tostring(writeErr) };
end
if not closeOk then
return { ok = false, error = tostring(closeErr) };
end
return { ok = true, path = path, bytes = string.len(content) };
end
function api.handleRequest(request, osLike)
if type(request) ~= 'table' or request.type ~= 'request' or type(request.id) ~= 'string' then
return nil;
end
if request.method == 'ping' then
return {
type = 'response',
id = request.id,
ok = true,
result = api.formatPong(osLike),
};
end
if request.method == 'exec-lua' then
local params = request.params;
local result = api.executeLua(type(params) == 'table' and params.code or nil);
return {
type = 'response',
id = request.id,
ok = result.ok,
result = {
returns = result.returns or {},
output = result.output or '',
},
error = result.error,
};
end
if request.method == 'write-file' then
local params = request.params;
local result = api.writeFile(
type(params) == 'table' and params.path or nil,
type(params) == 'table' and params.content or nil
);
return {
type = 'response',
id = request.id,
ok = result.ok,
result = {
path = result.path,
bytes = result.bytes,
},
error = result.error,
};
end
return {
type = 'response',
id = request.id,
ok = false,
error = 'unknown method',
};
end
-- Resolve the websocket URL from CLI args first, then fall back to the
-- 'mcp-computer.ws-url' setting when no argument is provided.
function api.resolveUrl(args, settingsLib)
args = args or {};
local count = args.n or #args;
if count > 0 then
return api.parseArgs(args);
end
local url = settingsLib and settingsLib.get('mcp-computer.ws-url');
if type(url) ~= 'string' or url == '' then
return nil, 'missing websocket URL (pass a URL or set mcp-computer.ws-url)';
end
return { url = url };
end
-- Decode a raw websocket frame and dispatch it. Non-request frames (e.g.
-- 'hello-ok') and malformed payloads produce no response.
function api.onMessage(content, osLike, sendFn, decode)
decode = decode or textutils.unserializeJSON;
local ok, frame = pcall(decode, content);
if not ok then
return;
end
local response = api.handleRequest(frame, osLike);
if response and sendFn then
sendFn(response);
end
end
-- Wire an event-driven MCP session onto an eventloop and connect. Registers
-- websocket handlers and returns immediately, so it never blocks the boot
-- sequence. Auto-reconnects on failure or close.
function api.startSession(opts)
opts = opts or {};
local el = assert(opts.eventloop, 'startSession requires an eventloop');
local url = assert(opts.url, 'startSession requires a url');
local osLike = opts.os or defaultOs();
local httpLike = opts.http or http;
local reconnectDelay = opts.reconnectDelay or 5;
local encode = opts.encode or textutils.serializeJSON;
local decode = opts.decode or textutils.unserializeJSON;
local activeWs = nil;
local function connect()
httpLike.websocketAsync(url);
end
local function scheduleReconnect()
activeWs = nil;
el.setTimeout(connect, reconnectDelay);
end
el.register('websocket_success', function(eventUrl, ws)
if eventUrl ~= url then return; end
activeWs = ws;
ws.send(encode(api.hello(osLike)));
end);
el.register('websocket_message', function(eventUrl, content)
if eventUrl ~= url then return; end
api.onMessage(content, osLike, function(response)
if activeWs then
activeWs.send(encode(response));
end
end, decode);
end);
el.register('websocket_failure', function(eventUrl)
if eventUrl ~= url then return; end
scheduleReconnect();
end);
el.register('websocket_closed', function(eventUrl)
if eventUrl ~= url then return; end
scheduleReconnect();
end);
connect();
return {
isConnected = function() return activeWs ~= nil; end,
};
end
return api;
end
return createMcpComputer;

View File

@ -1,49 +0,0 @@
-- TrapOS router state machine: (label -> id) map with TTL.
--
-- Pure logic so it can be unit-tested without a modem or eventloop.
-- The wire glue lives in /programs/router.lua.
local DEFAULT_TTL = 90;
local function createRouter(options)
options = options or {};
local ttl = options.ttl or DEFAULT_TTL;
local nowFn = options.now or os.clock;
local labelMap = {};
local function isExpired(entry)
return entry.expiresAt < nowFn();
end
local function register(label, id)
local existing = labelMap[label];
if existing and existing.id ~= id and not isExpired(existing) then
return false, 'duplicate label';
end
labelMap[label] = { id = id, expiresAt = nowFn() + ttl };
return true;
end
local function resolve(label)
local entry = labelMap[label];
if not entry then return nil end
if isExpired(entry) then
labelMap[label] = nil;
return nil;
end
return entry.id;
end
local function forget(label)
labelMap[label] = nil;
end
return {
register = register,
resolve = resolve,
forget = forget,
ttl = ttl,
};
end
return createRouter;

View File

@ -1,141 +0,0 @@
local DEFAULT_TIMEOUT_SECONDS = 3
local function createLibTest(args)
local api = {}
local tests = {}
local pretty = false
local verbose = false
local reportPath = nil
local printMarker = true
local timeoutSeconds = DEFAULT_TIMEOUT_SECONDS
local i = 1
while i <= #(args or {}) do
local arg = args[i]
if arg == "--verbose" then
pretty = true
verbose = true
elseif arg == "--pretty" then
pretty = true
elseif arg == "--report" then
reportPath = args[i + 1]
i = i + 1
elseif arg == "--no-marker" then
printMarker = false
elseif arg == "--timeout" then
timeoutSeconds = tonumber(args[i + 1])
i = i + 1
elseif arg == "--no-timeout" then
timeoutSeconds = nil
end
i = i + 1
end
local function fail(message)
error(message, 2)
end
local function writeReport(line)
if not reportPath then
return
end
local file = fs.open(reportPath, "a")
if file then
file.writeLine(line)
file.close()
end
end
function api.log(message)
if verbose then
writeReport("LOG " .. tostring(message))
end
end
function api.test(name, fn)
assert(type(name) == "string", "bad argument #1 (string expected)")
assert(type(fn) == "function", "bad argument #2 (function expected)")
tests[#tests + 1] = { name = name, fn = fn }
end
function api.assertEquals(actual, expected, message)
if actual ~= expected then
fail(
(message or "assertEquals failed")
.. ": expected "
.. tostring(expected)
.. ", got "
.. tostring(actual)
)
end
end
function api.assertTrue(value, message)
if not value then
fail(message or "assertTrue failed")
end
end
function api.assertErrors(fn, expected)
assert(type(fn) == "function", "bad argument #1 (function expected)")
local ok, err = pcall(fn)
if ok then
fail("expected error")
end
if expected and not string.find(tostring(err), expected, 1, true) then
fail("expected error containing " .. expected .. ", got " .. tostring(err))
end
end
local function runCase(fn)
if not timeoutSeconds then
return pcall(fn)
end
local ok, err
local finished = false
local function runner()
ok, err = pcall(fn)
finished = true
end
local function timer()
sleep(timeoutSeconds)
end
parallel.waitForAny(runner, timer)
if not finished then
return false, "libtest timeout after " .. timeoutSeconds .. "s", true
end
return ok, err
end
function api.run()
for _, t in ipairs(tests) do
api.log("RUN " .. t.name)
local ok, err, timedOut = runCase(t.fn)
if not ok then
if timedOut then
api.log("TIMEOUT " .. t.name .. " after " .. timeoutSeconds .. "s (libtest)")
end
writeReport("KO " .. t.name .. ": " .. tostring(err))
if not pretty then
print("FAIL " .. t.name .. ": " .. tostring(err))
end
error(err, 0)
end
writeReport("OK " .. t.name)
end
if printMarker then
print("__TRAPOS_TEST_OK__")
end
return true
end
return api
end
return createLibTest

View File

@ -1,178 +0,0 @@
local DEFAULT_THROTTLE_SECONDS = 5;
local DEFAULT_MAX_REPLY_CHARS = 160;
local DEFAULT_PREFIX = 'TrapGPT';
local DEFAULT_SESSION_SETTING_KEY = 'trapgpt.opencc.session_id';
local SILENCE = 'SILENCE';
local function nowSeconds()
if os.epoch then
return os.epoch('utc') / 1000;
end
return os.clock();
end
local function resolveNumber(value, defaultValue)
local n = tonumber(value);
if not n or n < 0 then return defaultValue; end
return n;
end
local function trim(s)
return tostring(s or ''):gsub('^%s+', ''):gsub('%s+$', '');
end
local function truncate(s, maxChars)
s = trim(s);
if #s <= maxChars then return s; end
if maxChars <= 3 then return string.sub(s, 1, maxChars); end
return string.sub(s, 1, maxChars - 3) .. '...';
end
local function formatChatLine(message)
local at = message.at and ('@' .. tostring(math.floor(message.at)) .. ' ') or '';
return at .. tostring(message.username or '?') .. ': ' .. tostring(message.text or '');
end
local function buildPrompt(messages, firstBatch, maxReplyChars)
local lines = {
'Tu es TrapGPT dans le chat Minecraft.',
'Reponds seulement si utile.',
'Reponse tres concise: une phrase courte, maximum ' .. tostring(maxReplyChars) .. ' caracteres.',
'Pas de markdown. Ne repete pas l historique.',
'Si aucune reponse utile, reponds exactement: ' .. SILENCE,
};
if firstBatch then
lines[#lines + 1] = 'Contexte initial: voici les premiers messages recus.';
end
lines[#lines + 1] = '';
lines[#lines + 1] = 'Nouveaux messages chat depuis le dernier envoi:';
for _, message in ipairs(messages) do
lines[#lines + 1] = formatChatLine(message);
end
return table.concat(lines, '\n');
end
local function createTrapGpt(opts)
opts = opts or {};
local settingsLib = opts.settings or settings;
local nowFunc = opts.now or nowSeconds;
local sleepFunc = opts.sleep or sleep;
local ai = opts.ai or require('/apis/libai')();
local chatBox = opts.chatBox;
local log = opts.log or print;
local api = {};
local history = {};
local sentIndex = 0;
local firstBatch = true;
local lastSendAt = 0;
local active = false;
local stopped = false;
local function throttleSeconds()
return resolveNumber(settingsLib.get('trapgpt.throttle_seconds'), DEFAULT_THROTTLE_SECONDS);
end
local function maxReplyChars()
return math.max(1, resolveNumber(settingsLib.get('trapgpt.max_reply_chars'), DEFAULT_MAX_REPLY_CHARS));
end
local function prefix()
local value = settingsLib.get('trapgpt.prefix');
if type(value) ~= 'string' or value == '' then return DEFAULT_PREFIX; end
return value;
end
local function queuedMessages()
local messages = {};
for i = sentIndex + 1, #history do
messages[#messages + 1] = history[i];
end
return messages;
end
local function shouldIgnore(username, text, isHidden)
if isHidden then return true; end
if type(text) ~= 'string' or trim(text) == '' then return true; end
if type(username) == 'string' and username == prefix() then return true; end
return false;
end
function api.onChat(username, message, uuid, isHidden, messageUtf8)
local text = messageUtf8 or message;
if shouldIgnore(username, text, isHidden) then return false; end
history[#history + 1] = {
username = username,
text = text,
uuid = uuid,
at = nowFunc(),
};
return true;
end
function api.pendingCount()
return #history - sentIndex;
end
function api.history()
return history;
end
function api.buildPrompt(messages)
return buildPrompt(messages, firstBatch, maxReplyChars());
end
function api.processOnce()
if active or api.pendingCount() <= 0 then return false; end
local waitSeconds = throttleSeconds() - (nowFunc() - lastSendAt);
if waitSeconds > 0 then sleepFunc(waitSeconds); end
local startIndex = sentIndex + 1;
local messages = queuedMessages();
if #messages == 0 then return false; end
active = true;
local ok, result = ai.ask(buildPrompt(messages, firstBatch, maxReplyChars()), {
sessionSettingKey = DEFAULT_SESSION_SETTING_KEY,
sessionTitle = 'trapgpt',
});
active = false;
lastSendAt = nowFunc();
if not ok then
log('trapgpt ai error: ' .. tostring(result));
return false;
end
sentIndex = startIndex + #messages - 1;
firstBatch = false;
local reply = truncate(result.reply, maxReplyChars());
if reply == '' or reply == SILENCE then return true; end
if chatBox then
local sent, err = chatBox.sendMessage(reply, prefix());
if not sent then log('trapgpt chat error: ' .. tostring(err)); end
end
return true, reply;
end
function api.stop()
stopped = true;
end
function api.run()
while not stopped do
if api.pendingCount() > 0 then
api.processOnce();
else
sleepFunc(0.25);
end
end
end
return api;
end
return createTrapGpt;

View File

@ -1,718 +0,0 @@
local utf8 = rawget(_G, 'utf8');
local NODE_TEXT = 'text';
local NODE_BUTTON = 'button';
local NODE_BOX = 'box';
local NODE_FRAGMENT = 'fragment';
local DEFAULT_COLOR = colors.white;
local DEFAULT_BG_COLOR = colors.black;
local DISABLED_COLOR = colors.gray;
local function isArray(value)
if type(value) ~= 'table' then
return false;
end
if value.kind then
return false;
end
local count = 0;
for key, _ in pairs(value) do
if type(key) ~= 'number' then
return false;
end
if key > count then
count = key;
end
end
return count > 0;
end
local function firstColor(value, fallback)
if type(value) == 'table' then
return value[1] or fallback;
end
return value or fallback;
end
local function firstFunction(value)
if type(value) == 'function' then
return value;
end
if type(value) == 'table' and type(value[1]) == 'function' then
return value[1];
end
return nil;
end
local function shallowCopy(value)
local result = {};
if type(value) ~= 'table' then
return result;
end
for key, item in pairs(value) do
result[key] = item;
end
return result;
end
local function utf8Len(value)
value = tostring(value or '');
local ok, iter, state, init = pcall(utf8.codes, value);
if not ok then
return string.len(value);
end
local count = 0;
for _ in iter, state, init do
count = count + 1;
end
return count;
end
local function utf8Sub(value, startIndex, endIndex)
value = tostring(value or '');
startIndex = math.max(1, math.floor(startIndex or 1));
endIndex = endIndex == nil and math.huge or math.floor(endIndex);
if endIndex < startIndex then
return '';
end
local ok, iter, state, init = pcall(utf8.codes, value);
if not ok then
return string.sub(value, startIndex, endIndex);
end
local byteStart;
local byteEnd;
local index = 0;
for pos, _ in iter, state, init do
index = index + 1;
if index == startIndex then
byteStart = pos;
end
if index == endIndex + 1 then
byteEnd = pos - 1;
break;
end
end
if not byteStart then
return '';
end
if not byteEnd then
byteEnd = string.len(value);
end
return string.sub(value, byteStart, byteEnd);
end
local function makeNode(kind, props)
props = props or {};
return {
kind = kind,
props = props,
};
end
local function makeText(value, props)
if type(value) == 'table' and props == nil then
props = shallowCopy(value);
else
props = shallowCopy(props);
props.text = value;
end
return makeNode(NODE_TEXT, props);
end
local function makeButton(value, props)
if type(value) == 'table' and props == nil then
props = shallowCopy(value);
else
props = shallowCopy(props);
props.text = value;
end
return makeNode(NODE_BUTTON, props);
end
local function makeBox(props)
return makeNode(NODE_BOX, shallowCopy(props));
end
local function makeList(props)
props = shallowCopy(props);
props.direction = 'column';
return makeNode(NODE_BOX, props);
end
local function makeFragment(children)
return makeNode(NODE_FRAGMENT, { children = children or {} });
end
local function lineText(value, width)
value = tostring(value or '');
if width <= 0 then
return '';
end
local valueLength = utf8Len(value);
if valueLength > width then
return utf8Sub(value, 1, width);
end
return value .. string.rep(' ', width - valueLength);
end
local function shrinkRect(rect, amount)
amount = amount or 0;
return {
x = rect.x + amount,
y = rect.y + amount,
w = rect.w - amount * 2,
h = rect.h - amount * 2,
};
end
local function isInside(rect, x, y)
return x >= rect.x and x < rect.x + rect.w and y >= rect.y and y < rect.y + rect.h;
end
local function fillRect(rect, bgColor)
if rect.w <= 0 or rect.h <= 0 then
return;
end
term.setBackgroundColor(bgColor);
for y = rect.y, rect.y + rect.h - 1 do
term.setCursorPos(rect.x, y);
term.write(string.rep(' ', rect.w));
end
end
local function writeAt(x, y, text, width)
if width <= 0 then
return;
end
term.setCursorPos(x, y);
term.write(lineText(text, width));
end
local function drawBorder(rect, props)
if rect.w <= 1 or rect.h <= 1 then
return;
end
local color = firstColor(props.color, DEFAULT_COLOR);
local bgColor = firstColor(props.bgColor, DEFAULT_BG_COLOR);
local top = '+' .. string.rep('-', rect.w - 2) .. '+';
local bottom = top;
local title = props.title;
if title then
local titleText = ' ' .. tostring(title) .. ' ';
local titleLength = utf8Len(titleText);
if titleLength < rect.w - 1 then
top = '+' .. titleText .. string.rep('-', rect.w - 2 - titleLength) .. '+';
end
end
term.setTextColor(color);
term.setBackgroundColor(bgColor);
writeAt(rect.x, rect.y, top, rect.w);
writeAt(rect.x, rect.y + rect.h - 1, bottom, rect.w);
for y = rect.y + 1, rect.y + rect.h - 2 do
term.setCursorPos(rect.x, y);
term.write('|');
term.setCursorPos(rect.x + rect.w - 1, y);
term.write('|');
end
end
local function normalizeChildren(children)
local result = {};
if children == nil then
return result;
end
if type(children) ~= 'table' or children.kind then
return { children };
end
if isArray(children) then
for _, child in ipairs(children) do
table.insert(result, child);
end
else
table.insert(result, children);
end
return result;
end
local resolveNode;
local function resolveChildren(children)
local result = {};
for _, child in ipairs(normalizeChildren(children)) do
local node = resolveNode(child);
if node then
table.insert(result, node);
end
end
return result;
end
function resolveNode(input)
if input == nil then
return nil;
end
if type(input) == 'function' then
return resolveNode(input());
end
if type(input) == 'string' or type(input) == 'number' then
return makeText(tostring(input));
end
if type(input) ~= 'table' then
return makeText(tostring(input));
end
if input.kind then
local props = shallowCopy(input.props);
props.children = resolveChildren(props.children);
return makeNode(input.kind, props);
end
if isArray(input) then
return makeFragment(resolveChildren(input));
end
return makeFragment(resolveChildren(input.children));
end
local function buttonLabel(props)
return '[ ' .. tostring(props.text or props.label or '') .. ' ]';
end
local function flexOf(child)
local f = (child.props or {}).flex;
if f == true then
return 1;
end
if type(f) == 'number' and f > 0 then
return f;
end
return nil;
end
local naturalSize;
local function childrenNaturalSize(children, direction, gap)
local width = 0;
local height = 0;
local mainCount = 0;
for _, child in ipairs(children) do
local hasFlex = flexOf(child) ~= nil;
local childWidth, childHeight = naturalSize(child);
if direction == 'row' then
height = math.max(height, childHeight);
if not hasFlex then
if mainCount > 0 then
width = width + gap;
end
width = width + childWidth;
mainCount = mainCount + 1;
end
else
width = math.max(width, childWidth);
if not hasFlex then
if mainCount > 0 then
height = height + gap;
end
height = height + childHeight;
mainCount = mainCount + 1;
end
end
end
return width, height;
end
function naturalSize(node)
if not node then
return 0, 0;
end
local props = node.props or {};
if node.kind == NODE_TEXT then
return props.width or utf8Len(tostring(props.text or '')), props.height or 1;
end
if node.kind == NODE_BUTTON then
return props.width or utf8Len(buttonLabel(props)), props.height or 1;
end
local direction = props.direction or 'column';
local gap = props.gap or 0;
local padding = props.padding or 0;
local border = props.border and 2 or 0;
local childWidth, childHeight = childrenNaturalSize(props.children or {}, direction, gap);
if node.kind == NODE_FRAGMENT then
return childWidth, childHeight;
end
return props.width or childWidth + padding * 2 + border, props.height or childHeight + padding * 2 + border;
end
local function applyNodeColors(props)
term.setTextColor(firstColor(props.color, DEFAULT_COLOR));
term.setBackgroundColor(firstColor(props.bgColor, DEFAULT_BG_COLOR));
end
local function childAxisSize(child, direction)
local props = child.props or {};
local naturalWidth, naturalHeight = naturalSize(child);
if direction == 'row' then
return props.width or naturalWidth;
end
return props.height or naturalHeight;
end
local function layoutChildren(children, rect, direction, gap)
local fixedSize = 0;
local flexSize = 0;
local axisSize = direction == 'row' and rect.w or rect.h;
local layouts = {};
local lastFlexIndex = nil;
local flexes = {};
for index, child in ipairs(children) do
local flex = flexOf(child);
flexes[index] = flex;
if flex then
flexSize = flexSize + flex;
lastFlexIndex = index;
else
fixedSize = fixedSize + childAxisSize(child, direction);
end
end
local remaining = axisSize - fixedSize - math.max(#children - 1, 0) * gap;
if remaining < 0 then
remaining = 0;
end
local cursor = direction == 'row' and rect.x or rect.y;
local usedFlexSize = 0;
for index, child in ipairs(children) do
local props = child.props or {};
local flex = flexes[index];
local size;
if flex then
if index == lastFlexIndex then
size = remaining - usedFlexSize;
else
size = math.floor(remaining * flex / flexSize);
usedFlexSize = usedFlexSize + size;
end
else
size = childAxisSize(child, direction);
end
if size < 0 then
size = 0;
end
if direction == 'row' then
table.insert(layouts, { node = child, rect = { x = cursor, y = rect.y, w = size, h = props.height or rect.h } });
else
table.insert(layouts, { node = child, rect = { x = rect.x, y = cursor, w = props.width or rect.w, h = size } });
end
cursor = cursor + size + gap;
end
return layouts;
end
local function createTui(eventloop)
assert(type(eventloop) == 'table', 'bad argument #1 (eventloop expected)');
assert(type(eventloop.register) == 'function', 'bad argument #1 (eventloop expected)');
assert(type(eventloop.startLoop) == 'function', 'bad argument #1 (eventloop expected)');
local api = {};
local root = nil;
local clickables = {};
local finalEvent = nil;
local previousState = nil;
local function createErrorEvent(reason)
if type(reason) == 'table' then
return {
type = 'error',
error = {
name = reason.name or 'libtui error',
reason = reason.reason or tostring(reason),
},
};
end
return {
type = 'error',
error = {
name = 'libtui error',
reason = tostring(reason),
},
};
end
local function stopWith(event)
finalEvent = finalEvent or event;
if eventloop.isRunningLoop and eventloop.isRunningLoop() then
eventloop.stopLoop();
end
end
local renderNode;
local function addClickable(node, rect)
local props = node.props or {};
local handler = firstFunction(props.onClick);
if not handler or props.disabled then
return;
end
table.insert(clickables, {
rect = rect,
node = node,
handler = handler,
});
end
local function drawTextNode(node, rect)
local props = node.props or {};
if rect.w <= 0 or rect.h <= 0 then
return;
end
fillRect(rect, firstColor(props.bgColor, DEFAULT_BG_COLOR));
applyNodeColors(props);
writeAt(rect.x, rect.y, props.text or '', rect.w);
addClickable(node, rect);
end
local function drawButtonNode(node, rect)
local props = node.props or {};
if rect.w <= 0 or rect.h <= 0 then
return;
end
fillRect(rect, firstColor(props.bgColor, DEFAULT_BG_COLOR));
term.setTextColor(props.disabled and DISABLED_COLOR or firstColor(props.color, DEFAULT_COLOR));
term.setBackgroundColor(firstColor(props.bgColor, DEFAULT_BG_COLOR));
writeAt(rect.x, rect.y, buttonLabel(props), rect.w);
addClickable(node, rect);
end
local function drawBoxNode(node, rect)
local props = node.props or {};
local children = props.children or {};
local contentRect = rect;
local padding = props.padding or 0;
local direction = props.direction or 'column';
local gap = props.gap or 0;
if rect.w <= 0 or rect.h <= 0 then
return;
end
fillRect(rect, firstColor(props.bgColor, DEFAULT_BG_COLOR));
addClickable(node, rect);
if props.border then
drawBorder(rect, props);
contentRect = shrinkRect(contentRect, 1);
end
if padding > 0 then
contentRect = shrinkRect(contentRect, padding);
end
if contentRect.w <= 0 or contentRect.h <= 0 then
return;
end
for _, item in ipairs(layoutChildren(children, contentRect, direction, gap)) do
renderNode(item.node, item.rect);
end
end
function renderNode(node, rect)
if node.kind == NODE_TEXT then
drawTextNode(node, rect);
elseif node.kind == NODE_BUTTON then
drawButtonNode(node, rect);
else
drawBoxNode(node, rect);
end
end
local function redraw()
local width, height = term.getSize();
clickables = {};
term.setCursorBlink(false);
term.setTextColor(DEFAULT_COLOR);
term.setBackgroundColor(DEFAULT_BG_COLOR);
term.clear();
renderNode(resolveNode(root), { x = 1, y = 1, w = width, h = height });
end
local function safeRedraw()
local ok, reason = pcall(redraw);
if not ok then
stopWith(createErrorEvent(reason));
end
end
local function safeClick(handler, event)
local ok, reason = pcall(handler, api, event);
if not ok then
stopWith(createErrorEvent(reason));
end
end
function api.exitUI(reason)
stopWith({ type = 'exitUI', reason = reason });
end
function api.rerender()
if root == nil then
return;
end
safeRedraw();
end
local function restoreTerminal()
if not previousState then
return;
end
term.setTextColor(previousState.color);
term.setBackgroundColor(previousState.bgColor);
term.clear();
term.setCursorPos(previousState.cursorX, previousState.cursorY);
term.setCursorBlink(previousState.cursorBlink);
previousState = nil;
end
function api.render(nextRoot)
finalEvent = nil;
root = nextRoot;
previousState = {
color = term.getTextColor(),
bgColor = term.getBackgroundColor(),
cursorX = 1,
cursorY = 1,
cursorBlink = false,
};
if term.getCursorPos then
previousState.cursorX, previousState.cursorY = term.getCursorPos();
end
if term.getCursorBlink then
previousState.cursorBlink = term.getCursorBlink();
end
eventloop.onStart(safeRedraw);
eventloop.onStop(restoreTerminal);
eventloop.register('mouse_click', function(button, x, y)
for index = #clickables, 1, -1 do
local item = clickables[index];
if isInside(item.rect, x, y) then
safeClick(item.handler, {
type = 'mouse_click',
button = button,
x = x,
y = y,
node = item.node,
});
return;
end
end
end);
eventloop.register('term_resize', function()
safeRedraw();
end);
eventloop.register('terminate', function()
finalEvent = finalEvent or { type = 'terminate' };
end);
local ok, reason = pcall(eventloop.startLoop);
if not ok then
pcall(restoreTerminal);
finalEvent = createErrorEvent(reason);
end
root = nil;
clickables = {};
return finalEvent or { type = 'exitUI' };
end
api.Text = makeText;
api.text = makeText;
api.Button = makeButton;
api.button = makeButton;
api.Box = makeBox;
api.box = makeBox;
api.List = makeList;
api.list = makeList;
api.Fragment = makeFragment;
api.eventloop = eventloop;
return api;
end
return createTui;

View File

@ -1,102 +0,0 @@
-- libversion: resolve a file's version from its owning package descriptor.
--
-- A factory: `local createVersion = require('/apis/libversion'); local v = createVersion();`
--
-- Resolution order for `api.forFile(path)`:
-- 1. `<stateDir>/ccpm.lock.json` — production lookup against installed packages.
-- 2. `<repoRoot>/packages/*/ccpm.json` — dev fallback (e.g. `just trapos`,
-- where the repo is mounted read-only at `/trapos` and ccpm has never run).
-- 3. `'?'` when no descriptor lists the file.
--
-- `api.forSelf()` returns the version of the currently running program by
-- delegating to `shell.getRunningProgram()`.
local DEFAULT_STATE_DIR = '/trapos';
local DEFAULT_REPO_ROOT = '/trapos';
local function normalizePath(path)
if type(path) ~= 'string' or path == '' then return nil; end
path = path:gsub('\\', '/');
path = path:gsub('//+', '/');
if path:sub(1, 1) ~= '/' then
path = '/' .. path;
end
return path;
end
local function readJsonFile(fsLib, path)
if not fsLib.exists(path) then return nil; end
local f = fsLib.open(path, 'r');
if not f then return nil; end
local data = f.readAll();
f.close();
if not data or data == '' then return nil; end
return textutils.unserializeJSON(data);
end
local function fileMatches(files, target)
if type(files) ~= 'table' then return false; end
for _, raw in ipairs(files) do
if normalizePath(raw) == target then
return true;
end
end
return false;
end
local function createVersion(opts)
opts = opts or {};
local fsLib = opts.fs or fs;
local shellLib = opts.shell or shell;
local stateDir = opts.stateDir or DEFAULT_STATE_DIR;
local repoRoot = opts.repoRoot or DEFAULT_REPO_ROOT;
local lockPath = stateDir .. '/ccpm.lock.json';
local packagesDir = repoRoot .. '/packages';
local api = {};
local function lookupInLock(target)
local lock = readJsonFile(fsLib, lockPath);
if not lock or type(lock.packages) ~= 'table' then return nil; end
for _, entry in pairs(lock.packages) do
if fileMatches(entry.files, target) then
return entry.version;
end
end
return nil;
end
local function lookupInRepo(target)
if not fsLib.isDir(packagesDir) then return nil; end
for _, name in ipairs(fsLib.list(packagesDir)) do
local descPath = packagesDir .. '/' .. name .. '/ccpm.json';
local desc = readJsonFile(fsLib, descPath);
if desc and fileMatches(desc.files, target) then
return desc.version;
end
end
return nil;
end
function api.forFile(path)
local target = normalizePath(path);
if not target then return '?'; end
local v = lookupInLock(target);
if v then return v; end
v = lookupInRepo(target);
if v then return v; end
return '?';
end
function api.forSelf()
if not shellLib or not shellLib.getRunningProgram then return '?'; end
local path = shellLib.getRunningProgram();
if not path or path == '' then return '?'; end
return api.forFile(path);
end
return api;
end
return createVersion;

View File

@ -1,212 +1,269 @@
-- TrapOS networking: service-name bus on a single channel.
--
-- Servers register handlers on the boot eventloop:
-- net.serve('ping', function(msg, reply) reply('pong') end)
-- net.listen('events.foo', function(msg, packet) ... end)
--
-- Clients (CLI programs) call or send without an eventloop:
-- local ok, res = net.call('ping', 'ping', { destId = 5, timeout = 0.5 })
-- net.send('events.foo', payload, { destId = 'alice' })
--
-- A router service (servers/router-server.lua) must run on exactly one machine.
-- It resolves label-addressed packets, stamps routerId, and rebroadcasts.
local _VERSION = '2.1.2';
local createEventLoop = require('/apis/eventloop');
local BUS_CHANNEL = 10;
local DEFAULT_TIMEOUT = 0.5;
local nextRequestSeq = 1;
local function newRequestId()
local id = tostring(os.getComputerID()) .. ':' .. tostring(nextRequestSeq) .. ':' .. tostring(os.clock());
nextRequestSeq = nextRequestSeq + 1;
return id;
end
local DEFAULT_TIMEOUT_WAIT_MESSAGE = 0.5; -- in seconds
local DEFAULT_ROUTING_CHANNEL = 10;
-- Utilitaire pour savoir si un packet nous est destiné.
-- le parametre 'packet' est une table avec les champs suivants:
-- - sourceId: l'id de la machine qui a envoyé le message
-- - destId: l'id du destinataire, si l'id est nil le message est routé a tout le monde
-- - routerId: l'id du routeur qui s'est occupé de transmettre le message
-- - message: le contenu du message (qui sera le plus souvent une table)
-- return un boolean
local function isPacketOk(packet)
if type(packet) ~= 'table' then return false end
if not packet.routerId or not packet.sourceId then return false end
if packet.destId == nil then return true end
if type(packet.destId) == 'number' and packet.destId == os.getComputerID() then return true end
if type(packet.destId) == 'string' and packet.destId == os.getComputerLabel() then return true end
return false
if type(packet) ~= "table" then
return false;
end
if not packet.routerId or not packet.sourceId then
return false;
end
-- if packet.sourceId == os.getComputerID() then
-- return false;
-- end
if packet.destId == nil then
return true;
end
if type(packet.destId) == 'number' and packet.destId == os.getComputerID() then
return true;
end
if type(packet.destId) == 'string' and packet.destId == os.getComputerLabel() then
return true;
end
return false;
end
local function createNetwork(el, modem, modemSide)
-- -- Example: implementation simple de ping
--
--
-- local createNet = require('apis/net');
-- net = createNet();
-- local net = createNet(nil, modem);
-- net.listenRequest(PING_CHANNEL, 'ping', function(message, reply)
-- if message == 'ping' then
-- reply('pong');
-- end
-- end)
--
local function createNetwork(el, modem, routingChannel, timeoutInSec)
el = el or createEventLoop();
modem = modem or peripheral.find('modem') or error('modem not found');
modem.open(BUS_CHANNEL);
modem = modem or peripheral.find("modem") or error("modem not found");
routingChannel = routingChannel or DEFAULT_ROUTING_CHANNEL;
timeoutInSec = timeoutInSec or DEFAULT_TIMEOUT_WAIT_MESSAGE;
local isRouter = false;
modemSide = modemSide or peripheral.getName(modem);
local function openChannel(chan)
return modem.open(chan);
end
local function buildPacket(service, kind, payload, destId, requestId)
return {
sourceId = os.getComputerID(),
sourceLabel = os.getComputerLabel(),
-- net.send function
local function sendRaw(channel, message, destId)
local sourceId = os.getComputerID()
local sourceLabel = os.getComputerLabel();
local routerId = nil;
if _G.isRouterEnabled then
routerId = sourceId
end
local packet = {
sourceId = sourceId,
sourceLabel = sourceLabel,
routerId = routerId,
destId = tonumber(destId) or destId,
service = service,
kind = kind,
requestId = requestId,
payload = payload,
routerId = isRouter and os.getComputerID() or nil,
};
end
message = message
}
local function transmit(packet)
local selfId = os.getComputerID();
local selfLabel = os.getComputerLabel();
local destIsSelfId = packet.destId == selfId;
local destIsSelfLabel = selfLabel ~= nil and packet.destId == selfLabel;
local destIsSelf = destIsSelfId or destIsSelfLabel;
local matchesSelf = packet.destId == nil or destIsSelf;
if matchesSelf then
local localPacket = {};
for k, v in pairs(packet) do localPacket[k] = v end
localPacket.routerId = localPacket.routerId or selfId;
os.queueEvent('modem_message', modemSide, BUS_CHANNEL, BUS_CHANNEL, localPacket, 0);
if packet.destId ~= nil and packet.destId == sourceId then
packet.routerId = packet.sourceId;
os.queueEvent('modem_message', peripheral.getName(modem), channel, channel, packet, 0);
return nil;
end
if not destIsSelf then
modem.transmit(BUS_CHANNEL, BUS_CHANNEL, packet);
if packet.destId == nil or packet.destId == sourceLabel then
os.queueEvent('modem_message', peripheral.getName(modem), channel, channel, packet, 0);
end
if packet.routerId then
return modem.transmit(channel, channel, packet);
end
return modem.transmit(routingChannel, channel, packet);
end
local function serve(serviceName, handler)
local function listenRaw(channel, handler)
openChannel(channel);
return el.register('modem_message', function(_, _, replyChannel, packet)
if replyChannel ~= BUS_CHANNEL then return end
if not isPacketOk(packet) then return end
if packet.service ~= serviceName or packet.kind ~= 'req' then return end
if isPacketOk(packet) and channel == replyChannel then
handler(packet.message, packet);
end
end)
end
local function reply(responsePayload)
local response = buildPacket(serviceName, 'res', responsePayload, packet.sourceId, packet.requestId);
transmit(response);
local function send(channel, eventType, payload, destId)
local event = { type = eventType, payload = payload };
return sendRaw(channel, event, destId);
end
local function listen(channel, eventType, handler)
return listenRaw(channel, function(event, packet)
if event.type == eventType then
handler(event.payload, packet)
end
end)
end
local function listenRequest(channel, eventType, handler)
return listen(channel, eventType, function(payload, packet)
local reply = function(responsePayload)
send(channel, eventType .. "_response", responsePayload, packet.sourceId);
end
handler(packet.payload, reply, packet);
end);
handler(payload, reply, packet);
end)
end
local function listen(serviceName, handler)
return el.register('modem_message', function(_, _, replyChannel, packet)
if replyChannel ~= BUS_CHANNEL then return end
if not isPacketOk(packet) then return end
if packet.service ~= serviceName or packet.kind ~= 'evt' then return end
local function sendRequest(channel, eventType, payload, destId)
local ok = false;
local result = nil;
local packetResult = nil;
handler(packet.payload, packet);
end);
local privateEventLoop = createEventLoop();
local privateNet = createNetwork(privateEventLoop, modem, routingChannel, timeoutInSec);
privateNet.listen(channel, eventType .. "_response", function(responsePayload, packet)
ok = true;
result = responsePayload
packetResult = packet;
privateNet.stop();
end)
privateEventLoop.setTimeout(function()
result = "net.sendRequest timeout!"
privateNet.stop();
end, timeoutInSec);
privateNet.onStart(function()
privateNet.send(channel, eventType, payload, destId);
end)
privateNet.startLoop();
return ok, result, packetResult;
end
local function send(serviceName, payload, opts)
opts = opts or {};
transmit(buildPacket(serviceName, 'evt', payload, opts.destId, nil));
end
local function sendMultipleRequests(channel, eventType, payload, destId)
if destId ~= nil and tonumber(destId) ~= nil then
local ok, res, packet = sendRequest(channel, eventType, payload, destId);
local function awaitResponse(serviceName, requestId, timeout, collectMultiple)
local timerId = os.startTimer(timeout);
if not ok then
return ok, res, packet
end
return ok, { res }, { packet };
end
local ok = false;
local results = {};
local packets = {};
local packetResults = {};
while true do
local event, p1, _, p3, p4 = os.pullEvent();
if event == 'timer' and p1 == timerId then
if collectMultiple then
if #results == 0 then return false, 'net.call timeout', {} end
return true, results, packets;
end
return false, 'net.call timeout', nil;
elseif event == 'modem_message' then
local replyChannel = p3;
local recvPacket = p4;
if replyChannel == BUS_CHANNEL
and isPacketOk(recvPacket)
and recvPacket.service == serviceName
and recvPacket.kind == 'res'
and recvPacket.requestId == requestId then
if collectMultiple then
table.insert(results, recvPacket.payload);
table.insert(packets, recvPacket);
else
os.cancelTimer(timerId);
return true, recvPacket.payload, recvPacket;
end
end
local privateEventLoop = createEventLoop();
local privateNet = createNetwork(privateEventLoop, modem, routingChannel, timeoutInSec);
privateNet.listen(channel, eventType .. "_response", function(responsePayload, packet)
ok = true;
table.insert(results, responsePayload)
table.insert(packetResults, packet);
end)
privateEventLoop.setTimeout(function()
if #results == 0 then
results = "net.sendRequest timeout!"
end
privateNet.stop();
end, timeoutInSec);
privateNet.onStart(function()
privateNet.send(channel, eventType, payload, destId);
end)
privateNet.startLoop();
return ok, results, packetResults;
end
local function createRequest(channel, eventType)
local requestApi = {};
function requestApi.send(payload, destId)
return sendRequest(channel, eventType, payload, destId);
end
end
local function call(serviceName, payload, opts)
opts = opts or {};
local timeout = opts.timeout or DEFAULT_TIMEOUT;
local requestId = newRequestId();
transmit(buildPacket(serviceName, 'req', payload, opts.destId, requestId));
return awaitResponse(serviceName, requestId, timeout, false);
end
local function callMultiple(serviceName, payload, opts)
opts = opts or {};
local timeout = opts.timeout or DEFAULT_TIMEOUT;
local requestId = newRequestId();
transmit(buildPacket(serviceName, 'req', payload, opts.destId, requestId));
return awaitResponse(serviceName, requestId, timeout, true);
end
local function setRouter(enabled)
isRouter = enabled and true or false;
end
local function onUnrouted(handler)
return el.register('modem_message', function(_, _, replyChannel, packet)
if replyChannel ~= BUS_CHANNEL then return end
if type(packet) ~= 'table' then return end
if packet.routerId then return end
if not packet.sourceId then return end
handler(packet);
end);
end
local function rebroadcast(packet)
packet.routerId = packet.routerId or os.getComputerID();
local selfId = os.getComputerID();
local selfLabel = os.getComputerLabel();
local destIsSelfId = packet.destId == selfId;
local destIsSelfLabel = selfLabel ~= nil and packet.destId == selfLabel;
local destIsSelf = destIsSelfId or destIsSelfLabel;
local matchesSelf = packet.destId == nil or destIsSelf;
if matchesSelf then
os.queueEvent('modem_message', modemSide, BUS_CHANNEL, BUS_CHANNEL, packet, 0);
function requestApi.sendMultiple(payload, destId)
return sendMultipleRequests(channel, eventType, payload, destId);
end
if not destIsSelf then
modem.transmit(BUS_CHANNEL, BUS_CHANNEL, packet);
function requestApi.listen(handler)
return listenRequest(channel, eventType, handler)
end
return requestApi;
end
local function createEvent(channel, eventType)
local eventApi = {}
function eventApi.send(payload, destId)
return send(channel, eventType, payload, destId);
end
function eventApi.listen(handler)
return listen(channel, eventType, handler)
end
return eventApi;
end
local function start()
return el.startLoop();
end
local function stop()
return el.stopLoop();
end
return {
BUS_CHANNEL = BUS_CHANNEL,
DEFAULT_TIMEOUT = DEFAULT_TIMEOUT,
eventloop = el,
isPacketOk = isPacketOk,
serve = serve,
listen = listen,
sendRaw = sendRaw,
listenRaw = listenRaw,
send = send,
call = call,
callMultiple = callMultiple,
setRouter = setRouter,
onUnrouted = onUnrouted,
rebroadcast = rebroadcast,
};
listen = listen,
sendRequest = sendRequest,
sendMultipleRequests = sendMultipleRequests,
listenRequest = listenRequest,
createRequest = createRequest,
createEvent = createEvent,
isPacketOk = isPacketOk,
openChannel = openChannel,
open = openChannel,
events = el,
eventloop = el,
start = start,
startLoop = start,
stop = stop,
stopLoop = stop,
onStart = el.onStart,
onStop = el.onStop,
}
end
local singleton = nil;
return function(el, modem, modemSide)
if el == nil and modem == nil and _G.bootEventLoop then
if not singleton then
singleton = createNetwork(_G.bootEventLoop, nil, nil);
end
return singleton;
end
return createNetwork(el, modem, modemSide);
end
return createNetwork;

View File

@ -1,15 +1,12 @@
# Documentation
Start here when looking up ComputerCraft-related APIs, CraftOS-PC behavior, peripherals, or mod integrations used by this repository.
Start here when looking up ComputerCraft-related APIs, peripherals, or mod integrations used by this repository.
## Indexes
- [`craftos_pc_glossary.md`](craftos_pc_glossary.md) - CraftOS-PC emulator setup, CLI flags, mounting, peripheral emulation, troubleshooting, and related references.
- [`../.opencode/agent-context/atm10-expert/INDEX.md`](../.opencode/agent-context/atm10-expert/INDEX.md) - ATM10 in-game agent context, including CC:Tweaked, Advanced Peripherals, and Create CC:Tweaked glossaries.
- [`opencode_server_guide.md`](opencode_server_guide.md) - Running `opencode serve` for the TrapOS `ai` client.
- [`ingame-trapos-ai-mcp-guide.md`](ingame-trapos-ai-mcp-guide.md) - Concise in-game checklist for installing TrapOS, connecting `ai`, and linking MCP.
- [`opencode_api.md`](opencode_api.md) - Minimal opencode HTTP API reference used by TrapOS.
- [`public-ports.md`](public-ports.md) - Public production TCP port allocation for TrapOS services.
- [`cc_glossary.md`](cc_glossary.md) - CC:Tweaked globals, modules, peripherals, events, and guides.
- [`advanced_peripherals_glossary.md`](advanced_peripherals_glossary.md) - Advanced Peripherals 0.7 guides, peripherals, turtles, integrations, and changelog pages.
- [`create_cc_tweaked_glossary.md`](create_cc_tweaked_glossary.md) - Create CC:Tweaked integration pages.
- [`adrs/`](adrs/) - Lightweight Architecture Decision Records for this repository.
## Notes

View File

@ -8,14 +8,7 @@ Future ADRs can reuse the shape of the existing files when it is useful.
## Records
- [`adr-0001-target-computercraft.md`](adr-0001-target-computercraft.md) — Target ComputerCraft.
- [`adr-0002-eventloop-and-service-bus.md`](adr-0002-eventloop-and-service-bus.md) — Eventloop substrate, service-name bus on a single channel, and `os.sleep` discipline.
- [`adr-0005-craftos-pc-harness-and-probes.md`](adr-0005-craftos-pc-harness-and-probes.md) — CraftOS-PC as the local harness, minimal periphemu bootstrap, and headless probes as the canonical hypothesis-test pattern.
- [`adr-0007-test-framework.md`](adr-0007-test-framework.md) — `libtest` per-case helper, `runtest` suite orchestration, and the two-layer timeout (libtest + shell watchdog).
- [`adr-0010-ccpm-package-manager.md`](adr-0010-ccpm-package-manager.md) — `ccpm` package manager (packages, registries, package-aware bootstrap).
- [`adr-0011-repo-conventions.md`](adr-0011-repo-conventions.md) — Git hooks own commit/push verification; markdown link syntax for cross-references.
- [`adr-0016-js-tool-verification.md`](adr-0016-js-tool-verification.md) — JavaScript/TypeScript tool build, check, test, CI, and future integration-test split.
- [`adr-0017-mcp-remote-lua-execution.md`](adr-0017-mcp-remote-lua-execution.md) — MCP `exec-lua` remote execution for linked ComputerCraft computers.
- [`adr-0018-justfile-organization.md`](adr-0018-justfile-organization.md) — Root Justfile split with imports while preserving flat recipe names.
Gaps in numbering (0003, 0004, 0006, 0008, 0009, 0012, 0013, 0014, 0015) are records that were either superseded by later decisions or consolidated into the surviving ADRs above.
- [`adr-0001-target-computercraft.md`](adr-0001-target-computercraft.md) - Target ComputerCraft.
- [`adr-0002-use-eventloop-for-async-code.md`](adr-0002-use-eventloop-for-async-code.md) - Use eventloop for async code.
- [`adr-0003-current-net-api-state.md`](adr-0003-current-net-api-state.md) - Current net API state.
- [`adr-0004-trapos-branding-and-manifest.md`](adr-0004-trapos-branding-and-manifest.md) - TrapOS branding and manifest-driven installs.

View File

@ -1,77 +0,0 @@
# ADR 0002: Eventloop Substrate, Service Bus, and Async Discipline
## Status
Accepted
## Date
2026-06-07
## Context
ComputerCraft is event-driven. Direct `os.pullEvent` loops are easy to write but hard to compose when multiple things need to happen at the same time. Without a single substrate the repo accumulated several distinct problems:
- Each long-lived process owned a private event loop, including the router (`programs/router.lua` was a hand-rolled `while true / os.pullEvent`). With N autostart servers, `parallel.waitForAll` ran N coroutines each pumping an independent `os.pullEventRaw`. Events were broadcast to every coroutine but only one would have a relevant handler — wasteful and conceptually awkward.
- `_G.isRouterEnabled` mutated send behavior across the codebase. [`apis/net.lua`](../../apis/net.lua) `sendRaw` switched its transmit path based on a global flag set by the router program, so the same function call meant different things depending on which machine ran it.
- Channel numbers leaked into every client. `servers/ping-server.lua` and `programs/ping.lua` both duplicated a `PING_CHANNEL` constant; there was no service registry. Adding a new service meant picking a free integer and replicating it on both ends.
- Label collision was a silent footgun. Two machines sharing the same label both accepted and rebroadcast packets addressed to that label, producing duplicate responses and duplicate retransmits.
- `os.sleep` looked innocent but broke the substrate. Its CC:Tweaked implementation yields via `os.pullEvent("timer")`. While the sleep is in flight, the enclosing eventloop's `os.pullEventRaw` is paused; non-`timer` events are silently discarded; even `eventloop.setTimeout` callbacks scheduled before the sleep cannot fire until it returns. This bit `apis/libai.lua` `pollMessage`, which used a sleep-based throttle and froze the whole loop the moment a caller invoked it from inside a handler.
Net's blast radius at the time of the bus rewrite was small (only ping consumed it), so a clean break was cheaper than incremental patching.
## Decision
### 1. Eventloop is the async substrate
New async code uses [`apis/eventloop.lua`](../../apis/eventloop.lua). Event handlers, timers, server listeners, and UI behavior compose through the eventloop instead of each feature owning its own blocking loop.
- Prefer `eventloop.register`, `setTimeout`, `onStart`, `onStop`, and `startLoop` for async behavior.
- APIs that listen for events accept an existing event loop as a constructor argument, the way [`apis/net.lua`](../../apis/net.lua) does. Do not create a private loop inside a module.
- Direct `os.pullEvent` loops should be rare and justified (CLI programs waiting for a single reply are the main exception).
- A handler that returns `api.STOP` auto-unregisters.
### 2. One boot eventloop and a service-name bus
`startup/servers.lua` creates a single `createEventLoop()` instance, stores it at `_G.bootEventLoop`, runs autostart server files (which register handlers and return without blocking), then runs `parallel.waitForAny(shellFn, eventLoopFn)`. The shell and the eventloop are the only two coroutines.
[`apis/net.lua`](../../apis/net.lua) exposes a service-name bus on a single channel:
- `net.serve(name, handler)` — register a server handler (server-side).
- `net.call(name, payload, opts)` — request/response with timeout (client-side).
- `net.send(name, payload, opts)` — fire-and-forget (client-side).
- `net.listen(name, handler)` — passive listener.
All traffic flows on channel `10` and is demultiplexed inside the packet body via a `service` field. Channel numbers stop being a public concept. `require('/apis/net')()` returns a singleton bound to `_G.bootEventLoop` when present, otherwise an ephemeral instance. CLI programs stay standalone: `net.call` internally uses `os.pullEvent` with a timer, so programs do not need the boot eventloop to receive a response.
[`programs/router.lua`](../../programs/router.lua) registers handlers on the same boot eventloop everything else uses. It owns a TTL-based label map extracted into [`apis/librouter.lua`](../../apis/librouter.lua) for testability. Machines with a label autostart [`servers/net-registrar.lua`](../../servers/net-registrar.lua), which periodically broadcasts `(id, label)` so the router can resolve label-addressed packets. Duplicate label registrations are rejected with a printed warning. `_G.isRouterEnabled` is gone; the router service flips a local flag via `net.setRouter(true)` instead.
### 3. `os.sleep` discipline
In library, server, and program code that may run inside an eventloop (directly or transitively), use `eventloop.setTimeout` for any waiting, throttling, polling, or retry-with-delay. Libraries that need to temporize must take an eventloop factory through their constructor rather than baking a hardcoded sleep call. [`apis/net.lua`](../../apis/net.lua) `sendRequest` is the canonical private-eventloop pattern: create a private eventloop, schedule the wait through `setTimeout`, then `runLoop` until the work resolves — synchronous from the caller's perspective, but the dispatcher stays alive internally so handlers can compose around it via `parallel.waitForAll`.
`os.sleep` remains acceptable only in narrow cases:
1. One-shot programs that are purely sequential and register no event handlers — a `programs/foo.lua` that prints, sleeps, prints again, and exits.
2. `parallel.waitForAny(task, function() sleep(t); end)` used as an isolated guard to bound an inner task (e.g. the AI Lua-exec sandbox in `apis/libai.lua` and the `parallel.waitForAny`-driven per-case timer in `apis/libtest.lua`). The guard sleep is private to its own coroutine group; it does not block anything external.
3. Tests that are themselves driven by `libtest`'s per-case timeout (see [ADR-0007](adr-0007-test-framework.md)).
New code must not expose a `sleep` injection point on its constructor. If a wait is needed, accept an `eventloop` factory and schedule through `setTimeout`. Tests substitute a synchronous deterministic eventloop fake the same way they substitute `http` or `settings`.
## Consequences
- Adding a new networked service is now: write a `servers/foo.lua` that calls `net.serve('foo', handler)` and returns, then add it to a package's `autostart`. No channel allocation, no `.start()` blocking call.
- The router program returns immediately instead of blocking the shell. Users type `router` once on the chosen machine and continue using the shell.
- Label collisions are detected and rejected at registration time, with a clear warning, instead of causing silent duplicate delivery.
- A router must still be running somewhere on the network for cross-machine label-addressed packets; without one, non-router senders produce packets with `routerId = nil` and consumers drop them on receive.
- Programs that need to wait for events still work by direct `os.pullEvent`, but if a program registers a long-lived handler on `_G.bootEventLoop` and exits, the handler keeps firing with a stale closure. Programs should prefer `call`/`send` over `serve`/`listen`. This is documented in [`apis/net.lua`](../../apis/net.lua) but not enforced.
- Tests for the router state machine live in [`tests/router.lua`](../../tests/router.lua) and exercise [`apis/librouter.lua`](../../apis/librouter.lua) with an injected clock. Tests for the net packet shape and dispatch live in [`tests/net.lua`](../../tests/net.lua) with a fake modem.
- Slightly more ceremony in "synchronous-looking" library functions that wait: a private eventloop plus a small `attempt`/`finish` pair. The benefit is clean composition with any caller's eventloop.
- Test fakes shift from a `sleep` stub to a synchronous eventloop double. Ergonomics are comparable; the eventloop fake additionally lets tests observe `pending` and `stopped` state, catching leaks the sleep stub would have missed.
- Existing call sites are migrated opportunistically when they cause observable bugs. The first `os.sleep` migration is `apis/libai.lua`.
## Out of Scope
- Multi-router topologies. The single-router assumption stays; a network is expected to run `router` on exactly one machine.
- Retry and acknowledgement primitives beyond the existing per-call `timeout`.
- Unifying `libtui`, `libai`, and `tuidemo` eventloops. They remain private; they are presentation/AI concerns, not network plumbing.

View File

@ -0,0 +1,28 @@
# ADR 0002: Use Eventloop For Async Code
## Status
Accepted
## Date
2026-06-07
## Context
ComputerCraft is event-driven. Direct `os.pullEvent` loops are easy to write, but they are hard to compose when multiple things need to happen at the same time.
This matters for servers, network listeners, timers, peripheral events, and future UI code. UI code especially will need to handle input, redraws, network replies, and timers together.
## Decision
New async code should use `/apis/eventloop`.
Event handlers, timers, server listeners, and future UI behavior should compose through the event loop instead of each feature owning its own blocking event loop.
## Consequences
- Prefer `eventloop.register`, `setTimeout`, `onStart`, `onStop`, and `startLoop` for async behavior.
- APIs that listen for events should accept an existing event loop as a constructor argument, the way `/apis/net` already takes one. Do not create a private loop inside a module.
- Direct `os.pullEvent` loops should be rare and justified.
- Existing code can stay as-is for now, but future async, server, and UI code should move toward eventloop composition.

View File

@ -0,0 +1,36 @@
# ADR 0003: Current Net API State
## Status
Accepted
## Date
2026-06-07
## Context
`/apis/net` is the current networking abstraction in this repository.
It wraps modem messages with packet metadata and uses `/apis/eventloop` for listeners and request/response flows. It is useful for today's basic routed messages and RPC-like requests, but it is not a final protocol design.
## Decision
Keep using `/apis/net` for simple program and server messaging.
Document the current behavior as the baseline, without over-designing the future protocol before real needs appear.
## Current State
- Default routing channel is `10`.
- Ping channel is `9`.
- Packets include `sourceId`, `sourceLabel`, `routerId`, `destId`, and `message`.
- Main convenience APIs include `send`, `listen`, `sendRequest`, `sendMultipleRequests`, `listenRequest`, `createEvent`, `createRequest`, and `openChannel` (alias `open`). Listening on a non-default channel requires `openChannel` first.
- `sendRequest` and `sendMultipleRequests` run a private event loop, default to a `0.5s` timeout, and return `ok, result, packet` (or `ok, results, packets` for the multi variant).
- Router behavior currently lives separately in `/programs/router.lua`. A router must be running on the network — otherwise non-router senders produce packets with `routerId = nil` and `isPacketOk` drops them on receive, so cross-machine messages silently fail.
## Consequences
- Use `/apis/net` for current basic messaging needs.
- Keep duplicated well-known channel constants in sync while they remain duplicated.
- Future ADRs can replace or refine this one if the network protocol gains discovery, retries, schemas, versioning, auth, or a different routing model.

View File

@ -0,0 +1,47 @@
# ADR 0004: TrapOS Branding And Manifest-Driven Installs
## Status
Accepted
## Date
2026-06-07
## Context
The project started as a loose collection of ComputerCraft / CC:Tweaked APIs and programs. As the surface area grew (eventloop, net, router, ping, events, upgrade) and the install/upgrade flow gained a beta channel, three pain points emerged:
- `install.lua` carries a hardcoded `LIST_FILES` table that has to be edited every time a file is added or removed. Adding a single shipped file means editing the file itself, the installer, and sometimes `startup/servers.lua`.
- The `--beta` flag is not persisted. The user has to remember to pass it on every `upgrade`, which makes the beta channel awkward to live on.
- The system has no visible identity at boot. There is no name, no version line, nothing that confirms which branch is installed.
At the same time, the codebase has outgrown the "Trap's ComputerCraft APIs" framing. It is closer to a small in-game operating system than a library, and treating it as one unlocks a clearer story (a name, a version, a manifest, a boot banner).
## Decision
Adopt the name **TrapOS** and a manifest-driven install architecture.
- A single `manifest.json` at the repo root is the source of truth for the project: name, version, branch, list of shipped files, and the list of servers to autostart at boot. Parsed with `textutils.unserializeJSON` / `textutils.serializeJSON` (built-in to CC:Tweaked).
- A local copy of that manifest is written to `/trapos/manifest.json` at the end of every install. This local file is the authoritative system state on the computer:
- `branch` is the persisted beta opt-in. Once a user installs with `--beta` (and confirms a one-time `(y/N)` prompt), subsequent `upgrade` calls auto-target the `next` branch with no flag needed.
- `version` is what the boot MOTD displays.
- `files` and `autostart` drive the next install and the boot sequence.
- A new `startup/motd.lua` prints a colored `TrapOS v<version>` line at boot — lime for stable, orange with a `[BETA]` tag for beta. Guarded on `term.isColor()` so monochrome terminals still get the text.
- `startup/servers.lua` reads `autostart` from the local manifest instead of carrying its own hardcoded list.
- The shipped install URL stays at `https://raw.githubusercontent.com/guillaumearm/cc-libs/...` for now. Renaming the GitHub repository is a follow-up, tracked in the "Future Work" section below.
## Consequences
- Adding a new file or autostart server is now a one-line edit to `manifest.json`. Both `install.lua` and `startup/servers.lua` pick it up automatically.
- The beta channel becomes a real opt-in: a single confirmed `upgrade --beta` is enough to live on `next`. A new `--stable` flag exists for the symmetric opt-out.
- The boot banner gives users an immediate sanity check ("am I on the right branch, the right version?").
- The system gains an explicit local state directory (`/trapos/`) and a clear contract for what lives there.
- The installer takes a hard dependency on `textutils.serializeJSON` / `unserializeJSON`, which require CC:Tweaked ≥ 1.79. This is well within any reasonable target version for Minecraft 1.21.
- Existing computers running the old installer still upgrade cleanly: the old `upgrade` fetches the new `install.lua`, which then creates `/trapos/manifest.json` from the manifest it just downloaded.
## Future Work
- **Repository rename.** Once the install flow has stabilized on `TrapOS`, the GitHub repository will be renamed from `cc-libs` to a name that matches (likely `trapos`). The install URL inside `install.lua` and `programs/upgrade.lua` will be updated in the same PR, and a redirect at the old URL is sufficient for in-the-wild installs.
- **Package manager.** The longer-term direction floated during planning was a small package manager where each directory in the repo is a package. The current manifest is a deliberate step toward that — same shape (name, version, files), single-package case — and can grow into multi-manifest discovery without changing the install/upgrade contract.
- **Per-version migration system.** Today the installer carries a static list of legacy files to delete. A future change can replace this with a per-version migration block driven by the manifest.

View File

@ -1,86 +0,0 @@
# ADR 0005: CraftOS-PC Harness, Periphemu Bootstrap, and Headless Probes
## Status
Accepted
## Date
2026-06-08
## Context
This repository targets CC:Tweaked on Minecraft 1.21. The Lua we ship runs inside the ComputerCraft sandbox: it depends on `os.pullEventRaw`, `peripheral`, `rednet`, `textutils.serializeJSON`, modem channels, and CC-specific globals. Standard Lua cannot execute this code as-is, so a normal local test harness was never a serious option.
Contributors have been running the code in two places:
- In-game on a real Minecraft server, which is slow to iterate on.
- In **CraftOS-PC** (<https://www.craftos-pc.cc/>), a desktop emulator that ships the same ROM/BIOS as CC:Tweaked, supports modem peripherals via `periphemu`, and can run fully headless (`--cli --headless --script <file>`).
CraftOS-PC was the *de facto* local harness for months but lived only as a single line in [`AGENTS.md`](../../AGENTS.md). There was no install guide, no minimum version, and `just install` did not check the binary was present. Two related concerns emerged on top of that:
- `startup/servers.lua` historically called `periphemu.create` four times on computer 0 (a top modem, two `computer` peers at ids 1 and 2 both labelled `Trap`, and a router peer at id 10). In CraftOS-PC GUI mode this opened **four windows** on every launch, and the duplicate `Trap` label plus persistent per-id state across versions caused recurring confusion.
- Headless CraftOS-PC is also a cheap, deterministic *interactive* tool: it boots the emulator, runs an arbitrary Lua snippet against the real CC:Tweaked ROM, prints output, and exits in well under a second. Humans and agents can use it to verify hypotheses about CC:Tweaked behavior *before* writing code or tests. That usage was implicit; no document framed headless exec recipes as the recommended first move when an agent is unsure about CC:Tweaked behavior.
## Decision
### 1. CraftOS-PC as a first-class local dev dependency
- **Minimum version `v2.8.3`** — recent enough to have the current CC:Tweaked ROM, old enough that contributors already on a 2.8.x build will not be forced to upgrade again.
- **Documented install** in [`docs/install-craftos-pc.md`](../install-craftos-pc.md), with a SHA-256-verified macOS flow and pointers to the official Windows/Linux artifacts.
- **Documented upstream navigation** in [`docs/craftos_pc_glossary.md`](../craftos_pc_glossary.md), covering CLI flags, mounts, `periphemu`, save data, and troubleshooting pages.
- **Verified by `just install`** via `check-install`, which checks `craftos`, `jq`, `luacheck`, and `openssl`. `check-craftos` runs `craftos --version` and requires v2.8.3 or newer. Failure prints a one-line pointer to the install guide.
- **Repository-local TrapOS launch.** `just trapos` runs CraftOS-PC with `--directory .craftos`, keeps the macOS `--rom /Applications/CraftOS-PC.app/Contents/Resources` workaround, mounts the repository root read-only at `/trapos`, and mounts each top-level source directory read-only at its ComputerCraft root path.
- **Vanilla launch.** `just craftos` launches CraftOS-PC under `.craftos-vanilla/` with no mounts and no startup, for probes that should not see TrapOS files and for the `just trapos-install` end-to-end install verification.
- **`just ci` is the local verification entry point.** It runs `check-craftos`, `check`, and `test`. Local Git hooks are installed by `just install`; see [ADR-0011](adr-0011-repo-conventions.md) for the commit/push split.
The existing [`AGENTS.md`](../../AGENTS.md) constraint ("Do not run Lua locally or add a test harness unless asked") is reframed rather than removed: there is still no standalone Lua harness, and we are not adding a Busted-style test runner. The harness *is* CraftOS-PC, invoked deliberately.
### 2. Periphemu bootstrap stays minimal
`startup/servers.lua` attaches **only a top modem** under `periphemu`:
```lua
if periphemu then
periphemu.create('top', 'modem');
end
```
Extra emulated computers are spawned manually from the CraftOS-PC shell when actually needed (e.g. `periphemu create 10 computer` to bring up a router peer for cross-VM testing). The pattern is documented in [`docs/periphemu.md`](../periphemu.md). The `if periphemu then` guard is preserved so in-game behavior is unchanged.
### 3. Headless probes as the canonical hypothesis pattern
Two safe-exec recipes wrap raw `--headless --exec` with `xpcall`, call `os.shutdown()` on success or Lua error, and use `TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS` (default `10`) as a host watchdog:
- `just trapos-exec '<lua>'` — probe against the **TrapOS dev environment**. Mounts of `/apis`, `/programs`, `/servers`, `/startup`, `/tests`, and the repo root at `/trapos` are live, so `require('/apis/eventloop')` and friends work against the current branch. Use this when the question involves repo code.
- `just craftos-exec '<lua>'` — probe against **vanilla CraftOS-PC**. No mounts, no startup scripts. Use this when the question is purely about CC:Tweaked behavior and TrapOS files would be a distraction, or to confirm a behavior is upstream rather than something the dev env layered on.
- `just trapos-install` — drive the full real install (`install-ccpm.lua` → `ccpm update``ccpm install trapos`) on a fresh ephemeral state. This is the probe to run when changing anything in the install path itself.
Conventions:
- Prefer the safe exec recipes over raw `--headless --exec`.
- Keep snippets minimal and side-effect-free. If a probe reveals a fact worth defending, add a `libtest` case under `tests/` — probes are not a substitute for committed tests.
- LLM agents SHOULD prefer a quick headless probe over speculation when answering "does X work in CC:Tweaked?" or "does my refactor still load?". The cost is one extra emulator boot (~1s); the benefit is grounded answers instead of plausible-sounding ones.
## Consequences
- Contributors must install CraftOS-PC before `just install` succeeds. The install guide makes this a 4-step copy/paste on macOS.
- Headless tests live under `tests/` and are driven by `just test`. See [ADR-0007](adr-0007-test-framework.md) for the runner and timeout layering.
- The macOS install symlinks the binary into `/usr/local/bin`, which makes CraftOS-PC unable to auto-discover the ROM that ships inside the `.app` bundle (`Could not mount ROM`). The `test:` recipe works around this by passing `--rom /Applications/CraftOS-PC.app/Contents/Resources` on Darwin. Linux (AppImage) and Windows (installer) auto-discover correctly.
- `just trapos` uses repository-local save data under `.craftos/config/` and `.craftos/computer/`. This keeps emulator state out of `~/Library/Application Support/CraftOS-PC` during repository work and keeps repo files visible through read-only mounts instead of copying them into the VM save.
- `just repl` is a human-only interactive wrapper around `just trapos --cli`; automation and LLM agents must use `just trapos-exec '<lua>'` or `just craftos-exec '<lua>'` instead.
- `craftos` (GUI) now opens a single unlabelled `Computer 0` window with a top modem attached. Cross-machine testing requires an explicit `periphemu create` call from the shell rather than being implicit on boot — one extra command when you need a peer, no surprise windows or persisted ghost VMs.
- `.craftos-vanilla/` is in `.gitignore` alongside `.craftos/`.
- `just trapos-install` is *not* part of `just ci`: it is network-dependent and slower than `just test`. Run it manually when touching `install-ccpm.lua` or ccpm package descriptors.
- Higher CraftOS-PC invocation traffic during agent sessions; cheap enough that this is a good trade.
- The harness version becomes a project-level concern. When CC:Tweaked ships breaking changes that require a newer CraftOS-PC build, we bump the minimum version in [`docs/install-craftos-pc.md`](../install-craftos-pc.md) and `check-craftos` keeps contributors honest.
- No CI integration yet. Running CraftOS-PC headless in GitHub Actions is feasible (the AppImage works on Ubuntu runners) but is out of scope; the contract is local-only for now.
## Future Work
- **API-loading smoke test.** Extend `tests/` with a script that `require`s `/apis/eventloop`, `/apis/net`, `/apis/libtest`, and the router, asserting the wiring loads without errors.
- **CI.** Run `just test` on push using the Linux AppImage.
- **Pinned ROM.** Point CraftOS-PC at a vendored ROM via `--rom` if we ever need to test against a specific in-game version.
- If `tests/` grows a multi-VM scenario, drive peer creation from the test script itself (each `tests/*.lua` already owns its setup) rather than re-adding peers to `startup/servers.lua`.

View File

@ -1,79 +0,0 @@
# ADR 0007: Test Framework — libtest, runtest, and Layered Timeouts
## Status
Accepted
## Date
2026-06-08
## Context
[ADR-0005](adr-0005-craftos-pc-harness-and-probes.md) made CraftOS-PC the local harness. The first behavior test, `tests/eventloop.lua`, proved the harness can exercise ComputerCraft APIs headlessly, but it also duplicated test-runner concerns directly in the script: collecting named cases, per-case progress in verbose mode, fail-fast messaging, success-marker emission, and process shutdown. Those details are easy to copy incorrectly.
A blocked eventloop test then showed two more harness needs:
- The shell harness needs a timeout and captured output so agentic debugging can proceed without manual interruption.
- A single shell watchdog is coarse. `kill -TERM` on the whole CraftOS-PC process cannot say *which* case hung, produces one generic message, and cannot tell a cooperatively-blocked event loop (the common failure — waiting on an event or `sleep` that never resolves) apart from a genuinely wedged process. A per-case timeout inside Lua is both finer and faster, but the shell watchdog is still needed for the cases Lua cannot interrupt.
At the same time, tests in this repository are still ComputerCraft programs. They should be useful in CraftOS-PC and in-game, not only inside a host-side shell loop.
## Decision
### 1. `libtest` is the per-case helper
[`apis/libtest.lua`](../../apis/libtest.lua) is the repository's lightweight ComputerCraft test helper. Tests under `tests/` require it with an absolute ComputerCraft path:
```lua
local createLibTest = require('/apis/libtest');
local testlib = createLibTest({ ... });
testlib.test('example', function()
testlib.assertEquals(1 + 1, 2);
end);
testlib.run();
```
`libtest` intentionally stays small. It provides named cases, `assertEquals`, `assertTrue`, `assertErrors`, optional case-status report output consumed by the suite runner, and failure reporting. It must stay usable from normal ComputerCraft programs in CraftOS-PC or in-game.
### 2. `runtest` owns suite orchestration; the Justfile stays minimal
[`/programs/runtest.lua`](../../programs/runtest.lua) owns suite-level concerns: test discovery under `/tests`, invoking each script with `shell.run`, grouped `--pretty` output, `--verbose` runner diagnostics, the `__TRAPOS_TEST_OK__` success marker (printed only after the full suite passes), and optional shutdown when the host harness asks with `--shutdown`. `runtest` can run inside CraftOS-PC or in-game when `/tests` and dependencies are present.
The `Justfile` launches CraftOS-PC, mounts repository directories, enforces the process timeout, checks for the success marker, and prints runner output files. It does not know about individual test files or cases. Verbose mode is reserved for debugging and agent work loops; `--pretty` is the normal human-readable mode.
### 3. Layered test timeouts
Two independent timeout layers, ordered so the finer one fires first.
**Layer 1 — `libtest` per-case timeout (primary).** [`apis/libtest.lua`](../../apis/libtest.lua) races each test case against a timer with `parallel.waitForAny(runner, timer)`. The default is `DEFAULT_TIMEOUT_SECONDS = 3`. When the timer wins, the case fails with a distinct message containing the token `libtest timeout` and, in `--verbose`, an extra `TIMEOUT … (libtest)` diagnostic. `--timeout <seconds>` overrides the default; `--no-timeout` disables the layer. `runtest` forwards both flags to each case script. This only interrupts cases that yield (the usual hang); a non-yielding CPU loop cannot be preempted in ComputerCraft.
**Layer 2 — shell watchdog (backstop).** The `Justfile` `test:` recipe keeps its `TRAP_CCLIBS_TEST_TIMEOUT_SECONDS` watchdog as an independent double-check. Its default matches the libtest default (`.env.test` ships `3`; the recipe falls back to `3`) so libtest fires first for yielding cases in normal runs and the watchdog only catches what Lua cannot — a non-yielding loop, a wedged libtest, or a deliberately bypassed case. Its SIGTERM message is worded differently from the `libtest timeout` message, so the two layers are never confused.
### How to write tests properly
- Normal tests live in `tests/*.lua`, use `/apis/libtest.lua`, and must finish under the libtest timeout. `runtest` auto-discovers them; `just test` runs the suite.
- Never commit a hanging or intentionally-slow test to `tests/`: it would fail every run.
- Intentionally-slow fixtures that exercise the harness itself live in `tests/harness/`. `runtest` discovery skips subdirectories, so they never run with the normal suite; they are driven only by dedicated recipes (`just test-timeout-lua`, `just test-timeout-shell`, aggregated by `just test-timeout`).
- Use `--no-timeout` only for harness fixtures that must outlive the libtest layer to prove the shell watchdog, never for ordinary tests.
## Consequences
- New deterministic behavior should get as many useful CraftOS-PC tests as practical. Tests that require human validation, such as complex turtle motion, in-game UX feel, or visual approval, may be skipped, but deterministic pieces should still get unit-style non-regression coverage.
- Test scripts remain normal ComputerCraft programs, not standalone Lua tests. They can run through `just test`, `/programs/runtest.lua`, or direct in-game execution when copied with their dependencies.
- `libtest` lives under `/apis` and ships in `trapos-test`, so it can be required consistently in the mounted CraftOS-PC environment.
- The `__TRAPOS_TEST_OK__` marker remains the single shell-level success contract and is owned by `runtest`.
- A hung case fails in ~3s with a per-case message instead of taking down the whole process anonymously.
- `just test-timeout` is a self-asserting harness regression guard wired into `just ci`. It chains `test-timeout-lua` (Layer 1: libtest cancels the slow case immediately with `--timeout 0`, before the shell backstop) and `test-timeout-shell` (Layer 2: the `TRAP_CCLIBS_TEST_TIMEOUT_WATCHDOG_SECONDS` watchdog, default `1`, kills the slow case with libtest bypassed). Both drive a single `tests/harness/slow-case.lua` fixture; the tight timeouts — not the fixture's sleep length — decide which layer fires, so the harness itself is covered against regressions on every `ci`.
- `libtest` stays a normal ComputerCraft program: `parallel` and `sleep` are sandbox globals, so the timeout works in CraftOS-PC and in-game alike.
- Host-specific concerns remain outside production Lua code.
## Future Work
- Add more assertions only when tests need them; avoid growing a large framework.
- Add test selection filters when the suite grows.
- Add runner-level or per-case timing in `--verbose` output if slow-but-passing cases become hard to spot.
- A `libtest`-level marker for "expected timeout" if more harness fixtures appear.
- Explore GitHub Actions with the Linux CraftOS-PC AppImage after local coverage is broader.

View File

@ -1,113 +0,0 @@
# ADR 0010: ccpm Package Manager
## Status
Accepted
## Date
2026-06-08
## Context
The previous install flow (a `LIST_FILES` table inside `install.lua` and a single flat
`manifest.json` read by `wget`) was all-or-nothing. There was no way to install just
networking or just the UI, and no way to add or remove pieces of the OS after the
initial install. The project also outgrew the "Trap's ComputerCraft APIs" framing into
a small in-game OS — TrapOS — with a name, a version, a boot banner, and a persisted
beta channel; that motion is preserved here even though the original install
mechanism is fully replaced.
We want a package manager, `ccpm` ("ComputerCraft Package Manager"), installed first
as a standalone user-facing step. After that, a machine can `ccpm update`,
`ccpm install trapos`, `ccpm install trapos-net`, `ccpm uninstall trapos-ui`, and manage
where packages come from. TrapOS itself is installed through a `trapos` meta-package;
the `wget run .../install-ccpm.lua` bootstrap exists only to install `ccpm`.
## Decision
### Packages are descriptors over the existing tree
Source files stay where they are (`apis/`, `programs/`, `servers/`, `startup/`); their
install targets remain the same absolute CC paths, so `require` paths and the dev
mounts are unchanged. A package is a descriptor that *references* those files:
`packages/<name>/ccpm.json` with `{ name, version, description, dependencies, files,
autostart }`. `packages/index.json` lists the packages a registry offers (for
`ccpm search`). There is no `ccpm.json` at the repo root.
The split is finer-grained than the install examples imply:
| package | contents | deps |
|----------|-----------------------------------------------------------------|----------|
| trapos-core | ccpm, libccpm, eventloop, upgrade, events | — |
| trapos-test | libtest, runtest | trapos-core |
| trapos-boot | motd, servers (startup) | trapos-core |
| trapos-net | net, router, ping, ping-server | trapos-core |
| trapos-ui | libtui, tuidemo | trapos-core |
| trapos-ai | AI client for opencode serve | trapos-core |
| trapos | full TrapOS meta-package | trapos-boot, trapos-net, trapos-ui, trapos-test, trapos-ai |
### Two files for ccpm, "manifest" reserved for the OS
To avoid colliding with the OS `manifest.json`, ccpm never uses the word "manifest".
Local state lives under `/trapos`:
- `ccpm.json` — ordered registry list `{ registries = { { name, type, branch } } }`.
`type` is `gitea` (resolves to `git.trapcloud.fr/<name>/raw/branch/<branch>/`, the
default seeded by the bootstrap), `github` (resolves to
`raw.githubusercontent.com/<name>/<branch>/`, deprecated but still supported), or
`http`/`https` (the `name` is a base URL).
- `ccpm.lock.json` — installed packages `{ packages = { <name> = { version, registry,
files, dependencies, autostart } } }`, used by `ls`, `uninstall`, and `reinstall`.
- `ccpm.cache.json` — packages advertised by configured registries, written by
`ccpm update` from each registry's `packages/index.json`, used by `ccpm search`,
`ccpm available`, and `ccpm upgrade`.
`apis/libccpm.lua` is the testable core (a factory; `http`/`stateDir`/`installRoot`
are injectable for tests). `programs/ccpm.lua` is a thin CLI over it.
### The bootstrap installs only ccpm
`install-ccpm.lua` resolves only the `trapos-core` package descriptor (pulling any future
dependencies), downloads its files, and writes:
- `/trapos/manifest.json` — the aggregated `{ name, version, branch, files, autostart }`
still consumed by `startup/motd.lua` and `startup/servers.lua` after boot packages
are installed. This is the surviving piece of the previous manifest-driven install:
it is no longer the install source of truth (each package's `ccpm.json` is), but it
is still the local system state used at boot for the colored `TrapOS v<version>`
banner and to read the `autostart` list. `branch` is the persisted beta opt-in
(a single confirmed `--beta` install switches subsequent `ccpm upgrade` runs to
`next` with no flag needed; `--stable` is the symmetric opt-out);
- `/trapos/ccpm.lock.json` — so right after a fresh install `ccpm install trapos-core`
correctly reports "already installed";
- `/trapos/ccpm.json` — seeding/refreshing the default `guillaumearm/cc-libs` registry
to track the install branch.
The install path is:
- `wget run .../install-ccpm.lua` — install `ccpm` (`trapos-core`) and seed the default
registry.
- `ccpm update` — refresh the local package cache.
- `ccpm install trapos` — install the full OS. During beta, `trapos` includes
`trapos-test` by default.
On a subsequent `upgrade`, `programs/upgrade.lua` delegates to `ccpm upgrade`, which
upgrades installed packages using `/trapos/ccpm.cache.json`. Users run `ccpm update`
first to refresh available versions.
## Consequences
- The repo gains a `packages/` descriptor tree; the flat source layout is untouched.
- `just trapos` (formerly `just craftos`; see [ADR-0005](adr-0005-craftos-pc-harness-and-probes.md)) no longer derives mounts
from `manifest.json .files` (it is now `.packages`); it mounts a fixed list of
top-level dirs instead. `just test` was already on fixed mounts and is unaffected.
- ccpm logic is covered by `tests/ccpm.lua` (URL resolution, dependency ordering,
cycle/missing detection, already-installed, registry CRUD, cache update, available
status, upgrade, uninstall dependency guard) with an injected `http` stub — no
network in tests.
## Future Work
- Version ranges (today a single pinned version per package).
- http/https registries beyond a plain base URL (auth, caching).

View File

@ -1,60 +0,0 @@
# ADR 0011: Repository Conventions — Git Hooks and Markdown Link Syntax
## Status
Accepted
## Date
2026-06-08
## Context
Two small operational conventions emerged together as the repo grew and want to be remembered:
- **Where verification runs.** The repository has a CraftOS-PC harness (`just test`) and a fuller local CI path (`just ci`) that adds tool checks, `luacheck`, and harness regression guards. Agents and humans can be asked to commit and push changes, so verification can happen in two places: manually before Git operations and automatically inside Git hooks. Running the same tests manually and again in hooks makes workflows slower without improving the success contract, and risks divergent habits between human and agent paths.
- **How docs reference each other.** The `docs/` tree increasingly cross-references itself, and ADRs reference one another by number. `lychee` is wired into `just check` (`lint-markdown` recipe) so broken local links fail the build. Lychee only validates *links it can see*: a bare prose mention of `docs/foo.md` or `ADR-0005`, or a backticked path like `` `docs/foo.md` ``, is invisible to it. When a doc is renamed or moved, those mentions silently rot until a human happens to read the surrounding paragraph.
## Decision
### 1. Git hooks own commit/push verification
Install two local Git hooks through `just install` / `just install-git-hooks`:
- `.git/hooks/pre-commit` runs `just check test`.
- `.git/hooks/pre-push` runs `just ci`.
When an agent is explicitly asked to commit and/or push, it should not run `just test` manually before the Git operation. The hook is the source of truth for that workflow: commit triggers `just check test`, and push triggers `just ci`.
Manual verification is still appropriate outside commit/push workflows. For example, run `just test` while developing a behavior change, and run `just ci` when checking the full local state without pushing.
### 2. Markdown link syntax for cross-references
In every markdown file in the repository, references to other `.md` files must use markdown link syntax `[text](relative/path.md)`. This includes:
- Direct file references (`` `docs/install-craftos-pc.md` `` → `` [`docs/install-craftos-pc.md`](docs/install-craftos-pc.md) ``).
- ADR-number references (`ADR-0005` → `[ADR-0005](docs/adrs/adr-0005-craftos-pc-harness-and-probes.md)`, with the path adjusted to be relative to the referencing file).
- Plain-prose mentions (`called out in CLAUDE.md` → `called out in [CLAUDE.md](../../CLAUDE.md)`).
Excluded by design:
- Mentions inside fenced code blocks. They are example/code content; `lychee.toml` skips them via `include_verbatim = false`.
- Mentions inside inline code spans used purely to *illustrate* the wrong form. The reader can see they are placeholders, and lychee does not extract them as links either.
- A file's own title (e.g. `# CLAUDE.md` as a heading is not a reference).
Link paths are written relative to the file containing the link, so `lychee --offline` resolves them on the local filesystem with no `--base` indirection.
## Consequences
- Commit and push verification is consistent for humans and agents.
- Commit workflows avoid duplicating the same CraftOS-PC test run before and during `git commit`.
- Push workflows still get the full local CI gate before remote updates.
- The hooks are local files under `.git/hooks`, so developers should run `just install` after cloning or after hook behavior changes.
- `just lint-markdown` (and therefore `just check`, pre-commit, and pre-push) catches dead cross-references the moment a doc is moved or renamed.
- The markdown-link convention is *social* — humans and agents must remember to apply it when writing prose. Lychee enforces correctness only once a link exists; it cannot flag a mention that should have been a link but wasn't.
- The `[`path`](path)` style (backticked path as link text) is preferred for direct file references, matching the existing house style in [`docs/README.md`](../README.md) and [`docs/adrs/README.md`](README.md). For ADR mentions in flowing prose, `[ADR-####](path)` reads better than the backticked form.
## Future Work
- Revisit the verification split if `just test` becomes too slow for pre-commit or `just ci` gains checks that should also block commits.
- If rot reappears in prose despite the link convention, add a small grep-based lint that flags unbracketed `.md` and `ADR-####` mentions and wire it into `just check`. Deferred until there is evidence the social convention is insufficient.

View File

@ -1,46 +0,0 @@
# ADR 0016: JavaScript Tool Verification
## Status
Accepted
## Date
2026-06-10
## Context
The repository now contains `tools/mcp-bridge`, a TypeScript Node.js tool that sits next to the ComputerCraft Lua code. It has a different build and test lifecycle than TrapOS packages, but it still participates in the same local developer workflow and Git hooks described by [ADR-0011](adr-0011-repo-conventions.md).
The bridge also needs future end-to-end coverage that spans both runtimes: a host Node process and a headless CraftOS-PC computer. That is broader and slower than normal unit tests, and it needs the same kind of timeout discipline as the Lua harness in [ADR-0007](adr-0007-test-framework.md).
## Decision
Keep the Node package scripts simple and one-purpose:
- `npm run build` emits TypeScript into `dist/` and acts as the type-check gate.
- `npm run test` runs the Node unit tests directly from TypeScript with `tsx --test test/*.test.ts`. No prior build is required.
- `npm run check` runs ESLint. TypeScript compilation is covered by `npm run build` and `npm run test:ci` to avoid duplicate compiler runs in repository CI.
- `npm run test:ci` runs `npm run check && npm run build && npm run test`, so type errors and lint failures both surface even though `test` itself no longer compiles.
- `npm run test-integration` runs the bridge-to-CraftOS integration suite with `tsx --test --test-concurrency=1 test-integration/*.test.ts`. Each case boots the bridge in-process on fixed loopback ports (`127.0.0.1:2000` for MCP HTTP, `127.0.0.1:2001` for the CraftOS link), spawns a CraftOS-PC headless computer that connects back, exercises `tools/call probe-computers`, and tears everything down. `--test-concurrency=1` keeps the fixed ports collision-free.
- These fixed integration-test ports are loopback-only and unrelated to the public production ports documented in [`../public-ports.md`](../public-ports.md).
Expose matching repository recipes for the Node lifecycle:
- `just build` delegates to `npm run build` for the bridge.
- `just test` depends on `just build`, runs `npm run test`, then runs the existing CraftOS-PC test suite.
- `just check` includes `npm run check` alongside Lua and Markdown checks.
- `just ci` uses `npm run test:ci`, then runs CraftOS-PC tests and the broader integration/harness target.
- `just test-integration` runs the placeholder Node integration target and the Lua timeout harness checks.
## Consequences
- `npm run test` no longer needs `dist/`. Both unit and integration tests load TypeScript directly through `tsx`, so test iteration is faster and `dist/` is only required for `npm start`.
- TypeScript errors are no longer caught by `npm run test`; they are caught by `npm run build`, which stays wired into `npm run test:ci`, `just build`, `just test`, and `just ci`.
- `just ci` avoids duplicating Node unit tests by calling `npm run test:ci` directly and then invoking only the CraftOS-side test body.
- ESLint failures are part of `just check`, so they are covered by the same pre-commit and pre-push hooks as Lua and Markdown checks.
- Integration tests live in `tools/mcp-bridge/test-integration/`, with Lua client fixtures under `test-integration/lua/`. Slow end-to-end behavior stays out of the fast unit-test path.
## Future Work
- Extend the integration harness with disconnect and reconnect scenarios (computer drops mid-probe; bridge restarts while a computer is connected) once those failure modes need regression coverage.

View File

@ -1,54 +0,0 @@
# ADR 0017: MCP Remote Lua Execution
## Status
Accepted
## Date
2026-06-11
## Context
`tools/mcp-bridge` links OpenCode MCP tools to ComputerCraft computers through the `mcp-computer` WebSocket program. The initial bridge only supported `probe-computers`, which proved that the host could reach linked computers but did not let the assistant inspect or modify in-game state directly.
For interactive TrapOS development, a remote Lua execution tool is useful: it can inspect files, peripherals, settings, labels, inventory-adjacent APIs, service state, and small runtime hypotheses without asking the player to type each command manually in-game.
This is also explicitly dangerous. A linked assistant can run arbitrary Lua with the same authority as the ComputerCraft computer. That includes file deletion, network traffic, peripheral operations, turtle movement, inventory changes, reboot/shutdown calls, and long-running or stuck programs.
## Decision
Add an MCP tool named `exec-lua` to `tools/mcp-bridge`.
The tool targets one linked computer by `computerId` and sends Lua source over the existing bridge request/response protocol:
```json
{ "type": "request", "id": "...", "method": "exec-lua", "params": { "code": "..." } }
```
The ComputerCraft side executes the code in `mcp-computer` and returns a normal bridge response with:
- `ok` for execution success or failure.
- `result.returns` as JSON-safe descriptors of returned values.
- `result.output` containing captured `print` and `write` output.
- `error` for syntax or runtime failure.
Execution is enabled by default for any computer running the updated `mcp-computer` program. We intentionally do not add an `--allow-exec` flag for this first version because the current workflow is a local, explicitly trusted development bridge and the user accepts the risk.
The execution environment overrides `print` and `write` so their text is captured in the MCP response instead of being emitted to the visible terminal. Code that intentionally wants to affect the ComputerCraft screen should call terminal APIs such as `term.clear`, `term.setCursorPos`, and `term.write` directly.
Timeouts are host-side request timeouts. A timed-out MCP call stops waiting for the response, but it does not preempt a running Lua chunk inside ComputerCraft. Avoid infinite loops and long blocking calls unless the in-game computer can be restarted.
## Consequences
- OpenCode can now inspect and operate linked ComputerCraft computers without manual in-game command entry.
- The trust boundary moves to the act of running `mcp-computer` against a bridge. Only run it against bridges and assistants you trust.
- The bridge remains protocol-compatible with older clients for `probe-computers`; older `mcp-computer` clients will report `unknown method` for `exec-lua`.
- Captured output is deterministic for assistant workflows; visible screen mutation remains explicit through `term.*` APIs.
- Tests must cover both host-side MCP routing and the real CraftOS-PC `mcp-computer` path so regressions are caught across the Node/Lua boundary described in [ADR-0016](adr-0016-js-tool-verification.md).
## Future Work
- Add a separate opt-in flag or per-computer policy if the bridge is used outside local trusted development.
- Add cooperative cancellation for yielding chunks if long-running remote execution becomes common.
- Add more convenience tools on top of the trusted bridge once usage patterns are clear.

View File

@ -1,46 +0,0 @@
# ADR 0018: Justfile Organization
## Status
Accepted
## Date
2026-06-11
## Context
The root `Justfile` had grown into a single large file containing unrelated repository operations: local installation, dependency checks, Node tooling, CraftOS-PC launch wrappers, headless probes, test orchestration, package validation, markdown linting, and opencode helpers.
That made the file harder to scan and edit, even though the public recipe surface is useful as a flat command list. Existing documentation, Git hooks, and developer habits refer to top-level commands such as `just check`, `just test`, `just ci`, and `just trapos-exec`.
`just` supports both imports and modules. Imports include another justfile into the current namespace, so imported recipes keep their top-level names. Modules create namespaced subcommands and isolate recipe/variable definitions between modules. That namespace is useful for new command families, but it would change the invocation shape of existing repository recipes.
## Decision
Keep the root `Justfile` as the canonical entrypoint, but make it only import concern-specific recipe files under `just/` and define the default listing recipe.
Use imports for the existing recipe split so all public recipe names remain unchanged. Organize imported files by operational concern:
- `just/deps.just` for dependency/tool availability checks.
- `just/install.just` for local install, cleanup, Git hooks, and generated environment setup.
- `just/npm.just` for Node-based repository tooling.
- `just/craftos.just` for CraftOS-PC launchers, headless Lua probes, install probe, and REPL wrapper.
- `just/opencode.just` for opencode server/client helpers.
- `just/test.just` for CI, standard tests, integration tests, and timeout harness checks.
- `just/check.just` for source checks, package validation, and markdown link validation.
Reserve `mod` for future commands that are intentionally namespaced from the start. Do not use modules for this migration because preserving the existing flat command API is more important than introducing module isolation.
## Consequences
- Existing commands keep working without aliases or compatibility wrappers.
- `just --list` remains the public command catalogue for the repository.
- Recipe bodies are easier to locate by concern while cross-file dependencies still work through the shared imported namespace.
- Duplicate recipe names remain a repository-level concern because all imported recipes share one namespace.
- Future recipes should be added to the concern file that owns their lifecycle rather than growing the root `Justfile` again.
## Future Work
- Consider modules for new namespaced tool families if the desired invocation is explicitly something like `just tool subcommand` or `just tool::subcommand`.
- Revisit the grouping if any imported justfile grows large enough to recreate the original navigation problem.

View File

@ -1,59 +0,0 @@
# CraftOS-PC Documentation Glossary
Compact index of CraftOS-PC documentation pages from <https://www.craftos-pc.cc/docs/>. CraftOS-PC is the emulator used by this repository; `craftos` is the command-line executable it installs.
Last checked: 2026-06-08.
Repo notes:
- Use [Install CraftOS-PC](install-craftos-pc.md) for the pinned local setup used by this repository.
- Use [Command-Line Flags](https://www.craftos-pc.cc/docs/cli) for `--headless`, `--script`, `--exec`, `--rom`, `--directory`, `--id`, and `--mount-*`.
- Use [File System Mounting](https://www.craftos-pc.cc/docs/mounter) for exposing repository files inside CraftOS-PC.
- Use [Peripheral Emulation](https://www.craftos-pc.cc/docs/periphemu) for `periphemu` and local modem/peripheral tests. See [`docs/periphemu.md`](periphemu.md) for the in-repo reference and how this repo uses it.
- Use [Error Messages](https://www.craftos-pc.cc/docs/error-messages) when `craftos --help` works but booting a computer fails, including `Could not mount ROM`.
## Introduction
- [What is CraftOS-PC?](https://www.craftos-pc.cc/docs/about)
- [Downloading & Installing](https://www.craftos-pc.cc/docs/installation)
- [Getting Started](https://www.craftos-pc.cc/docs/getting-started)
- [Save Data Location](https://www.craftos-pc.cc/docs/saves)
- [Standards Mode](https://www.craftos-pc.cc/docs/standards)
- [Migrating from CCEmuX](https://www.craftos-pc.cc/docs/ccemux)
- [CraftOS-PC Mobile](https://www.craftos-pc.cc/docs/mobile)
## New Features
- [Peripheral Emulation](https://www.craftos-pc.cc/docs/periphemu)
- [File System Mounting](https://www.craftos-pc.cc/docs/mounter)
- [Screenshots & Recording](https://www.craftos-pc.cc/docs/screenshot)
- [Multiple Computers](https://www.craftos-pc.cc/docs/multicomp)
- [ComputerCraft Configuration](https://www.craftos-pc.cc/docs/config)
- [Graphics Mode](https://www.craftos-pc.cc/docs/gfxmode)
- [HTTP & WebSocket Server](https://www.craftos-pc.cc/docs/http-server)
- [CC: Tweaked Features](https://www.craftos-pc.cc/docs/cctweaked)
- [Auto-Updater](https://www.craftos-pc.cc/docs/autoupdate)
- [Debugger](https://www.craftos-pc.cc/docs/debugger)
- [Command-Line Flags](https://www.craftos-pc.cc/docs/cli)
- [Rendering Options](https://www.craftos-pc.cc/docs/renderers)
- [Raw Mode](https://www.craftos-pc.cc/docs/rawmode)
- [VS Code Extension](https://www.craftos-pc.cc/docs/extension)
- [Plugin System](https://www.craftos-pc.cc/docs/plugins)
- [CraftOS-PC Accelerated](https://www.craftos-pc.cc/docs/accelerated)
## Other Information
- [CraftOS-PC Online](https://www.craftos-pc.cc/docs/online)
- [CraftOS-PC Remote](https://www.craftos-pc.cc/docs/remote)
- [Latest Development Build](https://www.craftos-pc.cc/artifact)
- [Error Messages](https://www.craftos-pc.cc/docs/error-messages)
- [Report a Bug](https://www.craftos-pc.cc/bugreport)
- [Privacy Policy](https://www.craftos-pc.cc/docs/privacy)
- [Contact](https://www.craftos-pc.cc/docs/contact)
- [Changelog](https://www.craftos-pc.cc/docs/changelog)
## API Reference
- [CC: Tweaked Documentation](https://tweaked.cc/)
- [Lua 5.2 Reference Manual](https://lua.org/manual/5.2/manual.html)
- [CraftOS APIs (Old Wiki)](http://www.computercraft.info/wiki/Category:APIs)

View File

@ -1,135 +0,0 @@
# In-Game TrapOS, AI, MCP Guide
Follow this order while playing.
## 1. Install TrapOS
On the ComputerCraft computer:
```sh
wget run https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/install-ccpm.lua --beta
ccpm update
ccpm install trapos
```
If the computer asks to reboot, reboot it.
## 2. Start OpenCode On Host
On your real machine:
```sh
opencode serve --hostname 0.0.0.0 --port 4242
```
If exposing beyond your machine, use a password:
```sh
OPENCODE_SERVER_PASSWORD=secret opencode serve --hostname 0.0.0.0 --port 4242
```
## 3. Connect `ai.lua`
On the ComputerCraft computer, use your public host:
```sh
set opencc.server_url http://<public-host>:4242
```
If you set a password:
```sh
set opencc.password secret
```
Optional model settings:
```sh
set opencc.provider_id anthropic
set opencc.model_id claude-opus-4-7
```
Optional agent setting for the in-game ComputerCraft assistant:
```sh
set opencc.agent atm10-expert
```
Test it:
```sh
ai ping
ai "say hello from TrapOS"
```
Expected ping: `pong`.
## 4. Start MCP Bridge On Host
From this repository on your real machine:
```sh
cd tools/mcp-bridge
npm install
CC_LINK_PORT=4243 npm run dev
```
Production ports:
```text
MCP endpoint: http://127.0.0.1:3000
ComputerCraft link: ws://<public-host>:4243
```
## 5. Link The Computer To MCP
On the ComputerCraft computer:
```sh
mcp-computer ws://<public-host>:4243
```
Leave it running. You should see:
```text
linked as <id> (Label: <label>)
waiting for requests... Press Ctrl+T to stop.
```
## 6. Connect OpenCode To MCP
Add the bridge as an MCP HTTP server in your OpenCode MCP config, pointing at:
```text
http://127.0.0.1:3000
```
Then ask OpenCode to use the MCP tool `probe-computers`. A working link returns a `pong from <id>` line.
The bridge also exposes `exec-lua`, which runs Lua on one linked computer by id. For example, this returns captured output to OpenCode:
```lua
print('captured in MCP output')
```
To write to the visible ComputerCraft screen, use terminal APIs directly:
```lua
term.clear()
term.setCursorPos(1, 1)
term.write('visible on screen')
```
`exec-lua` is powerful and unsafe by design: it can do anything the linked computer can do, including file, peripheral, turtle, and reboot operations. Only run `mcp-computer` against a bridge you trust.
The bridge also exposes `write-file`, which writes content to a path on one linked computer by id and overwrites any existing file. It follows normal ComputerCraft filesystem behavior, so missing parent directories fail instead of being created automatically.
## Quick Fixes
- `ai` says missing `opencc.server_url`: run the `set opencc.server_url ...` command again.
- `ai` cannot reach server: check `opencode serve`, public host, port `4242`, and ComputerCraft HTTP rules.
- `mcp-computer` says WebSocket unavailable: enable ComputerCraft HTTP/WebSocket support.
- MCP sees no computers: keep `mcp-computer ws://<public-host>:4243` running in-game.
- `exec-lua` or `write-file` is missing after updating the bridge: restart OpenCode so it reloads the MCP tool list.
More detail: [`opencode_server_guide.md`](opencode_server_guide.md), [`public-ports.md`](public-ports.md).

View File

@ -1,116 +0,0 @@
# Install CraftOS-PC
CraftOS-PC is the local harness used to run this repo's Lua outside of Minecraft. See [ADR-0005](adrs/adr-0005-craftos-pc-harness-and-probes.md) for why.
CraftOS-PC is the emulator; `craftos` is the command-line executable it installs. For the broader upstream documentation index, see [`craftos_pc_glossary.md`](craftos_pc_glossary.md).
Minimum version: **v2.8.3**. `just install` runs `craftos --version` to verify it is on `$PATH` and recent enough; it also checks that `jq`, `luacheck`, and `openssl` are installed.
The upstream installation page is <https://www.craftos-pc.cc/docs/installation>. The notes below pin the version we test against and add a SHA-256 verification step.
## macOS
There is no Homebrew cask, so the install is a manual drag-to-Applications from the official GitHub release.
```sh
# 1. Download the dmg and the published hashes.
curl -L -o ~/Downloads/CraftOS-PC.dmg \
https://github.com/MCJack123/craftos2/releases/download/v2.8.3/CraftOS-PC.dmg
# 2. Verify the SHA-256.
curl -sL https://github.com/MCJack123/craftos2/releases/download/v2.8.3/sha256-hashes.txt \
| grep CraftOS-PC.dmg
shasum -a 256 ~/Downloads/CraftOS-PC.dmg
# The two hashes must match.
# 3. Mount, install, unmount.
hdiutil attach ~/Downloads/CraftOS-PC.dmg -nobrowse
rm -rf /Applications/CraftOS-PC.app
cp -R "/Volumes/CraftOS-PC/CraftOS-PC.app" /Applications/
hdiutil detach "/Volumes/CraftOS-PC"
# 4. Clear Gatekeeper quarantine so the first launch is not blocked.
xattr -dr com.apple.quarantine /Applications/CraftOS-PC.app
```
The binary is not on `$PATH` by default. Add a symlink so `just check-craftos` can find it:
```sh
ln -sf /Applications/CraftOS-PC.app/Contents/MacOS/craftos /usr/local/bin/craftos
```
User data (computer state, settings) lives in `~/Library/Application Support/CraftOS-PC` and survives a reinstall. The steps above only touch the `.app` bundle.
When running headless tests on macOS, prefer passing the app bundle's resource directory explicitly if `craftos` is reached through the `/usr/local/bin/craftos` symlink:
```sh
craftos --headless --rom /Applications/CraftOS-PC.app/Contents/Resources --exec 'print("__TRAPOS_TEST_OK__"); os.shutdown()'
```
## Windows
Download `CraftOS-PC-Setup.exe` from the [latest release](https://github.com/MCJack123/craftos2/releases/latest) and run it. The installer puts `craftos.exe` on `%PATH%`.
User data: `%appdata%\CraftOS-PC`.
## Linux
Download `CraftOS-PC.x86_64.AppImage` from the [latest release](https://github.com/MCJack123/craftos2/releases/latest), make it executable, and symlink it into `$PATH`:
```sh
chmod +x ~/Downloads/CraftOS-PC.x86_64.AppImage
sudo ln -sf "$HOME/Downloads/CraftOS-PC.x86_64.AppImage" /usr/local/bin/craftos
```
User data: `~/.local/share/craftos-pc`.
## Verify
```sh
craftos --version
```
Must report `CraftOS-PC v2.8.3` or newer. Once this works, `just install` will succeed.
To verify that CraftOS-PC can boot a computer, not only print its executable version, run:
```sh
craftos --headless --exec 'print("__TRAPOS_TEST_OK__"); os.shutdown()'
```
On macOS, use the `--rom` form shown above if the command fails with `Could not mount ROM`.
## Running tests
`just test` runs `/programs/runtest.lua` headlessly through CraftOS-PC. The runner discovers tests under `/tests`, while the `Justfile` only owns host launch flags, timeout, and success-marker checks. On macOS the recipe passes `--rom /Applications/CraftOS-PC.app/Contents/Resources` because the `/usr/local/bin/craftos` symlink loses ROM auto-discovery; on Linux and Windows no flag is needed. `just ci` runs the same tests after `luacheck`.
## Repository-local launches
`just trapos` launches CraftOS-PC with the TrapOS dev environment: persistent save data rooted at `.craftos` instead of the platform default user-data directory, and read-only mounts of the repository at `/trapos` plus each shipped top-level directory at its ComputerCraft root path (such as `/apis`, `/programs`, `/servers`, `/startup`, and `/tests`). Generated files live under `.craftos/config/` and `.craftos/computer/`, while the root `.gitignore` keeps that state untracked.
`just craftos` launches a vanilla CraftOS-PC with no mounts, persistent under `.craftos-vanilla/` (also gitignored). Use it when a probe should not see TrapOS files — for example, to confirm a behavior is upstream rather than TrapOS-specific.
`just trapos-install` exercises the real ccpm bootstrap (`install-ccpm.lua` → `ccpm update``ccpm install trapos`) end-to-end on a fresh, ephemeral CraftOS-PC state. Network-dependent and slower than `just test`, so not part of `just ci`. Override the watchdog with `TRAP_CCLIBS_INSTALL_TIMEOUT_SECONDS` (default `60`).
For automated probes, use the safe wrappers. They mount the right environment,
shut down the machine after completion or Lua errors, and kill the host process
if the snippet blocks before shutdown:
```sh
just trapos-exec 'print("__TRAPOS_TEST_OK__")'
just craftos-exec 'print(_HOST)'
```
Override the watchdog with `TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS` (default `10`).
Pass CraftOS-PC flags directly after `just trapos` or `just craftos` only for
manual launches where you want raw emulator control.
See [ADR-0005](adrs/adr-0005-craftos-pc-harness-and-probes.md) for the canonical headless probe pattern used to verify hypotheses about CC:Tweaked behavior.
`just repl` delegates to `just trapos --cli` for human interactive use only. LLM agents must not run `just repl`.
See [Command-Line Flags](https://www.craftos-pc.cc/docs/cli) for `--headless`, `--exec`, `--script`, `--rom`, and `--mount-*`. See [Error Messages](https://www.craftos-pc.cc/docs/error-messages) for `Could not mount ROM` and other boot-time failures.
## Updating
Repeat the steps above against the newer release. The bundle replacement is in-place; the user data directory is preserved.

View File

@ -1,218 +0,0 @@
# opencode serve — HTTP API reference
Minimal reference for the endpoints used by `libai.lua`. Full spec served at `GET /doc` when the server is running.
## Running the server
Local/dev uses opencode's default port `4096`:
```bash
opencode serve \
--hostname 127.0.0.1 \
--port 4096
```
Public production uses port `4242`; see [`public-ports.md`](public-ports.md):
```bash
opencode serve \
--hostname 0.0.0.0 \
--port 4242
```
With Basic Auth (recommended):
```bash
OPENCODE_SERVER_PASSWORD=secret opencode serve \
--hostname 127.0.0.1 \
--port 4096
```
Default username is `opencode`. Override with `OPENCODE_SERVER_USERNAME`.
## Authentication
All requests must include `Authorization: Basic <base64(username:password)>` when the server was started with a password. No auth header is needed if no password is set.
```
Authorization: Basic base64("opencode:secret")
```
## Endpoints
### `GET /global/health`
Health check. Returns `200` when the server is up.
---
### `GET /session`
List all sessions for the current project.
Optional query parameters used by `ai`:
- `directory` — scope results to an opencode project directory.
**Response** `200`:
```json
[
{ "id": "ses_abc123", "title": "my session", "time": { "created": 1234567890, "updated": 1234567890 } }
]
```
---
### `POST /session`
Create a new session.
**Request body:**
```json
{ "title": "optional title", "parentID": "optional" }
```
**Response** `200`:
```json
{ "id": "ses_abc123", "title": "my session" }
```
---
### `POST /session/:id/message`
Send a message and wait for the AI reply (blocking). Returns when the assistant has finished responding.
**Request body:**
```json
{
"parts": [
{ "type": "text", "text": "your prompt here" }
],
"agent": "atm10-expert",
"model": { "providerID": "anthropic", "modelID": "claude-opus-4-7" }
}
```
`agent` and `model` are optional — omit them to use the server's configured defaults.
**Response** `200`:
```json
{
"info": { "id": "msg_xyz", "sessionID": "ses_abc123", "role": "assistant" },
"parts": [
{ "type": "text", "text": "the reply" }
]
}
```
Parts can include non-text types (`tool-call`, `step-start`, etc.) — collect all `type == "text"` entries to reconstruct the reply.
**Errors:**
- `401` — wrong credentials
- `404` — session not found (may have been deleted or server restarted)
- `504` — AI took too long
---
### `GET /session/:id/message/:messageID`
Get a message by ID. Opencode validates caller-provided message IDs; use IDs starting with `msg`.
**Response** `200`:
```json
{
"info": { "id": "msg_xyz", "sessionID": "ses_abc123", "role": "assistant", "time": { "completed": 1234567890 } },
"parts": [
{ "type": "text", "text": "the reply" }
]
}
```
---
### `DELETE /session/:id`
Delete a session.
---
### `POST /session/:id/abort`
Abort a running generation.
---
### `POST /session/:id/prompt_async`
Fire-and-forget variant. Returns `204` immediately and starts generation in the background. Include `messageID` in the request body so the submitted user message can be matched to the later assistant response. Opencode validates caller-provided message IDs; use IDs starting with `msg`.
**Request body:**
```json
{
"messageID": "msg_xyz",
"parts": [
{ "type": "text", "text": "your prompt here" }
],
"agent": "atm10-expert",
"model": { "providerID": "anthropic", "modelID": "claude-opus-4-7" }
}
```
`agent` and `model` are optional. Omit `model` to use the server/session default model, or include it to force a specific provider/model for this prompt.
`ai` uses this endpoint by default to avoid `504` failures from the blocking `/message` endpoint when the LLM takes longer than one HTTP request timeout. The submitted `messageID` identifies the user message; `ai` polls `GET /session/:id/message` and reads the completed assistant message after it. If `opencc.agent` or `--agent <name>` is set, `ai` includes it as `agent` in the request body. If generation fails in the background, opencode records the failure on the assistant message or session event stream; `ai` surfaces assistant message errors while polling.
---
### `GET /global/event` (SSE)
Server-Sent Events stream. Delivers all server bus events in real time. Useful for async workflows or watching a session from outside the TUI.
---
## opencode attach
`opencode attach` opens the TUI and connects it to an already-running `opencode serve` instance. Both the TUI and any HTTP clients operate on the same session state.
```bash
opencode attach http://127.0.0.1:4096
opencode attach http://127.0.0.1:4096 --session ses_abc123
```
To send messages to the session currently open in the TUI, set `opencc.session_id` in CC to the session ID shown in the TUI, then run `ai`:
```sh
set opencc.session_id ses_abc123
```
## TUI control
When a TUI is attached, these endpoints drive that TUI programmatically. They operate on the attached TUI prompt/dialog state, not on a specific `/session/:id`.
| Method | Path | Effect | Body |
|---|---|---|---|
| `POST` | `/tui/append-prompt` | Append text to the prompt | `{ "text": "..." }` |
| `POST` | `/tui/submit-prompt` | Submit the current prompt | none |
| `POST` | `/tui/clear-prompt` | Clear the prompt | none |
| `POST` | `/tui/open-help` | Open the help dialog | none |
| `POST` | `/tui/open-sessions` | Open the session selector | none |
| `POST` | `/tui/open-themes` | Open the theme selector | none |
| `POST` | `/tui/open-models` | Open the model selector | none |
| `POST` | `/tui/execute-command` | Execute a TUI command | `{ "command": "prompt.submit" }` |
| `POST` | `/tui/show-toast` | Show a notification | `{ "message": "...", "variant": "info" }` |
| `GET` | `/tui/control/next` | Wait for the next TUI control request | none |
| `POST` | `/tui/control/response` | Respond to a TUI control request | response body |
`/tui/show-toast` also accepts optional `title` and `duration` fields. `variant` is one of `info`, `success`, `warning`, or `error`.
Example: prefill and submit the attached TUI prompt:
```bash
curl -X POST http://127.0.0.1:4096/tui/append-prompt \
-H 'Content-Type: application/json' \
-d '{"text":"reply with exactly: pong"}'
curl -X POST http://127.0.0.1:4096/tui/submit-prompt
```
`POST /tui/publish` also exists as a lower-level endpoint for publishing TUI events such as `tui.prompt.append`, `tui.command.execute`, and `tui.toast.show`. Prefer the specific endpoints above unless you need that raw event surface.

View File

@ -1,175 +0,0 @@
# opencode server guide
How to run `opencode serve` and use `ai` from ComputerCraft directly, no proxy.
See [`opencode_api.md`](opencode_api.md) for the full API reference.
## Architecture
```
CC Computer
└─ ai.lua (libai.lua)
└─ POST /session/:id/message → opencode serve
```
## 0. Install TrapOS and the AI package
On a fresh CC computer (beta branch):
```
wget run https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/install-ccpm.lua --beta
ccpm update
ccpm install trapos
```
## 1. Start `opencode serve`
Local/dev uses opencode's default port `4096`:
```bash
opencode serve --hostname 0.0.0.0 --port 4096
```
Public production uses port `4242`; see [`public-ports.md`](public-ports.md):
```bash
opencode serve --hostname 0.0.0.0 --port 4242
```
With Basic Auth (recommended for LAN exposure):
```bash
OPENCODE_SERVER_PASSWORD=secret opencode serve \
--hostname 0.0.0.0 \
--port 4096
```
Default username is `opencode`. Override with `OPENCODE_SERVER_USERNAME=myuser`.
Check it's alive:
```bash
curl http://localhost:4096/global/health
```
With Basic Auth:
```bash
curl -u opencode:secret http://localhost:4096/global/health
```
## 2. (Optional) Attach the TUI
Open the interactive TUI connected to the running server. CC clients and the TUI share the same session state.
```bash
opencode attach http://127.0.0.1:4096
```
To target a specific session from CC, grab the session ID shown in the TUI and run:
```sh
set opencc.session_id ses_abc123
```
## 3. Configure CC settings
Use the CraftOS shell `set` program at the ComputerCraft console or CraftOS-PC terminal. It persists immediately — no `save` step. The Lua [settings](https://tweaked.cc/module/settings.html) API would also work from a script, but at the shell prompt use `set`.
```sh
set opencc.server_url http://<host-ip>:4096
```
For public production, use port `4242`:
```sh
set opencc.server_url http://<public-host>:4242
```
For example locally:
```sh
set opencc.server_url http://127.0.0.1:4096
```
With auth:
```sh
set opencc.password secret
```
Optional — override the Basic Auth username (default `opencode`):
```sh
set opencc.username myuser
```
Optional — select an opencode agent for requests from this computer:
```sh
set opencc.agent atm10-expert
```
Optional — scope `ai sessions` to a specific opencode project directory. If omitted, `ai sessions` falls back to the directory recorded on the saved `opencc.session_id` when the unscoped list is empty:
```sh
set opencc.directory /Users/garm/trap/cc-libs
```
Optional: pick the provider and model for requests from this computer. If omitted, `ai` still posts to `/session/:id/prompt_async`; opencode uses the server/session default model:
```sh
set opencc.provider_id anthropic
set opencc.model_id claude-opus-4-7
```
Optional timeout settings:
```sh
set opencc.timeout_seconds 60
set opencc.poll_timeout_seconds 600
set opencc.poll_interval_seconds 2
```
`opencc.session_id` is auto-managed and saved by `ai`. Set it manually only when you want CC to target an existing session, such as one opened in the attached TUI.
- **CraftOS-PC (localhost):** `http://127.0.0.1:4096`
- **In-game ATM10 local/dev:** use your LAN IP with port `4096` (e.g. `192.168.x.x:4096`) — add it to `http.rules` in `config/computercraft-server.toml`
- **Public production:** use your public host with port `4242`
## 4. Run `ai`
```
ai ping -- ping, reuses existing session
ai "explain what a turtle is"
ai new "start a fresh topic" -- forget current session, start fresh
ai sessions -- list all server sessions with their IDs
ai --agent atm10-expert "what peripherals are attached?"
ai --verbose ping -- show HTTP/session diagnostics
ai --help
ai --version
```
Expected `ai ping` output on a working setup: `pong`.
## 5. CraftOS-PC (no Minecraft)
```bash
just trapos --headless -- /programs/ai.lua ping
```
Set settings inside the harness before running, or inject them via the test API.
## Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| `missing opencc.server_url` | Setting not set | `set opencc.server_url http://...` |
| `serveur injoignable` | Server not running or wrong URL | Start `opencode serve`, check URL/port |
| `erreur message: HTTP 401` | Wrong password | Check `opencc.password` matches `OPENCODE_SERVER_PASSWORD` |
| `missing prompt` | No prompt was passed | Run `ai <prompt>` or `ai ping` |
| `session introuvable; lance: ai new <prompt>` | Session was deleted or server restarted | Run `ai new <prompt>` |
| `erreur message: HTTP 504` | Blocking mode AI call took too long | Retry; prefer the default async mode |
| `erreur assistant: ...` | Opencode accepted the async prompt but generation failed | Check the provider/model/agent and opencode logs |
| `delai depasse en attendant la reponse AI` | Async polling timed out | Increase `opencc.poll_timeout_seconds`; use `ai --verbose` and check opencode logs |
| `reponse vide` | Reply had no text parts | Check opencode logs |

View File

@ -1,69 +0,0 @@
# `periphemu` — CraftOS-PC Peripheral Emulation
In-repo reference for the `periphemu` global exposed by [CraftOS-PC](https://www.craftos-pc.cc/). It does **not** exist in CC:Tweaked in-game — always guard usage with `if periphemu then`.
Upstream: <https://www.craftos-pc.cc/docs/periphemu>. See also the [CraftOS-PC glossary](craftos_pc_glossary.md) and [ADR-0005](adrs/adr-0005-craftos-pc-harness-and-probes.md) (why CraftOS-PC is our local harness, and why our startup attaches only a modem).
## Usage in this repo
`startup/servers.lua` attaches a single top modem when running under CraftOS-PC:
```lua
if periphemu then
periphemu.create('top', 'modem');
end
```
That is the only `periphemu` call in the codebase. In-game (`periphemu == nil`) the block is a no-op. Locally a top modem is enough to boot the router, `ping-server`, and clients on the same VM.
To exercise cross-computer behavior locally, spawn extra peers ad-hoc from the shell:
```
periphemu create 10 computer # spawn router VM (id 10)
periphemu create 1 computer # spawn a client VM
```
Each call opens a new CraftOS-PC window (GUI mode). Keep this out of `startup/servers.lua` — persistent emulated peers clutter the desktop and leave stale state under `~/Library/Application Support/CraftOS-PC/computer/<id>/`.
## API
### `periphemu.create(side, type[, arg])``boolean`
Attaches a peripheral. Returns `true` on success, `false` if `side` is already occupied.
- `side` (string | number) — string for directional sides (`"top"`, `"left"`, …) or arbitrary names (`"modem_4"`); a number to attach a `computer` peripheral by id.
- `type` (string) — see types below.
- `arg` (optional) — type-specific, see table below.
### `periphemu.remove(side)``boolean`
Detaches the peripheral on `side`. Returns `false` if nothing is attached.
### `periphemu.names()``string[]`
Returns the list of peripheral type identifiers this CraftOS-PC build accepts as the `type` argument to `create`.
## Peripheral types
| Type | Third arg | Notes |
| -------------- | -------------------- | -------------------------------------------------------------------- |
| `modem` | network id (number) | Default network if omitted. Used to isolate modem networks. |
| `computer` | — | `side` is the computer id (number). Spawns a new VM window. |
| `drive` | mount path / `record:` / `treasure:` / `computer:<id>` | Initial mounted disk. |
| `monitor` | — | Size is set via `monitor.setTextScale` after attach. |
| `printer` | output PDF path | Required. Receives printed pages as PDF. |
| `speaker` | — | See caveat below about `playAudio` vs CC:T. |
| `debugger` | — | Attaches the CraftOS-PC debugger; see upstream debugger docs. |
| `debug_adapter`| — | DAP front-end peripheral. |
| `chest` | `true`/`false` | `true` = double chest. Also accepts `minecraft:chest`. |
| `energy` | max energy (number) | Optional further args declare supported energy type strings. |
| `tank` | tank count (number) | Optional further args set tank size and accepted fluid type strings. |
Call `periphemu.names()` from a CraftOS-PC shell to confirm what the current build supports — the upstream list grows occasionally.
## Caveats
- **CC:T has no `periphemu`.** Always guard with `if periphemu then`. [AGENTS.md](./../AGENTS.md) and [ADR-0005](adrs/adr-0005-craftos-pc-harness-and-probes.md) treat that guard as mandatory.
- **Speaker `playAudio` differs from CC:T** unless [standards mode](https://www.craftos-pc.cc/docs/standards) is on — CraftOS-PC queues audio without ever returning `false`.
- **Modem network id** segregates emulated networks. Two modems with different ids will not see each other; useful for testing the [router](../programs/router.lua) against a sealed network.
- **Persisted per-computer state** lives at `~/Library/Application Support/CraftOS-PC/computer/<id>/` and `config/<id>.json` (labels stored base64-encoded). Spawning a new id leaves that state behind across sessions.

View File

@ -1,18 +0,0 @@
# Public Production Ports
TrapOS public production services use the `4242-4244` TCP range. Keep local developer defaults separate from these public ports so tests and local tools can run without binding production-facing addresses.
| Port | Service | Notes |
|---|---|---|
| `4242` | opencode server HTTP API | Production/public port for `opencode serve`. Local/dev examples may still use opencode's default `4096`. |
| `4243` | ComputerCraft bridge WebSocket | Production/public equivalent of the local bridge link default `3001`. Use `CC_LINK_PORT=4243` for production bridge deployments. |
| `4244` | Reserved | Free public production port reserved for a future service. Do not assign it casually in local tooling. |
## Local Vs Production
- Local/dev opencode: `http://127.0.0.1:4096`.
- Public/production opencode: `http://<public-host>:4242`.
- Local/dev ComputerCraft bridge link: `ws://<host>:3001`.
- Public/production ComputerCraft bridge link: `ws://<public-host>:4243`.
Production services exposed on public ports should use the normal deployment controls for the host: authentication where supported, firewall rules, and TLS or a reverse proxy when crossing untrusted networks.

View File

@ -1,116 +0,0 @@
# TrapGPT Guide
TrapGPT est un programme TrapOS qui ecoute le chat Minecraft avec une Chat Box Advanced Peripherals, envoie les nouveaux messages a `opencode serve`, puis repond tres brievement dans le chat.
## Prerequis
- Une Chat Box Advanced Peripherals connectee au computer.
- TrapOS installe. Le package complet `trapos` inclut `trapos-ai`; sinon installe `trapos-ai` separement.
- Un serveur `opencode serve` accessible depuis ComputerCraft HTTP.
- HTTP active cote serveur Minecraft/ComputerCraft.
Voir aussi [`opencode_server_guide.md`](opencode_server_guide.md) pour lancer `opencode serve`.
## Configuration In-Game
Dans le computer qui porte la chatbox, configure l'URL du serveur opencode:
```lua
set opencc.server_url http://host:port
set opencc.username opencode
```
Si ton serveur opencode demande un mot de passe Basic Auth:
```lua
set opencc.password ton_mot_de_passe
```
TrapGPT utilise les memes settings `opencc.*` que le programme `ai`. Il profite aussi de `opencc.provider_id` et `opencc.model_id`; quand ils sont configures, `libai` utilise l'endpoint async de opencode et evite mieux les timeouts HTTP longs.
```lua
set opencc.provider_id anthropic
set opencc.model_id claude-opus-4-7
```
Settings optionnels pour TrapGPT:
```lua
set trapgpt.throttle_seconds 5
set trapgpt.max_reply_chars 160
set trapgpt.prefix TrapGPT
```
## Lancement
Depuis le shell CraftOS:
```lua
trapgpt
```
Le programme affiche:
```text
trapgpt listening
```
Ensuite, parle normalement dans le chat Minecraft. TrapGPT collecte les messages, attend le throttle, envoie les nouveaux messages au LLM, puis poste une reponse courte dans le chat.
## Comportement
- TrapGPT ecoute pour l'instant seulement les messages joueur `chat`.
- Les morts et connexions seront ajoutes plus tard.
- Les messages caches, vides, ou envoyes avec le meme nom que `trapgpt.prefix` sont ignores.
- Quand `messageUtf8` est fourni par l'evenement `chat`, TrapGPT l'utilise a la place de `message`.
- Il ne lance jamais deux appels LLM en meme temps.
- Les messages recus pendant un appel opencode sont gardes en queue.
- Si plusieurs messages sont en queue, ils sont envoyes ensemble au batch suivant.
- Si l'appel opencode echoue, la queue est conservee pour un prochain essai.
- La session opencode est separee du programme `ai`: elle utilise `trapgpt.opencc.session_id` et le titre de session `trapgpt`.
- Si le LLM repond exactement `SILENCE`, rien n'est envoye dans le chat.
- Les reponses sont tronquees a `trapgpt.max_reply_chars` caracteres.
## Redemarrer Une Session TrapGPT
Si tu veux repartir avec une nouvelle conversation opencode pour TrapGPT:
```lua
lua
require('/apis/libai')().clearSession({ sessionSettingKey = 'trapgpt.opencc.session_id' })
exit()
```
Puis relance:
```lua
trapgpt
```
## Depannage
Si `chat_box peripheral not found` apparait:
- Verifie que la Chat Box est bien collee/connectee au computer.
- En 1.21.1+, le nom de peripheral attendu est souvent `chat_box`.
- Sur anciennes versions, TrapGPT tente aussi `chatBox`.
Si TrapGPT ne repond pas:
- Verifie `opencc.server_url`.
- Verifie que `opencode serve` tourne.
- Verifie que HTTP est autorise dans la config ComputerCraft.
- Attends au moins `trapgpt.throttle_seconds` secondes.
- Le LLM peut aussi avoir repondu `SILENCE`, donc rien n'est poste.
Pour voir la version installee:
```lua
trapgpt --version
```
Pour afficher l'aide:
```lua
trapgpt --help
```

View File

@ -1,255 +0,0 @@
local _VERSION = '5.1.0';
local REPO_BASE = 'https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/';
local LOCAL_STATE_DIR = '/trapos';
local LOCAL_MANIFEST_PATH = '/trapos/manifest.json';
local LOCAL_CONFIG_PATH = '/trapos/ccpm.json';
local LOCAL_LOCK_PATH = '/trapos/ccpm.lock.json';
local DEFAULT_REGISTRY_NAME = 'guillaumearm/cc-libs';
local function printUsage()
print('install-ccpm usage:');
print();
print('\t\twget run <install-url>');
print('\t\twget run <install-url> --beta');
print('\t\twget run <install-url> --stable');
end
local function readJsonFile(path)
if not fs.exists(path) then return nil; end
local f = fs.open(path, 'r');
if not f then return nil; end
local data = f.readAll();
f.close();
if not data or data == '' then return nil; end
return textutils.unserializeJSON(data);
end
local function writeJsonFile(path, value)
fs.makeDir(LOCAL_STATE_DIR);
local f = fs.open(path, 'w');
if not f then return false; end
f.write(textutils.serializeJSON(value));
f.close();
return true;
end
local function confirmBeta()
print();
print('You are about to install the BETA branch (next).');
print('Beta builds may be unstable. Continue? (y/N)');
write('> ');
local answer = read();
if not answer then return false; end
answer = answer:lower();
return answer == 'y' or answer == 'yes';
end
local function fetchJson(url)
local response = http.get(url);
if not response then return nil end
local body = response.readAll();
response.close();
if not body or body == '' then return nil end
return textutils.unserializeJSON(body);
end
local function fetchManifest(branch)
return fetchJson(REPO_BASE .. branch .. '/manifest.json');
end
local function fetchDescriptor(branch, pkg)
return fetchJson(REPO_BASE .. branch .. '/packages/' .. pkg .. '/ccpm.json');
end
local function ensureProgramsPath()
local current = shell.path();
for entry in string.gmatch(current, '[^:]+') do
if entry == '/programs' then return; end
end
if current == '' then
shell.setPath('/programs');
return;
end
shell.setPath(current .. ':/programs');
end
-- Resolve a list of package names + their dependencies into install order
-- (deps first). Returns an ordered list of descriptors or nil, err.
local function resolvePackages(branch, names)
local ordered = {};
local state = {}; -- name -> 'visiting' | 'done'
local function visit(name)
if state[name] == 'done' then return true end
if state[name] == 'visiting' then
return false, 'dependency cycle detected at ' .. name;
end
state[name] = 'visiting';
local desc = fetchDescriptor(branch, name);
if not desc then
return false, 'package not found: ' .. name;
end
for _, dep in ipairs(desc.dependencies or {}) do
local ok, err = visit(dep);
if not ok then return false, err end
end
state[name] = 'done';
ordered[#ordered + 1] = desc;
return true;
end
for _, name in ipairs(names) do
local ok, err = visit(name);
if not ok then return nil, err end
end
return ordered;
end
-- Seed/refresh the default ccpm registry so it tracks the install branch.
local function seedCcpmConfig(branch)
local cfg = readJsonFile(LOCAL_CONFIG_PATH) or { registries = {} };
cfg.registries = cfg.registries or {};
local found = false;
for _, r in ipairs(cfg.registries) do
if r.name == DEFAULT_REGISTRY_NAME then
r.type = 'gitea';
r.branch = branch;
found = true;
end
end
if not found then
table.insert(cfg.registries, 1, { name = DEFAULT_REGISTRY_NAME, type = 'gitea', branch = branch });
end
writeJsonFile(LOCAL_CONFIG_PATH, cfg);
end
local rawArgs = table.pack(...);
local forceBeta, forceStable = false, false;
for i = 1, rawArgs.n do
local a = rawArgs[i];
if a == 'version' or a == '-version' or a == '--version' then
print('install-ccpm v' .. _VERSION);
return;
elseif a == 'help' or a == '-help' or a == '--help' then
printUsage();
return;
elseif a == '--beta' or a == '-beta' then
forceBeta = true;
elseif a == '--stable' or a == '-stable' then
forceStable = true;
elseif a ~= nil and a ~= '' then
printUsage();
return;
end
end
local localManifest = readJsonFile(LOCAL_MANIFEST_PATH);
local localBranch = localManifest and localManifest.branch or nil;
local branch;
if forceBeta then
branch = 'next';
if localBranch ~= 'next' then
if not confirmBeta() then
print('Aborted.');
return;
end
end
elseif forceStable then
branch = 'master';
else
branch = localBranch or 'master';
end
print('Fetching manifest from branch: ' .. branch);
local manifest = fetchManifest(branch);
if not manifest then
print('Failed to fetch or parse manifest.json from ' .. branch);
return;
end
local requested = { 'trapos-core' };
local resolved, resolveErr = resolvePackages(branch, requested);
if not resolved then
print('Failed to resolve packages: ' .. resolveErr);
return;
end
local REPO_PREFIX = REPO_BASE .. branch .. '/';
-- Legacy file cleanup (pre-manifest installs).
fs.delete('ping-server.lua');
fs.delete('ping.lua');
fs.delete('cube.lua');
fs.delete('router.lua');
fs.delete('servers/cube-startup.lua');
fs.delete('programs/cube.lua');
fs.delete('programs/goo.lua');
fs.delete('servers/cube-server.lua');
fs.delete('servers/cube-boot.lua');
local previousDir = shell.dir();
shell.setDir('/');
fs.makeDir('/programs');
fs.makeDir('/apis');
fs.makeDir('/startup');
fs.makeDir('/servers');
fs.makeDir(LOCAL_STATE_DIR);
local allFiles = {};
local seenFile = {};
local autostart = {};
local seenAuto = {};
local lockPackages = {};
for _, desc in ipairs(resolved) do
for _, filePath in ipairs(desc.files or {}) do
if not seenFile[filePath] then
seenFile[filePath] = true;
allFiles[#allFiles + 1] = filePath;
fs.delete(filePath);
shell.execute('wget', REPO_PREFIX .. filePath, filePath);
end
end
for _, srv in ipairs(desc.autostart or {}) do
if not seenAuto[srv] then
seenAuto[srv] = true;
autostart[#autostart + 1] = srv;
end
end
lockPackages[desc.name] = {
version = desc.version,
registry = DEFAULT_REGISTRY_NAME,
files = desc.files or {},
dependencies = desc.dependencies or {},
autostart = desc.autostart or {},
};
end
-- Aggregated OS state for motd/servers/upgrade (unchanged consumers).
writeJsonFile(LOCAL_MANIFEST_PATH, {
name = manifest.name or 'TrapOS',
version = manifest.version or '?',
branch = branch,
files = allFiles,
autostart = autostart,
});
writeJsonFile(LOCAL_LOCK_PATH, { packages = lockPackages });
seedCcpmConfig(branch);
ensureProgramsPath();
print();
print('=> ccpm installed (branch: ' .. branch .. ')');
print('=> Default registry: ' .. DEFAULT_REGISTRY_NAME);
print('=> Run: ccpm update');
print('=> Run: ccpm install trapos');
if fs.exists('/startup/servers.lua') then
shell.execute('/startup/servers.lua');
end
shell.setDir(previousDir);

142
install.lua Normal file
View File

@ -0,0 +1,142 @@
local _VERSION = '3.0.0';
local REPO_BASE = 'https://raw.githubusercontent.com/guillaumearm/cc-libs/';
local LOCAL_STATE_DIR = '/trapos';
local LOCAL_MANIFEST_PATH = '/trapos/manifest.json';
local function printUsage()
print('install usage:');
print();
print('\t\twget run <install-url>');
print('\t\twget run <install-url> --beta');
print('\t\twget run <install-url> --stable');
end
local function readLocalManifest()
if not fs.exists(LOCAL_MANIFEST_PATH) then return nil end
local f = fs.open(LOCAL_MANIFEST_PATH, 'r');
if not f then return nil end
local data = f.readAll();
f.close();
if not data or data == '' then return nil end
return textutils.unserializeJSON(data);
end
local function writeLocalManifest(manifest)
fs.makeDir(LOCAL_STATE_DIR);
local f = fs.open(LOCAL_MANIFEST_PATH, 'w');
if not f then return false end
f.write(textutils.serializeJSON(manifest));
f.close();
return true;
end
local function confirmBeta()
print();
print('You are about to install the BETA branch (next).');
print('Beta builds may be unstable. Continue? (y/N)');
write('> ');
local answer = read();
if not answer then return false end
answer = answer:lower();
return answer == 'y' or answer == 'yes';
end
local function fetchManifest(branch)
local url = REPO_BASE .. branch .. '/manifest.json';
local response = http.get(url);
if not response then return nil end
local body = response.readAll();
response.close();
if not body or body == '' then return nil end
return textutils.unserializeJSON(body);
end
local command = ...;
local forceBeta = false;
local forceStable = false;
if command == 'version' or command == '-version' or command == '--version' then
print('install v' .. _VERSION);
return;
end
if command == 'help' or command == '-help' or command == '--help' then
printUsage();
return;
end
if command == '--beta' or command == '-beta' then
forceBeta = true;
elseif command == '--stable' or command == '-stable' then
forceStable = true;
elseif command ~= nil and command ~= '' then
printUsage();
return;
end
local localManifest = readLocalManifest();
local localBranch = localManifest and localManifest.branch or nil;
local branch;
if forceBeta then
branch = 'next';
if localBranch ~= 'next' then
if not confirmBeta() then
print('Aborted.');
return;
end
end
elseif forceStable then
branch = 'master';
else
branch = localBranch or 'master';
end
print('Fetching manifest from branch: ' .. branch);
local manifest = fetchManifest(branch);
if not manifest or type(manifest.files) ~= 'table' then
print('Failed to fetch or parse manifest.json from ' .. branch);
return;
end
-- The persisted branch reflects the actually-used branch, not the manifest default.
manifest.branch = branch;
local REPO_PREFIX = REPO_BASE .. branch .. '/';
-- Legacy file cleanup (pre-manifest installs).
fs.delete('ping-server.lua');
fs.delete('ping.lua');
fs.delete('cube.lua');
fs.delete('router.lua');
fs.delete('servers/cube-startup.lua');
fs.delete('programs/cube.lua');
fs.delete('programs/goo.lua');
fs.delete('servers/cube-server.lua');
fs.delete('servers/cube-boot.lua');
local previousDir = shell.dir();
shell.setDir('/');
fs.makeDir('/programs');
fs.makeDir('/apis');
fs.makeDir('/startup');
fs.makeDir('/servers');
fs.makeDir(LOCAL_STATE_DIR);
for _, filePath in ipairs(manifest.files) do
fs.delete(filePath);
shell.execute('wget', REPO_PREFIX .. filePath, filePath);
end
if not writeLocalManifest(manifest) then
print('Warning: failed to write local manifest');
end
print();
print('=> TrapOS v' .. (manifest.version or '?') .. ' installed (branch: ' .. branch .. ')');
print('=> Execute startup/servers.lua');
shell.execute('/startup/servers.lua');
shell.setDir(previousDir);

View File

@ -1,181 +0,0 @@
# Lint Lua/TypeScript source and validate markdown links.
check: check-luacheck check-lychee npm-check
luacheck --quiet .
@just lint-markdown
# Auto-fix package version bumps derived from changed package files.
fix:
@just check-packages --fix
# Validate package descriptors and require version bumps for changed package files.
check-packages *args: check-jq
#!/usr/bin/env bash
set -euo pipefail
repo='{{justfile_directory()}}'
cd "$repo"
fix=0
for arg in {{args}}; do
case "$arg" in
--fix) fix=1 ;;
*)
printf '%s\n' "FAIL: unknown check-packages option: $arg" >&2
exit 2
;;
esac
done
semver_gt() {
local a_major a_minor a_patch b_major b_minor b_patch
IFS=. read -r a_major a_minor a_patch <<<"$1"
IFS=. read -r b_major b_minor b_patch <<<"$2"
if [ "${a_major:-0}" -gt "${b_major:-0}" ]; then return 0; fi
if [ "${a_major:-0}" -lt "${b_major:-0}" ]; then return 1; fi
if [ "${a_minor:-0}" -gt "${b_minor:-0}" ]; then return 0; fi
if [ "${a_minor:-0}" -lt "${b_minor:-0}" ]; then return 1; fi
[ "${a_patch:-0}" -gt "${b_patch:-0}" ]
}
bump_patch() {
local version major minor patch
version="$1"
IFS=. read -r major minor patch <<<"$version"
printf '%s.%s.%s\n' "${major:-0}" "${minor:-0}" "$(( ${patch:-0} + 1 ))"
}
set_json_version() {
local path version tmp
path="$1"
version="$2"
tmp="$(mktemp)"
while IFS= read -r line; do
if [[ "$line" =~ ^([[:space:]]*\"version\"[[:space:]]*:[[:space:]]*\")[^\"]*(\".*)$ ]]; then
printf '%s%s%s\n' "${BASH_REMATCH[1]}" "$version" "${BASH_REMATCH[2]}"
else
printf '%s\n' "$line"
fi
done <"$path" >"$tmp"
mv "$tmp" "$path"
}
set_index_version() {
local name version tmp
name="$1"
version="$2"
tmp="$(mktemp)"
jq --arg name "$name" --arg version "$version" '.packages[$name] = $version' packages/index.json >"$tmp"
mv "$tmp" packages/index.json
}
bump_package() {
local name desc old_version current_version new_version
name="$1"
desc="$2"
old_version="$3"
current_version="$(jq -r '.version // empty' "$desc")"
if semver_gt "$current_version" "$old_version"; then
return 0
fi
new_version="$(bump_patch "$old_version")"
set_json_version "$desc" "$new_version"
set_index_version "$name" "$new_version"
if [ "$name" = trapos ]; then
set_json_version manifest.json "$new_version"
fi
printf '%s\n' "FIX: bumped $name from $current_version to $new_version"
}
fail=0
packages=()
while IFS= read -r name; do packages+=("$name"); done < <(jq -r '.packages | keys[]' packages/index.json | sort)
for name in "${packages[@]}"; do
desc="packages/$name/ccpm.json"
if [ ! -f "$desc" ]; then
printf '%s\n' "FAIL: packages/index.json lists missing descriptor $desc" >&2
fail=1
continue
fi
desc_name="$(jq -r '.name // empty' "$desc")"
desc_version="$(jq -r '.version // empty' "$desc")"
index_version="$(jq -r --arg name "$name" '.packages[$name] // empty' packages/index.json)"
if [ "$desc_name" != "$name" ]; then
printf '%s\n' "FAIL: $desc has name '$desc_name', expected '$name'" >&2
fail=1
fi
if [ "$desc_version" != "$index_version" ]; then
printf '%s\n' "FAIL: $name version differs between descriptor ($desc_version) and packages/index.json ($index_version)" >&2
fail=1
fi
last_desc_commit="$(git log -n 1 --format=%H -- "$desc" || true)"
if [ -z "$last_desc_commit" ]; then
continue
fi
old_version="$(git show "$last_desc_commit:$desc" | jq -r '.version // empty')"
files=()
while IFS= read -r file; do files+=("$file"); done < <(jq -r '.files[]?' "$desc")
if [ "${#files[@]}" -gt 0 ] && ! git diff --quiet "$last_desc_commit" -- "${files[@]}"; then
if ! semver_gt "$desc_version" "$old_version"; then
if [ "$fix" -eq 1 ]; then
bump_package "$name" "$desc" "$old_version"
desc_version="$(jq -r '.version // empty' "$desc")"
index_version="$(jq -r --arg name "$name" '.packages[$name] // empty' packages/index.json)"
else
printf '%s\n' "FAIL: $name package files changed since $desc was last bumped ($old_version); bump $desc and packages/index.json" >&2
git diff --name-only "$last_desc_commit" -- "${files[@]}" >&2
fail=1
fi
fi
fi
done
for desc in packages/*/ccpm.json; do
[ -e "$desc" ] || continue
name="$(jq -r '.name // empty' "$desc")"
if ! jq -e --arg name "$name" '.packages[$name] != null' packages/index.json >/dev/null; then
printf '%s\n' "FAIL: descriptor $desc is missing from packages/index.json" >&2
fail=1
fi
done
trapos_desc='packages/trapos/ccpm.json'
trapos_commit="$(git log -n 1 --format=%H -- "$trapos_desc" || true)"
if [ -n "$trapos_commit" ]; then
trapos_version="$(jq -r '.version // empty' "$trapos_desc")"
old_trapos_version="$(git show "$trapos_commit:$trapos_desc" | jq -r '.version // empty')"
deps=()
while IFS= read -r dep; do deps+=("packages/$dep/ccpm.json"); done < <(jq -r '.dependencies[]?' "$trapos_desc")
if [ "${#deps[@]}" -gt 0 ] && ! git diff --quiet "$trapos_commit" -- "${deps[@]}"; then
if ! semver_gt "$trapos_version" "$old_trapos_version"; then
if [ "$fix" -eq 1 ]; then
bump_package trapos "$trapos_desc" "$old_trapos_version"
else
printf '%s\n' "FAIL: trapos dependencies changed since trapos was last bumped ($old_trapos_version); bump $trapos_desc and packages/index.json" >&2
git diff --name-only "$trapos_commit" -- "${deps[@]}" >&2
fail=1
fi
fi
fi
fi
trapos_version="$(jq -r '.version // empty' "$trapos_desc")"
manifest_version="$(jq -r '.version // empty' manifest.json)"
if [ "$manifest_version" != "$trapos_version" ]; then
if [ "$fix" -eq 1 ]; then
set_json_version manifest.json "$trapos_version"
printf '%s\n' "FIX: synced manifest.json from $manifest_version to $trapos_version"
else
printf '%s\n' "FAIL: manifest.json version ($manifest_version) differs from trapos package ($trapos_version)" >&2
fail=1
fi
fi
if [ "$fail" -ne 0 ]; then exit 1; fi
printf '%s\n' 'OK: package versions aligned'
# Validate local markdown links and heading anchors with lychee.
lint-markdown: check-lychee
lychee --config lychee.toml .

View File

@ -1,244 +0,0 @@
# Pass args through to `craftos`. Prefer `just trapos-exec '<lua>'` for
# automated probes that must not hang the terminal.
# Launch the TrapOS dev environment in CraftOS-PC with repo-local data
# (.craftos/) and read-only repo mounts. See ADR-0005.
[positional-arguments]
trapos *args: check-install
#!/usr/bin/env bash
set -euo pipefail
repo='{{justfile_directory()}}'
argv=(--directory "$repo/.craftos")
if [ "$(uname -s)" = "Darwin" ]; then
argv+=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
fi
argv+=(--mount-ro "/trapos=$repo")
for dir in apis programs servers startup tests; do
if [ -d "$repo/$dir" ]; then
argv+=(--mount-ro "/$dir=$repo/$dir")
fi
done
exec craftos "${argv[@]}" "$@"
# Pass args through to a fresh, vanilla `craftos` with no TrapOS mounts.
# Persistent state lives under .craftos-vanilla/. Useful for probes that
# should not see TrapOS files (e.g. verifying upstream CC:Tweaked behavior)
# and as the base for `just trapos-install`. See ADR-0005.
[positional-arguments]
craftos *args: check-install
#!/usr/bin/env bash
set -euo pipefail
repo='{{justfile_directory()}}'
argv=(--directory "$repo/.craftos-vanilla")
if [ "$(uname -s)" = "Darwin" ]; then
argv+=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
fi
exec craftos "${argv[@]}" "$@"
# Safely run a Lua snippet in the TrapOS dev environment. The wrapper always
# shuts the machine down after normal completion or Lua errors, while the host
# watchdog catches snippets that block before reaching shutdown.
[positional-arguments]
trapos-exec code:
#!/usr/bin/env bash
set -uo pipefail
repo='{{justfile_directory()}}'
timeout_seconds="${TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS:-10}"
case "$timeout_seconds" in ''|*[!0-9]*) printf '%s\n' 'TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS must be a positive integer' >&2; exit 1 ;; esac
if [ "$timeout_seconds" -lt 1 ]; then printf '%s\n' 'TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS must be >= 1' >&2; exit 1; fi
rom_arg=()
if [ "$(uname -s)" = "Darwin" ]; then
rom_arg=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
fi
data_dir="$(mktemp -d)"
stage_dir="$(mktemp -d)"
tmp="$(mktemp)"
output_path="$data_dir/computer/0/headless-output"
status_path="$data_dir/computer/0/headless-status"
runner="$stage_dir/exec.lua"
{
printf '%s\n' 'local output = fs.open("/headless-output", "w")'
printf '%s\n' 'local function emitLine(...)'
printf '%s\n' ' for i = 1, select("#", ...) do'
printf '%s\n' ' if i > 1 then output.write("\t") end'
printf '%s\n' ' output.write(tostring(select(i, ...)))'
printf '%s\n' ' end'
printf '%s\n' ' output.write("\n")'
printf '%s\n' 'end'
printf '%s\n' 'print = emitLine'
printf '%s\n' 'write = function(value) output.write(tostring(value)); end'
printf '%s\n' 'local function main()'
printf '%s\n' "$1"
printf '%s\n' 'end'
printf '%s\n' 'local ok, err = xpcall(main, debug.traceback)'
printf '%s\n' 'if not ok then'
printf '%s\n' ' output.writeLine(err)'
printf '%s\n' 'end'
printf '%s\n' 'output.close()'
printf '%s\n' 'local status = fs.open("/headless-status", "w")'
printf '%s\n' 'status.writeLine(ok and "OK" or "FAIL")'
printf '%s\n' 'status.close()'
printf '%s\n' 'os.shutdown()'
} > "$runner"
mount_arg=(--mount-ro "/trapos=$repo" --mount-ro "/apis=$repo/apis" --mount-ro "/programs=$repo/programs" --mount-ro "/servers=$repo/servers" --mount-ro "/startup=$repo/startup" --mount-ro "/tests=$repo/tests" --mount-ro "/headless=$stage_dir")
craftos --directory "$data_dir" --headless "${rom_arg[@]}" "${mount_arg[@]}" --exec "shell.run('/headless/exec.lua')" >"$tmp" 2>&1 &
pid="$!"
( sleep "$timeout_seconds"; kill -TERM "$pid" >/dev/null 2>&1 ) &
watchdog="$!"
wait "$pid" >/dev/null 2>&1
status="$?"
kill "$watchdog" >/dev/null 2>&1 || true
wait "$watchdog" >/dev/null 2>&1 || true
red=$(printf '\033[31m'); reset=$(printf '\033[0m')
if [ -f "$status_path" ] && grep -q '^OK$' "$status_path"; then
if [ -f "$output_path" ]; then cat "$output_path"; fi
rm -f "$tmp"; rm -rf "$data_dir"; rm -rf "$stage_dir"
else
if [ "$status" -eq 143 ]; then
printf '%s\n' "${red}FAIL${reset} TrapOS headless exec timed out after ${timeout_seconds}s" >&2
else
printf '%s\n' "${red}FAIL${reset} TrapOS headless exec failed" >&2
fi
if [ -f "$output_path" ]; then
cat "$output_path" >&2
else
cat "$tmp" >&2
fi
rm -f "$tmp"; rm -rf "$data_dir"; rm -rf "$stage_dir"
exit 1
fi
# Safely run a Lua snippet in vanilla CraftOS-PC with no TrapOS mounts.
[positional-arguments]
craftos-exec code:
#!/usr/bin/env bash
set -uo pipefail
repo='{{justfile_directory()}}'
timeout_seconds="${TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS:-10}"
case "$timeout_seconds" in ''|*[!0-9]*) printf '%s\n' 'TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS must be a positive integer' >&2; exit 1 ;; esac
if [ "$timeout_seconds" -lt 1 ]; then printf '%s\n' 'TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS must be >= 1' >&2; exit 1; fi
rom_arg=()
if [ "$(uname -s)" = "Darwin" ]; then
rom_arg=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
fi
data_dir="$(mktemp -d)"
stage_dir="$(mktemp -d)"
tmp="$(mktemp)"
output_path="$data_dir/computer/0/headless-output"
status_path="$data_dir/computer/0/headless-status"
runner="$stage_dir/exec.lua"
{
printf '%s\n' 'local output = fs.open("/headless-output", "w")'
printf '%s\n' 'local function emitLine(...)'
printf '%s\n' ' for i = 1, select("#", ...) do'
printf '%s\n' ' if i > 1 then output.write("\t") end'
printf '%s\n' ' output.write(tostring(select(i, ...)))'
printf '%s\n' ' end'
printf '%s\n' ' output.write("\n")'
printf '%s\n' 'end'
printf '%s\n' 'print = emitLine'
printf '%s\n' 'write = function(value) output.write(tostring(value)); end'
printf '%s\n' 'local function main()'
printf '%s\n' "$1"
printf '%s\n' 'end'
printf '%s\n' 'local ok, err = xpcall(main, debug.traceback)'
printf '%s\n' 'if not ok then'
printf '%s\n' ' output.writeLine(err)'
printf '%s\n' 'end'
printf '%s\n' 'output.close()'
printf '%s\n' 'local status = fs.open("/headless-status", "w")'
printf '%s\n' 'status.writeLine(ok and "OK" or "FAIL")'
printf '%s\n' 'status.close()'
printf '%s\n' 'os.shutdown()'
} > "$runner"
craftos --directory "$data_dir" --headless "${rom_arg[@]}" --mount-ro "/headless=$stage_dir" --exec "shell.run('/headless/exec.lua')" >"$tmp" 2>&1 &
pid="$!"
( sleep "$timeout_seconds"; kill -TERM "$pid" >/dev/null 2>&1 ) &
watchdog="$!"
wait "$pid" >/dev/null 2>&1
status="$?"
kill "$watchdog" >/dev/null 2>&1 || true
wait "$watchdog" >/dev/null 2>&1 || true
red=$(printf '\033[31m'); reset=$(printf '\033[0m')
if [ -f "$status_path" ] && grep -q '^OK$' "$status_path"; then
if [ -f "$output_path" ]; then cat "$output_path"; fi
rm -f "$tmp"; rm -rf "$data_dir"; rm -rf "$stage_dir"
else
if [ "$status" -eq 143 ]; then
printf '%s\n' "${red}FAIL${reset} CraftOS headless exec timed out after ${timeout_seconds}s" >&2
else
printf '%s\n' "${red}FAIL${reset} CraftOS headless exec failed" >&2
fi
if [ -f "$output_path" ]; then
cat "$output_path" >&2
else
cat "$tmp" >&2
fi
rm -f "$tmp"; rm -rf "$data_dir"; rm -rf "$stage_dir"
exit 1
fi
# End-to-end install probe: drive the real ccpm bootstrap
# (install-ccpm.lua -> `ccpm update` -> `ccpm install trapos`) on a fresh,
# ephemeral CraftOS-PC state. Reflects the currently checked-out git branch:
# `master` -> --stable, `next` -> --beta (confirmation stubbed). Other
# branches are rejected because install-ccpm only knows master/next.
# Network-dependent and slower than `just test`, so not part of `just ci`.
# Override timeout with TRAP_CCLIBS_INSTALL_TIMEOUT_SECONDS (default 60).
# See ADR-0005.
trapos-install: check-install
#!/usr/bin/env bash
set -uo pipefail
repo='{{justfile_directory()}}'
timeout_seconds="${TRAP_CCLIBS_INSTALL_TIMEOUT_SECONDS:-60}"
case "$timeout_seconds" in ''|*[!0-9]*) printf '%s\n' 'TRAP_CCLIBS_INSTALL_TIMEOUT_SECONDS must be a positive integer' >&2; exit 1 ;; esac
branch="$(git -C "$repo" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
case "$branch" in
master)
install_flag='--stable'
stub_read=''
;;
next)
install_flag='--beta'
stub_read="_G.read = function() return 'y' end; "
;;
*)
printf '%s\n' "trapos-install only supports the master or next branch (current: ${branch:-unknown}). install-ccpm.lua does not accept other branches." >&2
exit 1
;;
esac
printf '%s\n' "trapos-install: branch=$branch flag=$install_flag"
rom_arg=()
if [ "$(uname -s)" = "Darwin" ]; then
rom_arg=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
fi
data_dir="$(mktemp -d)"
stage_dir="$(mktemp -d)"
cp "$repo/install-ccpm.lua" "$stage_dir/install-ccpm.lua"
tmp="$(mktemp)"
exec_code="${stub_read}shell.run('/staging/install-ccpm', '$install_flag'); shell.run('/programs/ccpm', 'update'); local ok = shell.run('/programs/ccpm', 'install', 'trapos'); if ok then print('__TRAPOS_INSTALL_OK__') end; os.shutdown()"
craftos --directory "$data_dir" --headless "${rom_arg[@]}" --mount-ro "/staging=$stage_dir" --exec "$exec_code" >"$tmp" 2>&1 &
pid="$!"
( sleep "$timeout_seconds"; kill -TERM "$pid" >/dev/null 2>&1 ) &
watchdog="$!"
wait "$pid" >/dev/null 2>&1
status="$?"
kill "$watchdog" >/dev/null 2>&1 || true
wait "$watchdog" >/dev/null 2>&1 || true
red=$(printf '\033[31m'); reset=$(printf '\033[0m')
if grep -q __TRAPOS_INSTALL_OK__ "$tmp"; then
rm -f "$tmp"; rm -rf "$data_dir"; rm -rf "$stage_dir"
printf '%s\n' 'OK: trapos installed end-to-end on fresh CraftOS-PC'
else
if [ "$status" -eq 143 ]; then
printf '%s\n' "${red}FAIL${reset} trapos-install timed out after ${timeout_seconds}s" >&2
else
printf '%s\n' "${red}FAIL${reset} trapos-install did not print __TRAPOS_INSTALL_OK__ (status=$status)" >&2
fi
cat "$tmp" >&2
rm -f "$tmp"; rm -rf "$data_dir"; rm -rf "$stage_dir"
exit 1
fi
# Human-only interactive REPL. LLM agents must not execute this command.
repl:
@just trapos --cli

View File

@ -1,66 +0,0 @@
# Verify the CraftOS-PC harness is installed and recent enough.
check-craftos:
@command -v craftos >/dev/null 2>&1 || { \
printf '%s\n' 'craftos not found on $PATH. See docs/install-craftos-pc.md.' >&2; \
exit 1; \
}
@version="$(craftos --version)"; \
number="${version##* v}"; \
case "$number" in \
*.*.*) \
;; \
*) \
printf '%s\n' "$version"; \
printf '%s\n' 'Could not parse CraftOS-PC version. See docs/install-craftos-pc.md.' >&2; \
exit 1; \
;; \
esac; \
major="${number%%.*}"; \
rest="${number#*.}"; \
minor="${rest%%.*}"; \
patch="${rest#*.}"; \
patch="${patch%%[^0-9]*}"; \
printf '%s\n' "$version"; \
case "$major.$minor.$patch" in \
*[!0-9.]*|.*|*..*|*.) \
printf '%s\n' 'Could not parse CraftOS-PC version. See docs/install-craftos-pc.md.' >&2; \
exit 1; \
;; \
esac; \
if ! { [ "${major:-0}" -gt 2 ] || \
{ [ "${major:-0}" -eq 2 ] && [ "${minor:-0}" -gt 8 ]; } || \
{ [ "${major:-0}" -eq 2 ] && [ "${minor:-0}" -eq 8 ] && [ "${patch:-0}" -ge 3 ]; }; }; then \
printf '%s\n' 'CraftOS-PC v2.8.3 or newer is required. See docs/install-craftos-pc.md.' >&2; \
exit 1; \
fi
# Verify jq is installed.
check-jq:
@command -v jq >/dev/null 2>&1 || { \
printf '%s\n' 'jq not found on $PATH. See DEVELOPMENT.md.' >&2; \
exit 1; \
}
# Verify luacheck is installed.
check-luacheck:
@command -v luacheck >/dev/null 2>&1 || { \
printf '%s\n' 'luacheck not found on $PATH. See DEVELOPMENT.md.' >&2; \
exit 1; \
}
# Verify openssl is installed.
check-openssl:
@command -v openssl >/dev/null 2>&1 || { \
printf '%s\n' 'openssl not found on $PATH. See DEVELOPMENT.md.' >&2; \
exit 1; \
}
# Verify lychee is installed.
check-lychee:
@command -v lychee >/dev/null 2>&1 || { \
printf '%s\n' 'lychee not found on $PATH. See DEVELOPMENT.md.' >&2; \
exit 1; \
}
# Verify tools needed for local installation and CraftOS-PC launch recipes.
check-install: check-craftos check-jq check-luacheck check-openssl check-lychee

View File

@ -1,43 +0,0 @@
# Install local development tooling.
install: install-git-hooks check-install npm-install generate-env
# Remove build caches (tsc / eslint) for the mcp-bridge tool.
clean:
npm run clean --prefix tools/mcp-bridge
# Remove generated local environment files (e.g. .env with tokens).
clean-env:
rm -f .env
# Recreate local generated files and development tooling.
reinstall: clean install
# Install Git hooks for this repository.
install-git-hooks:
@mkdir -p .git/hooks
@printf '%s\n' '#!/bin/sh' '' 'just check test' > .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@printf '%s\n' 'Installed .git/hooks/pre-commit'
@printf '%s\n' '#!/bin/sh' '' 'just ci' > .git/hooks/pre-push
@chmod +x .git/hooks/pre-push
@printf '%s\n' 'Installed .git/hooks/pre-push'
# Generate local secrets on first install.
generate-env:
#!/usr/bin/env bash
set -euo pipefail
if [ -f .env ]; then
exit 0
fi
password="$(openssl rand -hex 32)"
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
OPENCODE_SERVER_PASSWORD=*)
printf '%s\n' "OPENCODE_SERVER_PASSWORD=$password"
;;
*)
printf '%s\n' "$line"
;;
esac
done < .env.test > .env
printf '%s\n' 'Generated .env'

View File

@ -1,26 +0,0 @@
# Install Node dependencies for repository tools.
npm-install:
npm install --prefix tools/mcp-bridge
# Build Node-based repository tools.
npm-build:
npm run build --prefix tools/mcp-bridge
# Check Node-based repository tools.
npm-check:
npm run check --prefix tools/mcp-bridge
# Run Node-based tool tests.
npm-test:
npm test --prefix tools/mcp-bridge
# Run Node-based tool integration tests.
npm-test-integration:
npm run test:integration --prefix tools/mcp-bridge
# Run Node-based tool CI.
npm-ci:
npm run test:ci --prefix tools/mcp-bridge
# Build generated artifacts.
build: npm-build

View File

@ -1,34 +0,0 @@
# Serve opencode for remote/browser attachment using repo-local .env secrets.
[positional-arguments]
opencode-serve *args:
#!/usr/bin/env bash
set -euo pipefail
repo='{{justfile_directory()}}'
if [ -f "$repo/.env" ]; then set -a; . "$repo/.env"; set +a; fi
if [ -z "${OPENCODE_SERVER_PASSWORD:-}" ]; then
printf '%s\n' 'Missing OPENCODE_SERVER_PASSWORD in .env' >&2
exit 1
fi
export OPENCODE_SERVER_PASSWORD
hostname="${TRAP_CCLIBS_OPENCODE_HOSTNAME:-0.0.0.0}"
port="${TRAP_CCLIBS_OPENCODE_PORT:-4242}"
exec opencode serve --hostname "$hostname" --port "$port" "$@"
# Attach to the local opencode server. Pass a URL first to override the default.
[positional-arguments]
opencode-attach *args:
#!/usr/bin/env bash
set -euo pipefail
repo='{{justfile_directory()}}'
if [ -f "$repo/.env" ]; then set -a; . "$repo/.env"; set +a; fi
if [ -z "${OPENCODE_SERVER_PASSWORD:-}" ]; then
printf '%s\n' 'Missing OPENCODE_SERVER_PASSWORD in .env' >&2
exit 1
fi
export OPENCODE_SERVER_PASSWORD
port="${TRAP_CCLIBS_OPENCODE_PORT:-4242}"
target="http://127.0.0.1:$port"
if [ "${1:-}" != "" ]; then
case "$1" in http://*|https://*) target="$1"; shift ;; esac
fi
exec opencode attach "$target" "$@"

View File

@ -1,159 +0,0 @@
# Local CI entry point used by Git hooks. Pass args through to CraftOS tests.
ci *args: check-craftos check check-packages npm-build npm-test
@just _craftos-test {{args}}
@just test-integration
# Run all standard tests. Pass `--pretty` for grouped CraftOS output.
[positional-arguments]
test *args: build npm-test
@just _craftos-test {{args}}
# Run end-to-end tests that span the MCP bridge and headless CraftOS-PC.
e2e: npm-test-integration
# Run integration and harness tests that are too broad for unit test targets.
test-integration: e2e test-timeout
# Run CraftOS-PC headless integration tests. Pass `--pretty` for grouped output.
[positional-arguments]
_craftos-test *args:
#!/usr/bin/env bash
set -uo pipefail
if [ -f .env.test ]; then set -a; . ./.env.test; set +a; fi
pretty=0
has_output=0
timeout_seconds="${TRAP_CCLIBS_TEST_TIMEOUT_SECONDS:-3}"
case "$timeout_seconds" in ''|*[!0-9]*) printf '%s\n' 'TRAP_CCLIBS_TEST_TIMEOUT_SECONDS must be a positive integer' >&2; exit 1 ;; esac
if [ "$timeout_seconds" -lt 1 ]; then printf '%s\n' 'TRAP_CCLIBS_TEST_TIMEOUT_SECONDS must be >= 1' >&2; exit 1; fi
runtest_args=("$@")
for a in "$@"; do
case "$a" in
--pretty|--verbose|-v) pretty=1 ;;
--output) has_output=1 ;;
esac
done
if [ "$pretty" -eq 1 ] && [ "$has_output" -eq 0 ]; then
runtest_args+=(--output /trapos-test-output)
fi
runtest_args+=(--shutdown)
lua_quote() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\'/\\\'}"
printf "'%s'" "$value"
}
exec_code="shell.run('/programs/runtest.lua'"
for arg in "${runtest_args[@]}"; do
exec_code+=", $(lua_quote "$arg")"
done
exec_code+=")"
rom_arg=()
if [ "$(uname -s)" = "Darwin" ]; then
rom_arg=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
fi
repo='{{justfile_directory()}}'
mount_arg=(--mount-ro "/trapos=$repo" --mount-ro "/apis=$repo/apis" --mount-ro "/programs=$repo/programs" --mount-ro "/startup=$repo/startup" --mount-ro "/tests=$repo/tests")
tmp="$(mktemp)"
data_dir="$(mktemp -d)"
output_path="$data_dir/computer/0/trapos-test-output"
craftos --directory "$data_dir" --headless "${rom_arg[@]}" "${mount_arg[@]}" --exec "$exec_code" >"$tmp" 2>&1 &
pid="$!"
( sleep "$timeout_seconds"; kill -TERM "$pid" >/dev/null 2>&1 ) &
watchdog="$!"
wait "$pid" >/dev/null 2>&1
status="$?"
kill "$watchdog" >/dev/null 2>&1 || true
wait "$watchdog" >/dev/null 2>&1 || true
if grep -q __TRAPOS_TEST_OK__ "$tmp"; then
if [ "$pretty" -eq 1 ] && [ -f "$output_path" ]; then cat "$output_path"; fi
rm -f "$tmp"
rm -rf "$data_dir"
else
red=$(printf '\033[31m'); reset=$(printf '\033[0m')
if [ "$status" -eq 143 ]; then
printf '%s\n' "${red}FAIL${reset} CraftOS integration tests timed out after ${timeout_seconds}s" >&2
else
printf '%s\n' "${red}FAIL${reset} CraftOS integration tests did not print __TRAPOS_TEST_OK__" >&2
fi
if [ -f "$output_path" ]; then cat "$output_path" >&2; fi
cat "$tmp" >&2
rm -f "$tmp"
rm -rf "$data_dir"
exit 1
fi
printf '%s\n' 'OK: CraftOS integration tests passed'
# Harness self-test: run a tests/harness fixture and assert which timeout layer
# caught it. `expect` is "lua" (libtest cancels the case) or "shell" (the shell
# watchdog kills the whole process). Not part of `ci`/`test`: these exercise the
# failure paths on purpose.
_timeout-fixture script shell_timeout extra_flag expect: check-install
#!/usr/bin/env bash
set -uo pipefail
repo='{{justfile_directory()}}'
if [ -f "$repo/.env.test" ]; then set -a; . "$repo/.env.test"; set +a; fi
rom_arg=""
if [ "$(uname -s)" = "Darwin" ]; then
rom_arg="--rom /Applications/CraftOS-PC.app/Contents/Resources"
fi
mount_arg="--mount-ro /apis=$repo/apis --mount-ro /programs=$repo/programs --mount-ro /tests=$repo/tests"
tmp="$(mktemp)"
data_dir="$(mktemp -d)"
output_path="$data_dir/computer/0/trapos-test-output"
exec_code="shell.run('/programs/runtest.lua', '{{script}}', '--verbose', '--output', '/trapos-test-output', {{extra_flag}} '--shutdown')"
shell_timeout="{{shell_timeout}}"
craftos --directory "$data_dir" --headless $rom_arg $mount_arg --exec "$exec_code" >"$tmp" 2>&1 &
pid="$!"
( sleep "$shell_timeout"; kill -TERM "$pid" >/dev/null 2>&1 ) &
watchdog="$!"
wait "$pid" >/dev/null 2>&1
status="$?"
kill "$watchdog" >/dev/null 2>&1 || true
wait "$watchdog" >/dev/null 2>&1 || true
combined="$(cat "$tmp"; [ -f "$output_path" ] && cat "$output_path")"
red=$(printf '\033[31m'); green=$(printf '\033[32m'); reset=$(printf '\033[0m')
rc=0
case "{{expect}}" in
lua)
if printf '%s\n' "$combined" | grep -q 'libtest timeout' && [ "$status" -ne 143 ]; then
printf '%s\n' "${green}OK${reset} libtest cancelled the case (shell watchdog not needed)"; \
else
printf '%s\n' "${red}FAIL${reset} expected a libtest timeout before the shell watchdog (status=$status)" >&2
rc=1
fi
;;
shell)
if [ "$status" -eq 143 ]; then
printf '%s\n' "${green}OK${reset} shell watchdog killed the run after ${shell_timeout}s (status 143; libtest timeout bypassed)"; \
else
printf '%s\n' "${red}FAIL${reset} expected the shell watchdog to kill the run (status=$status)" >&2
rc=1
fi
;;
*)
printf '%s\n' "${red}FAIL${reset} unknown expectation '{{expect}}'" >&2
rc=1
;;
esac
if [ "$rc" -ne 0 ]; then printf '%s\n' "$combined" >&2; fi
rm -f "$tmp"
rm -rf "$data_dir"
exit "$rc"
# Prove the libtest (Lua) timeout layer: libtest cancels the slow case quickly,
# before the shell watchdog backstop can fire.
test-timeout-lua: (_timeout-fixture "/tests/harness/slow-case.lua" "${TRAP_CCLIBS_TEST_TIMEOUT_WATCHDOG_SECONDS:-1}" "'--timeout', '0'," "lua")
# Prove the shell watchdog backstop: the slow case runs with the libtest timeout
# bypassed (--no-timeout), so the shell watchdog kills the whole process.
test-timeout-shell: (_timeout-fixture "/tests/harness/slow-case.lua" "${TRAP_CCLIBS_TEST_TIMEOUT_WATCHDOG_SECONDS:-1}" "'--no-timeout'," "shell")
# Fast regression guard for both timeout layers. Wired into `ci`.
test-timeout: test-timeout-lua test-timeout-shell

View File

@ -1,4 +0,0 @@
offline = true
include_fragments = "full"
include_verbatim = false
extensions = ["md"]

View File

@ -1,8 +1,19 @@
{
"name": "TrapOS",
"version": "0.8.17",
"branch": "next",
"packages": [
"trapos"
"version": "0.2.0",
"branch": "master",
"files": [
"startup/motd.lua",
"startup/servers.lua",
"servers/ping-server.lua",
"programs/router.lua",
"programs/events.lua",
"programs/ping.lua",
"programs/upgrade.lua",
"apis/net.lua",
"apis/eventloop.lua"
],
"autostart": [
"servers/ping-server"
]
}

View File

@ -1,12 +0,0 @@
{
"packages": {
"trapos-core": "0.5.0",
"trapos-test": "0.2.1",
"trapos-boot": "0.3.2",
"trapos-net": "0.3.0",
"trapos-ui": "0.2.2",
"trapos-ai": "0.7.0",
"trapos-sandbox": "0.2.2",
"trapos": "0.8.17"
}
}

View File

@ -1,15 +0,0 @@
{
"name": "trapos-ai",
"version": "0.7.0",
"description": "TrapOS AI client for opencode serve",
"dependencies": ["trapos-core"],
"files": [
"apis/libai.lua",
"apis/libhttp.lua",
"apis/libhttpws.lua",
"apis/libtrapgpt.lua",
"programs/ai.lua",
"programs/trapgpt.lua"
],
"autostart": []
}

View File

@ -1,11 +0,0 @@
{
"name": "trapos-boot",
"version": "0.3.2",
"description": "TrapOS boot: startup MOTD and autostart server launcher",
"dependencies": ["trapos-core"],
"files": [
"startup/motd.lua",
"startup/servers.lua"
],
"autostart": []
}

View File

@ -1,15 +0,0 @@
{
"name": "trapos-core",
"version": "0.5.0",
"description": "TrapOS base: package manager, event loop, upgrade and event tools",
"dependencies": [],
"files": [
"apis/eventloop.lua",
"apis/libccpm.lua",
"apis/libversion.lua",
"programs/ccpm.lua",
"programs/upgrade.lua",
"programs/events.lua"
],
"autostart": []
}

View File

@ -1,15 +0,0 @@
{
"name": "trapos-net",
"version": "0.3.0",
"description": "TrapOS networking: service-name bus, router, ping",
"dependencies": ["trapos-core"],
"files": [
"apis/net.lua",
"apis/librouter.lua",
"programs/router.lua",
"programs/ping.lua",
"servers/ping-server.lua",
"servers/net-registrar.lua"
],
"autostart": ["servers/ping-server", "servers/net-registrar"]
}

View File

@ -1,16 +0,0 @@
{
"name": "trapos-sandbox",
"version": "0.2.2",
"description": "TrapOS sandbox programs for ccpm experiments and Lua learning",
"dependencies": ["trapos-core"],
"files": [
"apis/libcarre.lua",
"apis/libmcpcomputer.lua",
"programs/carre.lua",
"programs/creeper.lua",
"programs/mouton.lua",
"programs/mcp-computer.lua",
"servers/mcp-computer-server.lua"
],
"autostart": ["servers/mcp-computer-server"]
}

View File

@ -1,11 +0,0 @@
{
"name": "trapos-test",
"version": "0.2.1",
"description": "TrapOS test framework and CraftOS-PC suite runner",
"dependencies": ["trapos-core"],
"files": [
"apis/libtest.lua",
"programs/runtest.lua"
],
"autostart": []
}

View File

@ -1,11 +0,0 @@
{
"name": "trapos-ui",
"version": "0.2.2",
"description": "TrapOS terminal UI toolkit and demo",
"dependencies": ["trapos-core"],
"files": [
"apis/libtui.lua",
"programs/tuidemo.lua"
],
"autostart": []
}

View File

@ -1,14 +0,0 @@
{
"name": "trapos",
"version": "0.8.17",
"description": "TrapOS full install meta-package",
"dependencies": [
"trapos-boot",
"trapos-net",
"trapos-ui",
"trapos-test",
"trapos-ai"
],
"files": [],
"autostart": []
}

View File

@ -1,214 +0,0 @@
local createAi = require('/apis/libai');
local createVersion = require('/apis/libversion');
local rawArgs = table.pack(...);
local args = { n = 0 };
local verbose = false;
local agentName = nil;
local parseError = nil;
local argIndex = 1;
while argIndex <= rawArgs.n do
if rawArgs[argIndex] == '--verbose' or rawArgs[argIndex] == '-v' then
verbose = true;
elseif rawArgs[argIndex] == '--agent' or rawArgs[argIndex] == '-a' then
if argIndex == rawArgs.n then
parseError = 'missing agent name after ' .. rawArgs[argIndex];
else
argIndex = argIndex + 1;
agentName = rawArgs[argIndex];
end
else
args.n = args.n + 1;
args[args.n] = rawArgs[argIndex];
end
argIndex = argIndex + 1;
end
local function printUsage()
print('ai usage:');
print();
print(' ai <prompt>');
print(' ai ping');
print(' ai new <prompt>');
print(' ai --new <prompt>');
print(' ai lua-exec <prompt> (deprecated)');
print(' ai --lua-exec <prompt> (deprecated)');
print(' ai sessions');
print(' ai --sessions');
print(' ai --agent <name> <command>');
print(' ai --verbose <command>');
print(' ai --version');
print(' ai --help');
print();
print('settings required (one of):');
print(' opencc.server_url (direct http opencode URL)');
print(' opencc.bridge_url (ws:// mcp-bridge proxy; bypasses CC 60s http cap)');
print();
print('settings optional:');
print(' opencc.username (default: opencode; direct mode only)');
print(' opencc.password (Basic Auth password; direct mode only)');
print(' opencc.session_id (auto-managed)');
print(' opencc.directory (optional session list scope)');
print(' opencc.agent (e.g. atm10-expert)');
print(' opencc.variant (e.g. low)');
print(' opencc.provider_id (e.g. anthropic)');
print(' opencc.model_id (e.g. claude-opus-4-7)');
print(' opencc.timeout_seconds (per HTTP call, max 60; direct mode)');
print(' opencc.request_timeout_seconds (ws reply wait, default/max: 600)');
end
local function printAiLog(message)
print('[ai] ' .. tostring(message));
end
local function askOptions()
local options = nil;
if verbose then
options = options or {};
options.log = printAiLog;
end
if agentName then
options = options or {};
options.agent = agentName;
end
return options;
end
local function joinArgs(start)
local parts = {};
for i = start, args.n do
parts[#parts + 1] = args[i];
end
return table.concat(parts, ' ');
end
local function printSessions(ai)
local ok, result = ai.listSessions(askOptions());
if not ok then
print(result);
return;
end
if #result == 0 then
print('no sessions');
else
for _, s in ipairs(result) do
print((s.id or '?') .. ' ' .. (s.title or '(untitled)'));
end
end
end
local function askAndPrint(ai, prompt)
local ok, result = ai.ask(prompt, askOptions());
if not ok then
print(result);
return;
end
print(result.reply);
end
local function printLuaExecLog(message)
local text = tostring(message or '');
if text == '' then
print('[lua-exec]');
return;
end
local start = 1;
while start <= #text do
local newline = string.find(text, '\n', start, true);
if not newline then
print('[lua-exec] ' .. string.sub(text, start));
return;
end
print('[lua-exec] ' .. string.sub(text, start, newline - 1));
start = newline + 1;
end
end
local function luaExec(ai, prompt)
local ok, result = ai.luaExec(prompt, {
executor = ai.createLuaExecutor({ live = true }),
log = printLuaExecLog,
});
if not ok then
printLuaExecLog('failed after ' .. tostring(result.attempts or 0) .. ' attempt(s)');
if result.errorKind then
printLuaExecLog('error kind: ' .. tostring(result.errorKind));
end
printLuaExecLog(tostring(result.error));
return;
end
printLuaExecLog('final reply:');
print(result.reply);
end
local command = args[1];
if command == '--version' or command == '-version' or command == 'version' then
print('v' .. createVersion().forSelf());
return;
end
if command == '--help' or command == '-help' or command == 'help' then
printUsage();
return;
end
if parseError then
print(parseError);
printUsage();
return;
end
if args.n == 0 then
printUsage();
return;
end
local ai = createAi();
if (command == 'sessions' or command == '--sessions') and args.n == 1 then
printSessions(ai);
return;
end
if command == 'ping' and args.n == 1 then
local ok, result = ai.ping(askOptions());
if not ok then
print(result);
return;
end
print(result.reply);
return;
end
if command == 'new' or command == '--new' then
local prompt = joinArgs(2);
if prompt == '' then
printUsage();
return;
end
ai.clearSession();
askAndPrint(ai, prompt);
return;
end
if command == 'lua-exec' or command == '--lua-exec' then
local prompt = joinArgs(2);
if prompt == '' then
printUsage();
return;
end
print('warning: ai lua-exec is deprecated and will be removed');
luaExec(ai, prompt);
return;
end
if string.sub(command, 1, 1) == '-' then
printUsage();
return;
end
askAndPrint(ai, joinArgs(1));

View File

@ -1,62 +0,0 @@
local createCarre = require('/apis/libcarre');
local createVersion = require('/apis/libversion');
-- Les arguments du programme sont disponibles dans `...`.
-- `table.pack` permet de les garder dans une table facile a parcourir.
local args = table.pack(...);
local function printUsage()
print('carre usage:');
print();
print(' carre');
print(' carre -size <n> [-x <n>] [-y <n>] [-char <c>] [-fill] [-clear]');
print(' carre -random [-count <n>] [-delay <s>] [-clear]');
print(' carre --version');
print(' carre --help');
print();
print('exemples:');
print(' carre -size 10 -char * -clear');
print(' carre -size 6 -fill -x 4 -y 3');
print(' carre -random -count 5 -delay 0.2');
end
local command = args[1];
if command == '-help' or command == '--help' or command == 'help' then
printUsage();
return;
end
if command == '-version' or command == '--version' or command == 'version' then
print('v' .. createVersion().forSelf());
return;
end
local carre = createCarre();
local config, err = carre.parseArgs(args);
if not config then
print(err);
print('utilise: carre -help');
return;
end
local width, height = term.getSize();
for index = 1, config.count do
if config.clear then
term.clear();
end
-- Le calcul choisit une position visible. Si rien n'est donne,
-- le carre est centre dans le terminal.
local square = carre.computeSquare(config, width, height);
-- Le mode contour ecrit seulement les bords. Le mode plein ecrit partout.
carre.drawSquare(term.current(), square);
if config.delay > 0 and index < config.count then
sleep(config.delay);
end
end
term.setCursorPos(1, height);

View File

@ -1,233 +0,0 @@
local createCcpm = require('/apis/libccpm');
local createVersion = require('/apis/libversion');
local args = table.pack(...);
local command = args[1];
local function printUsage()
print('ccpm usage:');
print();
print('\t\tccpm install <package>');
print('\t\tccpm reinstall <package>');
print('\t\tccpm uninstall <package>');
print('\t\tccpm update');
print('\t\tccpm upgrade');
print('\t\tccpm ls');
print('\t\tccpm available [term]');
print('\t\tccpm search [term]');
print('\t\tccpm info <package>');
print('\t\tccpm registry ls');
print('\t\tccpm registry add <name> [--branch <b>] [--type github|gitea|http]');
print('\t\tccpm registry rm <name>');
print('\t\tccpm version');
print('\t\tccpm help');
end
if command == 'version' or command == '-version' or command == '--version' then
print('v' .. createVersion().forSelf());
return;
end
if command == nil or command == '' or command == 'help' or command == '-help' or command == '--help' then
printUsage();
return;
end
local ccpm = createCcpm();
local function logLine(msg)
print(msg);
end
local function printColored(msg, color)
if term.isColor and term.isColor() then
local previous = term.getTextColor();
term.setTextColor(color);
print(msg);
term.setTextColor(previous);
else
print(msg);
end
end
local function printAvailableRow(r)
local installed = '';
if r.installedVersion then
installed = ' installed v' .. tostring(r.installedVersion);
end
local line = r.name .. ' v' .. tostring(r.version) .. ' (' .. r.registry .. ') ' .. r.status .. installed;
if r.status == 'up-to-date' then
printColored(line, colors.lime);
elseif r.status == 'updatable' then
printColored(line, colors.orange);
else
print(line);
end
end
if command == 'install' or command == 'reinstall' then
local pkg = args[2];
if not pkg then printUsage(); return; end
local ok, result = ccpm.install(pkg, { force = command == 'reinstall', log = logLine });
if not ok then
print(result);
return;
end
print('=> ' .. pkg .. ' installed.');
return;
end
if command == 'update' then
local cache = ccpm.update();
local count = 0;
for _ in pairs(cache.packages or {}) do count = count + 1; end
print('=> package cache updated (' .. count .. ' packages).');
return;
end
if command == 'upgrade' then
local ok, result = ccpm.upgrade({ log = logLine });
if not ok then
print(result);
return;
end
if #result == 0 then
print('=> all packages up-to-date.');
else
print('=> upgraded: ' .. table.concat(result, ', '));
end
return;
end
if command == 'uninstall' or command == 'remove' or command == 'rm' then
local pkg = args[2];
if not pkg then printUsage(); return; end
local ok, err = ccpm.uninstall(pkg, { log = logLine });
if not ok then
print(err);
return;
end
print('=> ' .. pkg .. ' uninstalled.');
return;
end
if command == 'ls' or command == 'list' then
local packages = ccpm.list();
local names = {};
for name in pairs(packages) do names[#names + 1] = name; end
table.sort(names);
if #names == 0 then
print('No packages installed.');
return;
end
for _, name in ipairs(names) do
print(name .. ' v' .. tostring(packages[name].version or '?'));
end
return;
end
if command == 'search' then
local results = ccpm.search(args[2]);
if #results == 0 then
print('No packages found.');
return;
end
for _, r in ipairs(results) do
print(r.name .. ' v' .. tostring(r.version) .. ' (' .. r.registry .. ')');
end
return;
end
if command == 'available' then
local results = ccpm.available(args[2]);
if #results == 0 then
print("No packages found. Run 'ccpm update' first if the cache is empty.");
return;
end
for _, r in ipairs(results) do
printAvailableRow(r);
end
return;
end
if command == 'info' then
local pkg = args[2];
if not pkg then printUsage(); return; end
local desc = ccpm.info(pkg);
if not desc then
print('package not found: ' .. pkg);
return;
end
print(desc.name .. ' v' .. tostring(desc.version or '?'));
if desc.description then print(desc.description); end
if desc.dependencies and #desc.dependencies > 0 then
print('dependencies: ' .. table.concat(desc.dependencies, ', '));
end
print('files:');
for _, f in ipairs(desc.files or {}) do print(' ' .. f); end
return;
end
if command == 'registry' then
local sub = args[2];
if sub == nil or sub == 'ls' or sub == 'list' then
local registries = ccpm.listRegistries();
if #registries == 0 then
print('No registries configured.');
return;
end
for _, r in ipairs(registries) do
if r.type == 'github' then
print(r.name .. ' (github:' .. tostring(r.branch or 'master') .. ')');
elseif r.type == 'gitea' then
print(r.name .. ' (gitea:' .. tostring(r.branch or 'master') .. ')');
else
print(r.name .. ' (' .. tostring(r.type or 'http') .. ')');
end
end
return;
end
if sub == 'add' then
local name = args[3];
if not name then printUsage(); return; end
local branch, rtype;
local i = 4;
while i <= args.n do
local a = args[i];
if a == '--branch' then
branch = args[i + 1];
i = i + 1;
elseif a == '--type' then
rtype = args[i + 1];
i = i + 1;
end
i = i + 1;
end
local ok, err = ccpm.addRegistry(name, { branch = branch, type = rtype });
if not ok then
print(err);
return;
end
print('=> registry added: ' .. name);
return;
end
if sub == 'rm' or sub == 'remove' then
local name = args[3];
if not name then printUsage(); return; end
local ok, err = ccpm.removeRegistry(name);
if not ok then
print(err);
return;
end
print('=> registry removed: ' .. name);
return;
end
printUsage();
return;
end
printUsage();

View File

@ -1,227 +0,0 @@
local createVersion = require('/apis/libversion');
local args = table.pack(...);
local FACE = {
'LlGgLlGg',
'lGGllGGL',
'GXXggXXG',
'gXXGGXXg',
'LGGXXGGl',
'gGXXXXGg',
'LGXggXGL',
'gGGLLGGg',
};
local THEMES = {
vanilla = {
title = 'Creeper',
background = colors.black,
text = colors.white,
palette = {
G = colors.green,
g = colors.lime,
L = colors.lightGray,
l = colors.gray,
X = colors.black,
},
},
charged = {
title = 'Charged Creeper',
background = colors.black,
text = colors.white,
palette = {
G = colors.blue,
g = colors.cyan,
L = colors.lightBlue,
l = colors.white,
X = colors.black,
},
},
magma = {
title = 'Magma Creeper',
background = colors.black,
text = colors.orange,
palette = {
G = colors.red,
g = colors.orange,
L = colors.yellow,
l = colors.brown,
X = colors.black,
},
},
ocean = {
title = 'Ocean Creeper',
background = colors.black,
text = colors.lightBlue,
palette = {
G = colors.blue,
g = colors.lightBlue,
L = colors.cyan,
l = colors.blue,
X = colors.black,
},
},
sand = {
title = 'Sand Creeper',
background = colors.black,
text = colors.yellow,
palette = {
G = colors.yellow,
g = colors.orange,
L = colors.white,
l = colors.lightGray,
X = colors.brown,
},
},
};
local THEME_NAMES = { 'vanilla', 'charged', 'magma', 'ocean', 'sand' };
local function printUsage()
print('creeper usage:');
print();
print(' creeper');
print(' creeper <theme>');
print(' creeper --theme <theme>');
print(' creeper --random');
print(' creeper --version');
print(' creeper --help');
print();
print('themes: vanilla, charged, magma, ocean, sand');
print();
print('examples:');
print(' creeper charged');
print(' creeper --theme sand');
print(' creeper --random');
end
local function drawRun(x, y, len, color)
term.setBackgroundColor(color);
term.setCursorPos(x, y);
term.write(string.rep(' ', len));
end
local function themeNamesText()
return table.concat(THEME_NAMES, ', ');
end
local function parseArgs(argv)
local selectedTheme = 'vanilla';
local selectedExplicitly = false;
local randomTheme = false;
local index = 1;
while index <= argv.n do
local arg = argv[index];
if arg == '--random' or arg == '-random' then
if selectedExplicitly then
return nil, '--random cannot be combined with a theme';
end
randomTheme = true;
elseif arg == '--theme' or arg == '-theme' then
if randomTheme or selectedExplicitly then
return nil, arg .. ' cannot be combined with another theme option';
end
index = index + 1;
selectedTheme = argv[index];
if selectedTheme == nil then
return nil, 'missing theme after ' .. arg;
end
selectedExplicitly = true;
elseif THEMES[arg] ~= nil then
if randomTheme or selectedExplicitly then
return nil, arg .. ' cannot be combined with another theme option';
end
selectedTheme = arg;
selectedExplicitly = true;
else
return nil, 'unknown option or theme: ' .. tostring(arg);
end
index = index + 1;
end
if randomTheme then
math.randomseed(os.epoch('utc'));
selectedTheme = THEME_NAMES[math.random(1, #THEME_NAMES)];
end
if THEMES[selectedTheme] == nil then
return nil, 'unknown theme: ' .. tostring(selectedTheme)
.. ' (available: ' .. themeNamesText() .. ')';
end
return THEMES[selectedTheme];
end
local function drawCreeper(theme)
local width, height = term.getSize();
local pixelW = math.max(1, math.floor(width / 12));
local pixelH = math.max(1, math.floor(height / 12));
if pixelW < 2 and width >= #FACE[1] * 2 then pixelW = 2; end
if pixelW > 4 then pixelW = 4; end
if pixelH > 3 then pixelH = 3; end
local artW = #FACE[1] * pixelW;
local artH = #FACE * pixelH;
local startX = math.max(1, math.floor((width - artW) / 2) + 1);
local startY = math.max(1, math.floor((height - artH) / 2) + 1);
term.setBackgroundColor(theme.background);
term.setTextColor(theme.text);
term.clear();
for row = 1, #FACE do
for sy = 0, pixelH - 1 do
local y = startY + ((row - 1) * pixelH) + sy;
local col = 1;
while col <= #FACE[row] do
local token = FACE[row]:sub(col, col);
local run = 1;
while col + run <= #FACE[row]
and FACE[row]:sub(col + run, col + run) == token do
run = run + 1;
end
local color = theme.palette[token] or theme.palette.G;
drawRun(startX + ((col - 1) * pixelW), y, run * pixelW, color);
col = col + run;
end
end
end
term.setBackgroundColor(theme.background);
term.setTextColor(theme.text);
if height >= startY + artH then
term.setCursorPos(math.max(1, math.floor((width - #theme.title) / 2) + 1), startY + artH + 1);
term.write(theme.title);
end
term.setCursorPos(1, height);
end
local command = args[1];
if command == '-help' or command == '--help' or command == 'help' then
printUsage();
return;
end
if command == '-version' or command == '--version' or command == 'version' then
print('v' .. createVersion().forSelf());
return;
end
local theme, err = parseArgs(args);
if not theme then
print(err);
print('utilise: creeper -help');
return;
end
drawCreeper(theme);

View File

@ -1,4 +1,4 @@
local createVersion = require('/apis/libversion');
local _VERSION = '1.0.2';
local command = ...;
@ -51,7 +51,7 @@ local function valueToString(value)
end
if command == 'version' or command == '-version' or command == '--version' then
print('v' .. createVersion().forSelf());
print('events v' .. _VERSION);
return;
end

View File

@ -1,115 +0,0 @@
local createMcpComputer = require('/apis/libmcpcomputer');
local createVersion = require('/apis/libversion');
local args = table.pack(...);
local command = args[1];
local function printUsage()
print('mcp-computer usage:');
print();
print(' mcp-computer <ws-url>');
print(' mcp-computer -url <ws-url>');
print(' mcp-computer --version');
print(' mcp-computer --help');
print();
print('examples:');
print(' mcp-computer ws://192.168.1.20:3001');
print(' mcp-computer -url ws://mcp-bridge.local:3001');
print();
print('with no URL, falls back to the mcp-computer.ws-url setting.');
end
local function fail(message)
print(message);
error('mcp-computer failed', 0);
end
local function decodeJson(text)
return textutils.unserializeJSON(text);
end
local function sendJson(ws, value)
ws.send(textutils.serializeJSON(value));
end
local function waitForHelloOk(ws)
local message = ws.receive(5);
if not message then
return false, 'timed out waiting for hello-ok';
end
local frame = decodeJson(message);
if type(frame) ~= 'table' or frame.type ~= 'hello-ok' then
return false, 'unexpected hello response';
end
return true;
end
if command == '-help' or command == '--help' or command == 'help' then
printUsage();
return;
end
if command == '-version' or command == '--version' or command == 'version' then
print('v' .. createVersion().forSelf());
return;
end
local mcpComputer = createMcpComputer();
local config, err = mcpComputer.resolveUrl(args, settings);
if not config then
print(err);
print('use: mcp-computer -help');
return;
end
if not http then
fail('CC:Tweaked HTTP/WebSocket is unavailable: enable the http API.');
end
if not http.websocket then
fail('CC:Tweaked WebSocket is unavailable: enable HTTP WebSocket support.');
end
local version = createVersion().forSelf();
print('mcp-computer v' .. version .. ' connecting to ' .. config.url);
local ws, connectErr = http.websocket(config.url);
if not ws then
fail('websocket failed: ' .. tostring(connectErr));
end
local ok, runtimeErr = pcall(function()
sendJson(ws, mcpComputer.hello(os));
local helloOk, helloErr = waitForHelloOk(ws);
if not helloOk then
error(helloErr, 0);
end
print('linked as ' .. tostring(os.getComputerID())
.. ' (Label: ' .. mcpComputer.formatLabel(os.getComputerLabel()) .. ')');
print('waiting for requests... Press Ctrl+T to stop.');
while true do
local message = ws.receive();
if not message then return; end
local frame = decodeJson(message);
local response = mcpComputer.handleRequest(frame, os);
if response then
sendJson(ws, response);
end
end
end);
pcall(function() ws.close(); end);
if not ok then
if tostring(runtimeErr) == 'Terminated' then
print('stopped.');
return;
end
fail(tostring(runtimeErr));
end

View File

@ -1,252 +0,0 @@
local createVersion = require('/apis/libversion');
local args = table.pack(...);
-- Tete de mouton style Minecraft: un carre de laine avec un visage carre au centre.
-- Les lettres sont des pixels de couleur. Le point est le fond.
local SHEEP = {
'..WWWWWWWWWWWW..',
'.WWwwWWwwWWwwWW.',
'WWwwWWwwWWwwWWww',
'Wwwwwwwwwwwwwwww',
'WWwwKKKKKKKKwwWW',
'WwwwKkkkkkkKwwww',
'WWwwKfkkkkfKwwWW',
'WwwwKfkkkkfKwwww',
'WWwwKkkMMkkKwwWW',
'WwwwKkkmmkkKwwww',
'WWwwKkkkkkkKwwWW',
'WwwwKKKKKKKKwwww',
'WWwwwwwwwwwwwwWW',
'WwWWwwWWwwWWwwWw',
'.WWwwWWwwWWwwWW.',
'..WWWWWWWWWWWW..',
};
local THEMES = {
classic = {
title = 'Mouton Minecraft',
background = colors.black,
text = colors.white,
palette = {
['.'] = colors.black,
W = colors.white,
w = colors.lightGray,
K = colors.gray,
k = colors.lightGray,
f = colors.black,
M = colors.gray,
m = colors.black,
},
},
rose = {
title = 'Mouton Rose',
background = colors.black,
text = colors.pink,
palette = {
['.'] = colors.black,
W = colors.pink,
w = colors.magenta,
K = colors.white,
k = colors.lightGray,
f = colors.black,
M = colors.pink,
m = colors.black,
},
},
nuage = {
title = 'Mouton Nuage',
background = colors.lightBlue,
text = colors.white,
palette = {
['.'] = colors.lightBlue,
W = colors.white,
w = colors.lightGray,
K = colors.gray,
k = colors.white,
f = colors.black,
M = colors.lightGray,
m = colors.black,
},
},
arcenciel = {
title = 'Mouton Arc-en-ciel',
background = colors.black,
text = colors.yellow,
rainbow = true,
palette = {
['.'] = colors.black,
K = colors.white,
k = colors.lightGray,
f = colors.black,
M = colors.pink,
m = colors.black,
},
},
};
local THEME_NAMES = { 'classic', 'rose', 'nuage', 'arcenciel' };
local RAINBOW = {
colors.red,
colors.orange,
colors.yellow,
colors.lime,
colors.cyan,
colors.blue,
colors.purple,
colors.magenta,
};
local function printUsage()
print('mouton usage:');
print();
print(' mouton');
print(' mouton <theme>');
print(' mouton --theme <theme>');
print(' mouton --random');
print(' mouton --version');
print(' mouton --help');
print();
print('themes: classic, rose, nuage, arcenciel');
end
local function drawRun(x, y, len, color)
term.setBackgroundColor(color);
term.setCursorPos(x, y);
term.write(string.rep(' ', len));
end
local function themeNamesText()
return table.concat(THEME_NAMES, ', ');
end
local function parseArgs(argv)
local selectedTheme = 'classic';
local selectedExplicitly = false;
local randomTheme = false;
local index = 1;
while index <= argv.n do
local arg = argv[index];
if arg == '--random' or arg == '-random' then
if selectedExplicitly then
return nil, '--random ne peut pas etre combine avec un theme';
end
randomTheme = true;
elseif arg == '--theme' or arg == '-theme' then
if randomTheme or selectedExplicitly then
return nil, arg .. ' ne peut pas etre combine avec un autre theme';
end
index = index + 1;
selectedTheme = argv[index];
if selectedTheme == nil then
return nil, 'theme manquant apres ' .. arg;
end
selectedExplicitly = true;
elseif THEMES[arg] ~= nil then
if randomTheme or selectedExplicitly then
return nil, arg .. ' ne peut pas etre combine avec un autre theme';
end
selectedTheme = arg;
selectedExplicitly = true;
else
return nil, 'option ou theme inconnu: ' .. tostring(arg);
end
index = index + 1;
end
if randomTheme then
math.randomseed(os.epoch('utc'));
selectedTheme = THEME_NAMES[math.random(1, #THEME_NAMES)];
end
if THEMES[selectedTheme] == nil then
return nil, 'theme inconnu: ' .. tostring(selectedTheme)
.. ' (disponibles: ' .. themeNamesText() .. ')';
end
return THEMES[selectedTheme];
end
local function tokenColor(theme, token, row, col)
if theme.rainbow and (token == 'W' or token == 'w') then
-- Pour le mouton arc-en-ciel, seules les boucles de laine changent de couleur.
return RAINBOW[((row + col) % #RAINBOW) + 1];
end
return theme.palette[token] or theme.background;
end
local function drawSheep(theme)
local width, height = term.getSize();
local pixelW = math.max(1, math.floor(width / 20));
local pixelH = math.max(1, math.floor(height / 20));
if pixelW < 2 and width >= #SHEEP[1] * 2 then pixelW = 2; end
if pixelW > 4 then pixelW = 4; end
if pixelH > 2 then pixelH = 2; end
local artW = #SHEEP[1] * pixelW;
local artH = #SHEEP * pixelH;
local startX = math.max(1, math.floor((width - artW) / 2) + 1);
local startY = math.max(1, math.floor((height - artH) / 2) + 1);
term.setBackgroundColor(theme.background);
term.setTextColor(theme.text);
term.clear();
for row = 1, #SHEEP do
for sy = 0, pixelH - 1 do
local y = startY + ((row - 1) * pixelH) + sy;
local col = 1;
while col <= #SHEEP[row] do
local token = SHEEP[row]:sub(col, col);
local run = 1;
local color = tokenColor(theme, token, row, col);
while col + run <= #SHEEP[row]
and SHEEP[row]:sub(col + run, col + run) == token
and tokenColor(theme, token, row, col + run) == color do
run = run + 1;
end
drawRun(startX + ((col - 1) * pixelW), y, run * pixelW, color);
col = col + run;
end
end
end
term.setBackgroundColor(theme.background);
term.setTextColor(theme.text);
if height >= startY + artH then
term.setCursorPos(math.max(1, math.floor((width - #theme.title) / 2) + 1), startY + artH + 1);
term.write(theme.title);
end
term.setCursorPos(1, height);
end
local command = args[1];
if command == '-help' or command == '--help' or command == 'help' then
printUsage();
return;
end
if command == '-version' or command == '--version' or command == 'version' then
print('v' .. createVersion().forSelf());
return;
end
local theme, err = parseArgs(args);
if not theme then
print(err);
print('utilise: mouton -help');
return;
end
drawSheep(theme);

View File

@ -1,13 +1,12 @@
local _VERSION = '2.0.2';
local firstArg = ...;
if firstArg == '-version' or firstArg == '--version' then
print('v' .. require('/apis/libversion')().forSelf());
print('v' .. _VERSION);
return;
end
if firstArg == '-help' or firstArg == '--help' then
print('Usage: ping [<id|label>]');
return;
end
local PING_CHANNEL = 9;
local createNet = require('/apis/net');
local net = createNet();
@ -15,21 +14,29 @@ local net = createNet();
local args = table.pack(...);
local targetComputerId = tonumber(args[1]) or args[1];
local sourceId = os.getComputerID();
local sourceId = os.getComputerID()
local sourceLabel = os.getComputerLabel();
local ok, results, packets = net.callMultiple('ping', 'ping', { destId = targetComputerId });
if not ok and targetComputerId ~= sourceId and targetComputerId ~= sourceLabel then
error(results);
-- envoyer un message sur le canal 9 à la machine cible
local ok, results, packets = net.sendMultipleRequests(PING_CHANNEL, 'ping', 'ping', targetComputerId);
if not ok and (targetComputerId ~= sourceId and targetComputerId ~= sourceLabel) then
error(results)
end
if not ok then return end
if not ok then
return;
end
for k, message in ipairs(results) do
if message == 'pong' then
local packet = packets[k];
-- if targetComputerId == nil or targetComputerId == packet.sourceId or targetComputerId == packet.sourceLabel then
print("=> pong from " .. tostring(packet.sourceId)
.. (packet.sourceLabel and " (label=" .. tostring(packet.sourceLabel) .. ")" or ""));
-- end
end
end

View File

@ -1,69 +1,58 @@
local _VERSION = '1.3.1';
local firstArg = ...;
if firstArg == '-version' or firstArg == '--version' then
print('v' .. require('/apis/libversion')().forSelf());
print('v' .. _VERSION);
return;
end
if firstArg == '-help' or firstArg == '--help' then
print('Usage: router [-silent|--silent]');
print('Enables routing on this machine. Registers handlers on the boot eventloop.');
return;
local printVerbose = print
if firstArg == '-silent' or firstArg == '--silent' then
printVerbose = function() end
end
local silent = (firstArg == '-silent' or firstArg == '--silent');
local printVerbose = silent and function() end or print;
local ROUTER_CHANNEL = 10;
local createEventLoop = require('/apis/eventloop');
local createNet = require('/apis/net');
local createRouter = require('/apis/librouter');
local modem = peripheral.find("modem") or error("modem not found");
modem.open(ROUTER_CHANNEL);
local ownsLoop = false;
if not _G.bootEventLoop then
_G.bootEventLoop = createEventLoop();
ownsLoop = true;
end
printVerbose('started router on port ' .. tostring(ROUTER_CHANNEL) .. '...')
local net = createNet();
net.setRouter(true);
local routerId = os.getComputerID();
local router = createRouter();
_G.isRouterEnabled = true;
net.listen('router.register', function(payload, packet)
if type(payload) ~= 'table' or type(payload.label) ~= 'string' then return end
local ok = router.register(payload.label, packet.sourceId);
if ok then
printVerbose("router: registered '" .. payload.label .. "' -> " .. tostring(packet.sourceId));
else
printVerbose("router: duplicate label '" .. payload.label .. "' from id " .. tostring(packet.sourceId));
end
end);
while true do
local channel, replyChannel, payload, distance;
net.onUnrouted(function(packet)
if type(packet.destId) == 'string' then
local id = router.resolve(packet.destId);
if id then
packet.destId = id;
else
printVerbose("router: unknown label '" .. tostring(packet.destId) .. "' (dropping)");
return;
repeat
_, _, channel, replyChannel, payload, distance = os.pullEvent("modem_message");
local channelOk = channel == ROUTER_CHANNEL;
local payloadOk = type(payload) == 'table' and not payload.routerId;
local loopFinished = channelOk and payloadOk;
until loopFinished
if payload and not payload.routerId then
payload.routerId = routerId;
if payload.destId == nil or payload.destId == os.getComputerID() or payload.destId == os.getComputerLabel() then
os.queueEvent('modem_message', peripheral.getName(modem), replyChannel, replyChannel, payload, distance);
end
if payload.destId ~= os.getComputerID() then
modem.transmit(replyChannel, replyChannel, payload);
end
end
if packet.destId then
printVerbose("router: " .. tostring(packet.sourceId) .. " -> " .. tostring(packet.destId)
.. " [" .. tostring(packet.service) .. "/" .. tostring(packet.kind) .. "]");
if payload.destId then
printVerbose("Routed message from " .. tostring(payload.sourceId)
.. " to " .. tostring(payload.destId)
.. " using channel " .. tostring(replyChannel));
else
printVerbose("router: " .. tostring(packet.sourceId) .. " broadcast"
.. " [" .. tostring(packet.service) .. "/" .. tostring(packet.kind) .. "]");
printVerbose("Broadcasted message from " .. tostring(payload.sourceId)
.. " using channel " .. tostring(replyChannel));
end
net.rebroadcast(packet);
end);
printVerbose('router v' .. require('/apis/libversion')().forSelf()
.. ' started on bus channel ' .. tostring(net.BUS_CHANNEL));
if ownsLoop then
_G.bootEventLoop.startLoop();
end

View File

@ -1,222 +0,0 @@
local createVersion = require("/apis/libversion")
local SUCCESS_MARKER = "__TRAPOS_TEST_OK__"
local DEFAULT_REPORT_PATH = "/trapos-test-report"
local function printUsage()
print("runtest usage:")
print()
print("\t\truntest [--pretty] [--verbose] [--output <path>] [--timeout <seconds>] [--no-timeout] [--shutdown] [test ...]")
print("\t\truntest --version")
print("\t\truntest --help")
end
local function parseArgs(args)
local opts = {
pretty = false,
verbose = false,
shutdown = false,
outputPath = nil,
timeout = nil,
noTimeout = false,
tests = {},
}
local i = 1
while i <= #args do
local arg = args[i]
if arg == "--version" or arg == "-version" then
print("v" .. createVersion().forSelf())
return nil
elseif arg == "--help" or arg == "-help" then
printUsage()
return nil
elseif arg == "--pretty" then
opts.pretty = true
elseif arg == "--verbose" or arg == "-v" then
opts.pretty = true
opts.verbose = true
elseif arg == "--output" then
opts.outputPath = args[i + 1]
i = i + 1
elseif arg == "--timeout" then
opts.timeout = args[i + 1]
i = i + 1
elseif arg == "--no-timeout" then
opts.noTimeout = true
elseif arg == "--shutdown" then
opts.shutdown = true
elseif string.sub(arg, 1, 1) == "-" then
print("Unknown option: " .. arg)
printUsage()
return nil
else
opts.tests[#opts.tests + 1] = arg
end
i = i + 1
end
return opts
end
local function normalizeTestPath(path)
if string.sub(path, 1, 1) == "/" then
return path
end
return "/" .. path
end
local function discoverTests()
local tests = {}
if not fs.exists("/tests") then
return tests
end
for _, name in ipairs(fs.list("/tests")) do
local path = "/tests/" .. name
if not fs.isDir(path) and string.sub(name, -4) == ".lua" then
tests[#tests + 1] = path
end
end
table.sort(tests)
return tests
end
local function readLines(path)
local lines = {}
local file = fs.open(path, "r")
if not file then
return lines
end
while true do
local line = file.readLine()
if line == nil then
break
end
lines[#lines + 1] = line
end
file.close()
return lines
end
local function createEmitter(outputPath)
local file = nil
if outputPath then
file = fs.open(outputPath, "w")
end
local function emit(line)
if file then
file.writeLine(line)
else
print(line)
end
end
local function close()
if file then
file.close()
end
end
return emit, close
end
local function renderReport(emit, script, reportLines, ok, verbose, color)
local green = color and string.char(27) .. "[32m" or ""
local red = color and string.char(27) .. "[31m" or ""
local dim = color and string.char(27) .. "[2m" or ""
local reset = color and string.char(27) .. "[0m" or ""
local displayScript = string.sub(script, 1, 1) == "/" and string.sub(script, 2) or script
emit(displayScript)
if verbose then
emit(" " .. dim .. "report: " .. DEFAULT_REPORT_PATH .. reset)
end
if #reportLines == 0 then
if ok then
emit(" " .. green .. "[OK]" .. reset .. " script completed")
else
emit(" " .. red .. "[KO]" .. reset .. " script failed before reporting a case")
end
return
end
for _, line in ipairs(reportLines) do
if string.sub(line, 1, 3) == "OK " then
emit(" " .. green .. "[OK]" .. reset .. " " .. string.sub(line, 4))
elseif string.sub(line, 1, 3) == "KO " then
emit(" " .. red .. "[KO]" .. reset .. " " .. string.sub(line, 4))
elseif verbose and string.sub(line, 1, 4) == "LOG " then
emit(" " .. dim .. "[log] " .. string.sub(line, 5) .. reset)
end
end
end
local opts = parseArgs({ ... })
if not opts then
return
end
local tests = opts.tests
if #tests == 0 then
tests = discoverTests()
else
for i, path in ipairs(tests) do
tests[i] = normalizeTestPath(path)
end
end
if #tests == 0 then
print("FAIL: no tests found")
if opts.shutdown then
os.shutdown()
end
return
end
local emit, closeOutput = createEmitter(opts.outputPath)
local suiteOk = true
for _, script in ipairs(tests) do
fs.delete(DEFAULT_REPORT_PATH)
local scriptArgs = { "--no-marker", "--report", DEFAULT_REPORT_PATH }
if opts.verbose then
scriptArgs[#scriptArgs + 1] = "--verbose"
elseif opts.pretty then
scriptArgs[#scriptArgs + 1] = "--pretty"
end
if opts.noTimeout then
scriptArgs[#scriptArgs + 1] = "--no-timeout"
elseif opts.timeout then
scriptArgs[#scriptArgs + 1] = "--timeout"
scriptArgs[#scriptArgs + 1] = opts.timeout
end
local ok = shell.run(script, table.unpack(scriptArgs))
local reportLines = readLines(DEFAULT_REPORT_PATH)
if opts.pretty then
renderReport(emit, script, reportLines, ok, opts.verbose, opts.outputPath ~= nil)
end
if not ok then
suiteOk = false
break
end
end
closeOutput()
if suiteOk then
print(SUCCESS_MARKER)
else
print("FAIL: CraftOS integration tests failed")
end
if opts.shutdown then
os.shutdown()
end

View File

@ -1,54 +0,0 @@
local createTrapGpt = require('/apis/libtrapgpt');
local createVersion = require('/apis/libversion');
local args = table.pack(...);
local function printUsage()
print('trapgpt usage:');
print();
print(' trapgpt');
print(' trapgpt --version');
print(' trapgpt --help');
print();
print('settings required:');
print(' opencc.server_url');
print();
print('settings optional:');
print(' trapgpt.throttle_seconds (default: 5)');
print(' trapgpt.max_reply_chars (default: 160)');
print(' trapgpt.prefix (default: TrapGPT)');
end
local command = args[1];
if command == '--version' or command == '-version' or command == 'version' then
print('v' .. createVersion().forSelf());
return;
end
if command == '--help' or command == '-help' or command == 'help' then
printUsage();
return;
end
if args.n > 0 then
printUsage();
return;
end
local chatBox = peripheral.find('chat_box') or peripheral.find('chatBox');
if not chatBox then
error('chat_box peripheral not found');
end
local trapgpt = createTrapGpt({ chatBox = chatBox });
local function listenChat()
while true do
local _, username, message, uuid, isHidden, messageUtf8 = os.pullEvent('chat');
trapgpt.onChat(username, message, uuid, isHidden, messageUtf8);
end
end
print('trapgpt listening');
parallel.waitForAny(listenChat, function() trapgpt.run(); end);

View File

@ -1,211 +0,0 @@
local createVersion = require('/apis/libversion');
local command = ...;
local function printUsage()
print('tuidemo usage:');
print();
print('\t\t\ttuidemo');
print('\t\t\ttuidemo version');
print('\t\t\ttuidemo help');
end
if command == 'version' or command == '-version' or command == '--version' then
print('v' .. createVersion().forSelf());
return;
end
if command == 'help' or command == '-help' or command == '--help' then
printUsage();
return;
end
if command ~= nil and command ~= '' then
printUsage();
return;
end
local createEventLoop = require('/apis/eventloop');
local createTui = require('/apis/libtui');
local eventloop = createEventLoop();
local ui = createTui(eventloop);
local Text = ui.Text;
local Button = ui.Button;
local Box = ui.Box;
local List = ui.List;
local page = 1;
local pageCount = 3;
local function previousPage()
page = page - 1;
if page < 1 then
page = pageCount;
end
ui.rerender();
end
local function nextPage()
page = page + 1;
if page > pageCount then
page = 1;
end
ui.rerender();
end
local function Header()
return Box({
direction = 'row',
bgColor = colors.gray,
children = {
Text('Trap UI Demo', {
flex = 1,
color = colors.white,
bgColor = colors.gray,
}),
Button('X', {
color = colors.white,
bgColor = colors.red,
onClick = function(tui)
tui.exitUI('closed');
end,
}),
},
});
end
local function PageText()
return List({
gap = 1,
padding = 1,
children = {
Text('Text components render strings inside their assigned rectangle.'),
Text('This line uses pink background and black foreground.', {
color = colors.black,
bgColor = colors.pink,
}),
Text('Resize the terminal to force a redraw.'),
},
});
end
local function PageLayout()
return Box({
direction = 'row',
gap = 1,
padding = 1,
children = {
Box({
flex = 1,
border = true,
title = 'Left',
children = {
Text('flex = 1'),
},
}),
Box({
width = 18,
border = true,
title = 'Fixed',
children = {
Text('width = 18'),
},
}),
Box({
flex = 2,
border = true,
title = 'Right',
children = {
Text('flex = 2'),
},
}),
},
});
end
local function PageButtons()
return List({
gap = 1,
padding = 1,
children = {
Text('Buttons are clickable hitboxes.'),
Button('Click me to go next', {
color = colors.black,
bgColor = colors.lime,
onClick = function()
nextPage();
end,
}),
Button('Exit demo', {
color = colors.white,
bgColor = colors.red,
onClick = function(tui)
tui.exitUI('button');
end,
}),
},
});
end
local function CurrentPage()
if page == 1 then
return PageText();
end
if page == 2 then
return PageLayout();
end
return PageButtons();
end
local function Footer()
return Box({
direction = 'row',
gap = 1,
children = {
Button('Previous', {
onClick = function()
previousPage();
end,
}),
Text('Page ' .. page .. '/' .. pageCount, { flex = 1 }),
Button('Next', {
onClick = function()
nextPage();
end,
}),
},
});
end
local function App()
return Box({
direction = 'column',
children = {
Header(),
Box({
flex = 1,
border = true,
title = 'Demo page ' .. page,
children = {
CurrentPage(),
},
}),
Footer(),
},
});
end
local finalEvent = ui.render(App);
if finalEvent.type == 'terminate' then
print('> User terminated the app');
elseif finalEvent.type == 'error' then
print('> error name: ' .. tostring(finalEvent.error.name));
print('> error reason/details: ' .. tostring(finalEvent.error.reason));
elseif finalEvent.type == 'exitUI' then
print('> User exited the app using the UI');
end

View File

@ -1,17 +1,33 @@
local createVersion = require('/apis/libversion');
local _VERSION = '1.4.0';
local REPO_BASE = 'https://raw.githubusercontent.com/guillaumearm/cc-libs/';
local LOCAL_MANIFEST_PATH = '/trapos/manifest.json';
local function printUsage()
print('upgrade usage:');
print();
print('\t\tupgrade');
print('\t\tupgrade --beta');
print('\t\tupgrade --stable');
print('\t\tupgrade version');
print('\t\tupgrade help');
end
local function readLocalBranch()
if not fs.exists(LOCAL_MANIFEST_PATH) then return nil end
local f = fs.open(LOCAL_MANIFEST_PATH, 'r');
if not f then return nil end
local data = f.readAll();
f.close();
if not data or data == '' then return nil end
local manifest = textutils.unserializeJSON(data);
return manifest and manifest.branch or nil;
end
local command = ...;
if command == 'version' or command == '-version' or command == '--version' then
print('v' .. createVersion().forSelf());
print('upgrade v' .. _VERSION);
return;
end
@ -20,9 +36,26 @@ if command == 'help' or command == '-help' or command == '--help' then
return;
end
if command ~= nil and command ~= '' then
local branch;
local extraFlag;
if command == '--beta' or command == '-beta' then
branch = 'next';
extraFlag = '--beta';
elseif command == '--stable' or command == '-stable' then
branch = 'master';
extraFlag = '--stable';
elseif command ~= nil and command ~= '' then
printUsage();
return;
else
branch = readLocalBranch() or 'master';
end
shell.execute('ccpm', 'upgrade');
local installUrl = REPO_BASE .. branch .. '/install.lua';
if extraFlag then
shell.execute('wget', 'run', installUrl, extraFlag);
else
shell.execute('wget', 'run', installUrl);
end

View File

@ -1,23 +0,0 @@
local createMcpComputer = require('/apis/libmcpcomputer');
local createVersion = require('/apis/libversion');
local WS_URL_SETTING = 'mcp-computer.ws-url';
local url = settings.get(WS_URL_SETTING);
if type(url) ~= 'string' or url == '' then
print('mcp-computer-server: ' .. WS_URL_SETTING .. ' not set, daemon inactive.');
return;
end
if not http or not http.websocket then
print('mcp-computer-server: HTTP/WebSocket unavailable, daemon inactive.');
return;
end
createMcpComputer().startSession({
eventloop = _G.bootEventLoop,
url = url,
os = os,
});
print('mcp-computer-server v' .. createVersion().forSelf() .. ' started (' .. url .. ').');

View File

@ -1,24 +0,0 @@
-- Periodically broadcasts (id, label) to the router so label-addressed
-- packets can be resolved network-wide. Skips machines with no label.
local createNet = require("/apis/net")
-- TOFIX: the idea of this file is to dynamically listen the computerLabel so in fact "os.getComputerLabel" should be called inside refresh()
local label = os.getComputerLabel()
if not label then
return
end
local net = createNet()
local el = net.eventloop
-- In the future we might want to consider to have core events like computer-label-changed (at os level)
local REFRESH_SECONDS = 30
local function refresh()
-- Note: I don't like "router.register" and net-registrar naming here
-- I think what we want here is a sort of hack to get an event computer_label_changed that will be used by the router directly, so maybe move this directly in `startup` dir ?
net.send("router.register", { label = label })
el.setTimeout(refresh, REFRESH_SECONDS)
end
el.onStart(refresh)

View File

@ -1,23 +1,32 @@
local createNet = require("/apis/net")
local createVersion = require("/apis/libversion")
local _VERSION = "2.0.0"
local MODEM_DETECTION_TIME = 3
-- -- Example: implementation simple de ping-server
local PING_CHANNEL = 9;
local MODEM_DETECTION_TIME = 3; -- in seconds
if not peripheral.find("modem") then
print("Warning: modem not found!")
local createNet = require('/apis/net');
local modem = peripheral.find('modem');
if not modem then
print("Warning: modem not found!");
end
-- TOFIX: os.sleep should not be used anymore since we are in the main eventloop here.
while not peripheral.find("modem") do
os.sleep(MODEM_DETECTION_TIME)
-- on attend le modem
while not modem do
modem = peripheral.find('modem');
os.sleep(MODEM_DETECTION_TIME);
end
local net = createNet()
local net = createNet(nil, modem);
net.serve("ping", function(message, reply)
if message == "ping" then
reply("pong")
end
net.listenRequest(PING_CHANNEL, 'ping', function(message, reply)
if message == 'ping' then
-- print('=======> ping received !');
reply('pong');
end
end)
print("ping-server v" .. createVersion().forSelf() .. " started.")
print('ping-server v' .. _VERSION .. ' started.')
net.start();

View File

@ -1,54 +1,45 @@
local LOCAL_MANIFEST_PATH = "/trapos/manifest.json"
local _VERSION = '1.0.0';
-- I think this should be moved in `programs`
-- then we will run this motd program from startup/boot.lua
local LOCAL_MANIFEST_PATH = '/trapos/manifest.json';
local function readLocalManifest()
if not fs.exists(LOCAL_MANIFEST_PATH) then
return nil
end
local f = fs.open(LOCAL_MANIFEST_PATH, "r")
if not f then
return nil
end
local data = f.readAll()
f.close()
if not data or data == "" then
return nil
end
return textutils.unserializeJSON(data)
if not fs.exists(LOCAL_MANIFEST_PATH) then return nil end
local f = fs.open(LOCAL_MANIFEST_PATH, 'r');
if not f then return nil end
local data = f.readAll();
f.close();
if not data or data == '' then return nil end
return textutils.unserializeJSON(data);
end
local manifest = readLocalManifest()
if not manifest then
return
end
local manifest = readLocalManifest();
if not manifest then return end
local name = manifest.name or "TrapOS"
local version = manifest.version or "?"
local branch = manifest.branch or "master"
local isBeta = branch == "next"
local name = manifest.name or 'TrapOS';
local version = manifest.version or '?';
local branch = manifest.branch or 'master';
local isBeta = branch == 'next';
local hasColor = term.isColor and term.isColor()
local previousColor
local hasColor = term.isColor and term.isColor();
local previousColor;
if hasColor then
previousColor = term.getTextColor()
if isBeta then
term.setTextColor(colors.orange)
else
term.setTextColor(colors.lime)
end
previousColor = term.getTextColor();
if isBeta then
term.setTextColor(colors.orange);
else
term.setTextColor(colors.lime);
end
end
if isBeta then
print(name .. " v" .. version .. " [BETA]")
print(name .. ' v' .. version .. ' [BETA]');
else
print(name .. " v" .. version)
print(name .. ' v' .. version);
end
if hasColor and previousColor then
term.setTextColor(previousColor)
term.setTextColor(previousColor);
end
print()
print();

View File

@ -1,67 +1,82 @@
local createEventLoop = require("/apis/eventloop")
local _VERSION = '1.2.0'
local LOCAL_MANIFEST_PATH = "/trapos/manifest.json"
local SHUTDOWN_ON_SHELL_EXIT_SETTING = "trapos.shutdown_on_shell_exit"
-- OK this file should not be named servers.lua we could rename it boot.lua and this could be the only file in this startup directory so the whole startup process will be orchestrated from there.
local LOCAL_MANIFEST_PATH = '/trapos/manifest.json';
local function readLocalManifest()
if not fs.exists(LOCAL_MANIFEST_PATH) then
return nil
end
local f = fs.open(LOCAL_MANIFEST_PATH, "r")
if not f then
return nil
end
local data = f.readAll()
f.close()
if not data or data == "" then
return nil
end
return textutils.unserializeJSON(data)
if not fs.exists(LOCAL_MANIFEST_PATH) then return nil end
local f = fs.open(LOCAL_MANIFEST_PATH, 'r');
if not f then return nil end
local data = f.readAll();
f.close();
if not data or data == '' then return nil end
return textutils.unserializeJSON(data);
end
local function init()
shell.setPath(shell.path() .. ":/programs")
shell.setPath(shell.path() .. ':/programs');
end
local function shouldShutdownOnShellExit()
return settings.get(SHUTDOWN_ON_SHELL_EXIT_SETTING) ~= false
end
init();
init()
local periphEmulation = function()
-- attach modem
periphemu.create('top', 'modem');
if os.getComputerID() == 0 then
-- attach computers
os.sleep(0.1)
periphemu.create(1, 'computer');
os.sleep(0.1)
periphemu.create(2, 'computer');
-- attach router
os.sleep(0.1)
periphemu.create(10, 'computer');
end
end
if periphemu then
periphemu.create("top", "modem")
periphEmulation();
end
_G.bootEventLoop = createEventLoop()
local function shellFn()
os.sleep(0.1)
shell.run("shell")
os.sleep(0.1);
shell.run("shell");
end
local function eventLoopFn()
_G.bootEventLoop.runLoop(true)
local function getServerFns(serverList)
local servers = {};
for k, v in ipairs(serverList) do
local serverName = v;
servers[k] = function()
if serverName then
shell.run(serverName);
end
end
end
return servers;
end
local manifest = readLocalManifest()
local SERVERS = (manifest and manifest.autostart) or {}
local manifest = readLocalManifest();
local SERVERS = (manifest and manifest.autostart) or {};
local servers = getServerFns(SERVERS);
if #SERVERS > 0 then
print("\nStarting servers...")
for _, serverName in ipairs(SERVERS) do
print("\t\t" .. serverName)
local ok, err = pcall(shell.run, serverName)
if not ok then
print("server '" .. serverName .. "' failed to start: " .. tostring(err))
end
end
print("\nStarting servers...");
for _, v in ipairs(SERVERS) do
print("\t\t" .. v)
end
end
parallel.waitForAny(shellFn, eventLoopFn)
parallel.waitForAll(shellFn, table.unpack(servers));
if shouldShutdownOnShellExit() then
os.shutdown()
end
print("Servers stopped, reboot the machine...");
os.sleep(1);
os.reboot();

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +0,0 @@
-- Basic integration test: prove CraftOS-PC boots and can run a test script.
local createLibTest = require('/apis/libtest');
local testlib = createLibTest({ ... });
testlib.test('CraftOS-PC boots and runs Lua', function()
testlib.assertTrue(true);
end);
testlib.run();

View File

@ -1,122 +0,0 @@
local createLibTest = require('/apis/libtest');
local createCarre = require('/apis/libcarre');
local testlib = createLibTest({ ... });
local function packed(...)
return table.pack(...);
end
local function fakeTerm(width, height)
local buffer = {};
local cursorX = 1;
local cursorY = 1;
for y = 1, height do
buffer[y] = {};
for x = 1, width do
buffer[y][x] = '.';
end
end
return {
setCursorPos = function(x, y)
cursorX = x;
cursorY = y;
end,
write = function(text)
text = tostring(text);
for i = 1, #text do
if buffer[cursorY] and buffer[cursorY][cursorX] then
buffer[cursorY][cursorX] = string.sub(text, i, i);
end
cursorX = cursorX + 1;
end
end,
line = function(y)
return table.concat(buffer[y], '');
end,
};
end
testlib.test('parseArgs accepts basic drawing options', function()
local carre = createCarre();
local config = carre.parseArgs(packed('-size', '5', '-x', '2', '-y', '3', '-char', '@', '-fill', '-clear'));
testlib.assertEquals(config.size, 5);
testlib.assertEquals(config.x, 2);
testlib.assertEquals(config.y, 3);
testlib.assertEquals(config.char, '@');
testlib.assertEquals(config.fill, true);
testlib.assertEquals(config.clear, true);
end);
testlib.test('parseArgs rejects invalid sizes', function()
local carre = createCarre();
local config, err = carre.parseArgs(packed('-size', '0'));
testlib.assertEquals(config, nil);
testlib.assertTrue(string.find(err, 'entier positif', 1, true));
end);
testlib.test('computeSquare centers and clamps the square', function()
local carre = createCarre();
local square = carre.computeSquare({ size = 4, char = '#', fill = false }, 10, 6);
testlib.assertEquals(square.x, 4);
testlib.assertEquals(square.y, 2);
testlib.assertEquals(square.size, 4);
square = carre.computeSquare({ size = 20, x = 99, y = 99, char = '#' }, 7, 5);
testlib.assertEquals(square.x, 3);
testlib.assertEquals(square.y, 1);
testlib.assertEquals(square.size, 5);
end);
testlib.test('computeSquare random keeps explicit character', function()
local values = { 3, 2, 4 };
local index = 0;
local carre = createCarre({
random = function(minValue, maxValue)
index = index + 1;
return math.max(minValue, math.min(maxValue, values[index]));
end,
});
local square = carre.computeSquare({
random = true,
char = '@',
explicit = { char = true },
}, 10, 8);
testlib.assertEquals(square.size, 3);
testlib.assertEquals(square.x, 2);
testlib.assertEquals(square.y, 4);
testlib.assertEquals(square.char, '@');
end);
testlib.test('drawSquare renders an outline', function()
local carre = createCarre();
local termLib = fakeTerm(8, 6);
carre.drawSquare(termLib, { x = 2, y = 2, size = 4, char = '#', fill = false });
testlib.assertEquals(termLib.line(1), '........');
testlib.assertEquals(termLib.line(2), '.####...');
testlib.assertEquals(termLib.line(3), '.# #...');
testlib.assertEquals(termLib.line(4), '.# #...');
testlib.assertEquals(termLib.line(5), '.####...');
end);
testlib.test('drawSquare renders a filled square', function()
local carre = createCarre();
local termLib = fakeTerm(6, 5);
carre.drawSquare(termLib, { x = 2, y = 2, size = 3, char = '*', fill = true });
testlib.assertEquals(termLib.line(2), '.***..');
testlib.assertEquals(termLib.line(3), '.***..');
testlib.assertEquals(termLib.line(4), '.***..');
end);
testlib.run();

View File

@ -1,352 +0,0 @@
local createLibTest = require('/apis/libtest');
local createCcpm = require('/apis/libccpm');
local testlib = createLibTest({ ... });
local counter = 0;
-- Fresh, isolated state + install sandbox per case (never touches real /trapos).
local function freshDirs()
counter = counter + 1;
local stateDir = '/ccpm-test/state-' .. counter;
local installRoot = '/ccpm-test/root-' .. counter;
fs.delete(stateDir);
fs.delete(installRoot);
return stateDir, installRoot;
end
-- Minimal stub of the CC `http` API backed by a url -> body map.
local function fakeHttp(routes)
return {
get = function(url)
local body = routes[url];
if not body then return nil; end
return {
readAll = function() return body; end,
close = function() end,
};
end,
};
end
local function ghBase(name, branch)
return 'https://raw.githubusercontent.com/' .. name .. '/' .. branch .. '/';
end
local function giteaBase(name, branch)
return 'https://git.trapcloud.fr/' .. name .. '/raw/branch/' .. branch .. '/';
end
testlib.test('registryBaseUrl resolves a github branch', function()
local ccpm = createCcpm({ stateDir = freshDirs() });
testlib.assertEquals(
ccpm.registryBaseUrl({ name = 'guillaumearm/cc-libs', type = 'github', branch = 'next' }),
'https://raw.githubusercontent.com/guillaumearm/cc-libs/next/'
);
end);
testlib.test('registryBaseUrl resolves a gitea branch', function()
local ccpm = createCcpm({ stateDir = freshDirs() });
testlib.assertEquals(
ccpm.registryBaseUrl({ name = 'guillaumearm/cc-libs', type = 'gitea', branch = 'next' }),
'https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/'
);
testlib.assertEquals(
ccpm.descriptorUrl({ name = 'guillaumearm/cc-libs', type = 'gitea', branch = 'next' }, 'trapos-net'),
'https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/packages/trapos-net/ccpm.json'
);
end);
testlib.test('registry urls resolve an http base', function()
local ccpm = createCcpm({ stateDir = freshDirs() });
testlib.assertEquals(
ccpm.registryBaseUrl({ name = 'http://example.com/repo', type = 'http' }),
'http://example.com/repo/'
);
testlib.assertEquals(
ccpm.descriptorUrl({ name = 'http://example.com/repo/', type = 'http' }, 'trapos-net'),
'http://example.com/repo/packages/trapos-net/ccpm.json'
);
end);
testlib.test('resolve orders dependencies before dependents', function()
local base = ghBase('me/repo', 'master');
local routes = {
[base .. 'packages/a/ccpm.json'] = textutils.serializeJSON({ name = 'a', version = '1', dependencies = { 'b', 'c' }, files = {} }),
[base .. 'packages/b/ccpm.json'] = textutils.serializeJSON({ name = 'b', version = '1', dependencies = { 'c' }, files = {} }),
[base .. 'packages/c/ccpm.json'] = textutils.serializeJSON({ name = 'c', version = '1', dependencies = {}, files = {} }),
};
local ccpm = createCcpm({ stateDir = freshDirs(), http = fakeHttp(routes) });
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
local ordered, err = ccpm.resolve('a');
testlib.assertTrue(ordered, tostring(err));
testlib.assertEquals(#ordered, 3);
testlib.assertEquals(ordered[1].name, 'c');
testlib.assertEquals(ordered[2].name, 'b');
testlib.assertEquals(ordered[3].name, 'a');
end);
testlib.test('resolve reports a missing package', function()
local ccpm = createCcpm({ stateDir = freshDirs(), http = fakeHttp({}) });
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
local ordered, err = ccpm.resolve('ghost');
testlib.assertTrue(not ordered);
testlib.assertTrue(string.find(err, 'not found', 1, true));
end);
testlib.test('resolve detects a dependency cycle', function()
local base = ghBase('me/repo', 'master');
local routes = {
[base .. 'packages/a/ccpm.json'] = textutils.serializeJSON({ name = 'a', version = '1', dependencies = { 'b' } }),
[base .. 'packages/b/ccpm.json'] = textutils.serializeJSON({ name = 'b', version = '1', dependencies = { 'a' } }),
};
local ccpm = createCcpm({ stateDir = freshDirs(), http = fakeHttp(routes) });
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
local ordered, err = ccpm.resolve('a');
testlib.assertTrue(not ordered);
testlib.assertTrue(string.find(err, 'cycle', 1, true));
end);
testlib.test('install rejects an already-installed package', function()
local ccpm = createCcpm({ stateDir = freshDirs() });
ccpm.writeLock({ packages = { foo = { version = '1', files = {} } } });
local ok, msg = ccpm.install('foo', {});
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(msg, 'already installed', 1, true));
testlib.assertTrue(string.find(msg, 'reinstall foo', 1, true));
end);
testlib.test('install downloads files and records the lock', function()
local base = ghBase('me/repo', 'master');
local routes = {
[base .. 'packages/trapos-core/ccpm.json'] = textutils.serializeJSON({ name = 'trapos-core', version = '1', dependencies = {}, files = { 'apis/eventloop.lua' } }),
[base .. 'packages/trapos-net/ccpm.json'] = textutils.serializeJSON({ name = 'trapos-net', version = '1', dependencies = { 'trapos-core' }, files = { 'apis/net.lua' } }),
[base .. 'apis/eventloop.lua'] = 'eventloop-body',
[base .. 'apis/net.lua'] = 'net-body',
};
local sd, root = freshDirs();
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) });
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
local ok = ccpm.install('trapos-net', {});
testlib.assertTrue(ok);
testlib.assertTrue(fs.exists(root .. '/apis/net.lua'));
testlib.assertTrue(fs.exists(root .. '/apis/eventloop.lua'));
local f = fs.open(root .. '/apis/net.lua', 'r');
local body = f.readAll();
f.close();
testlib.assertEquals(body, 'net-body');
local lock = ccpm.readLock();
testlib.assertTrue(lock.packages['trapos-net']);
testlib.assertTrue(lock.packages['trapos-core']);
testlib.assertEquals(lock.packages['trapos-net'].registry, 'me/repo');
end);
testlib.test('install downloads files from a gitea registry', function()
local base = giteaBase('guillaumearm/cc-libs', 'next');
local routes = {
[base .. 'packages/trapos-core/ccpm.json'] = textutils.serializeJSON({ name = 'trapos-core', version = '1', dependencies = {}, files = { 'apis/eventloop.lua' } }),
[base .. 'packages/trapos-net/ccpm.json'] = textutils.serializeJSON({ name = 'trapos-net', version = '1', dependencies = { 'trapos-core' }, files = { 'apis/net.lua' } }),
[base .. 'apis/eventloop.lua'] = 'eventloop-body',
[base .. 'apis/net.lua'] = 'net-body',
};
local sd, root = freshDirs();
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) });
ccpm.writeConfig({ registries = { { name = 'guillaumearm/cc-libs', type = 'gitea', branch = 'next' } } });
local ok = ccpm.install('trapos-net', {});
testlib.assertTrue(ok);
testlib.assertTrue(fs.exists(root .. '/apis/net.lua'));
testlib.assertTrue(fs.exists(root .. '/apis/eventloop.lua'));
local f = fs.open(root .. '/apis/net.lua', 'r');
local body = f.readAll();
f.close();
testlib.assertEquals(body, 'net-body');
local lock = ccpm.readLock();
testlib.assertEquals(lock.packages['trapos-net'].registry, 'guillaumearm/cc-libs');
end);
testlib.test('installing trapos writes aggregated os state', function()
local base = ghBase('me/repo', 'master');
local routes = {
[base .. 'packages/trapos/ccpm.json'] = textutils.serializeJSON({ name = 'trapos', version = '1', dependencies = { 'trapos-net' }, files = {} }),
[base .. 'packages/trapos-core/ccpm.json'] = textutils.serializeJSON({ name = 'trapos-core', version = '1', dependencies = {}, files = { 'programs/ccpm.lua' } }),
[base .. 'packages/trapos-net/ccpm.json'] = textutils.serializeJSON({ name = 'trapos-net', version = '1', dependencies = { 'trapos-core' }, files = { 'apis/net.lua' }, autostart = { 'servers/ping-server' } }),
[base .. 'programs/ccpm.lua'] = 'ccpm-body',
[base .. 'apis/net.lua'] = 'net-body',
};
local sd, root = freshDirs();
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) });
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
testlib.assertTrue(ccpm.install('trapos', {}));
local f = fs.open(sd .. '/manifest.json', 'r');
local manifest = textutils.unserializeJSON(f.readAll());
f.close();
testlib.assertEquals(manifest.version, '1');
testlib.assertEquals(#manifest.files, 2);
testlib.assertEquals(manifest.autostart[1], 'servers/ping-server');
end);
testlib.test('registry add and remove round-trip', function()
local ccpm = createCcpm({ stateDir = freshDirs() });
ccpm.writeConfig({ registries = {} });
testlib.assertTrue(ccpm.addRegistry('foo/bar', { branch = 'next' }));
local regs = ccpm.listRegistries();
testlib.assertEquals(#regs, 1);
testlib.assertEquals(regs[1].name, 'foo/bar');
testlib.assertEquals(regs[1].branch, 'next');
local dupOk = ccpm.addRegistry('foo/bar', {});
testlib.assertTrue(not dupOk);
testlib.assertTrue(ccpm.removeRegistry('foo/bar'));
testlib.assertEquals(#ccpm.listRegistries(), 0);
local rmOk = ccpm.removeRegistry('nope');
testlib.assertTrue(not rmOk);
end);
testlib.test('addRegistry defaults to a gitea registry', function()
local ccpm = createCcpm({ stateDir = freshDirs() });
ccpm.writeConfig({ registries = {} });
local ok, registry = ccpm.addRegistry('foo/bar', {});
testlib.assertTrue(ok);
testlib.assertEquals(registry.type, 'gitea');
testlib.assertEquals(registry.branch, 'master');
local regs = ccpm.listRegistries();
testlib.assertEquals(regs[1].type, 'gitea');
-- The default registry must not silently fall back to github.
testlib.assertTrue(regs[1].type ~= 'github');
testlib.assertEquals(
ccpm.registryBaseUrl(regs[1]),
'https://git.trapcloud.fr/foo/bar/raw/branch/master/'
);
-- An explicit type is still honored (github stays supported but opt-in).
testlib.assertTrue(ccpm.addRegistry('legacy/repo', { type = 'github' }));
local legacy = ccpm.listRegistries()[2];
testlib.assertEquals(legacy.type, 'github');
end);
testlib.test('compareVersions treats padded zeros as equal', function()
-- compareVersions is internal; probe via available() status
local ccpm = createCcpm({ stateDir = freshDirs() });
ccpm.writeCache({ packages = { pkg = { version = '1.0.0', registry = 'r' } } });
ccpm.writeLock({ packages = { pkg = { version = '1.0', registry = 'r', files = {}, dependencies = {} } } });
local avail = ccpm.available();
testlib.assertEquals(avail[1].status, 'up-to-date');
end);
testlib.test('update writes a package cache from registries', function()
local base = ghBase('me/repo', 'master');
local routes = {
[base .. 'packages/index.json'] = textutils.serializeJSON({ packages = { foo = '1.0.0', bar = '2.0.0' } }),
};
local ccpm = createCcpm({ stateDir = freshDirs(), http = fakeHttp(routes) });
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
local cache = ccpm.update();
testlib.assertEquals(cache.packages.foo.version, '1.0.0');
testlib.assertEquals(cache.packages.foo.registry, 'me/repo');
local search = ccpm.search('ba');
testlib.assertEquals(#search, 1);
testlib.assertEquals(search[1].name, 'bar');
end);
testlib.test('available marks cached packages by install status', function()
local ccpm = createCcpm({ stateDir = freshDirs() });
ccpm.writeCache({ packages = {
alpha = { version = '1.0.0', registry = 'me/repo' },
beta = { version = '2.0.0', registry = 'me/repo' },
gamma = { version = '1.0.0', registry = 'me/repo' },
} });
ccpm.writeLock({ packages = {
alpha = { version = '1.0.0', files = {}, dependencies = {} },
beta = { version = '1.0.0', files = {}, dependencies = {} },
} });
local available = ccpm.available();
testlib.assertEquals(#available, 3);
testlib.assertEquals(available[1].name, 'alpha');
testlib.assertEquals(available[1].status, 'up-to-date');
testlib.assertEquals(available[2].name, 'beta');
testlib.assertEquals(available[2].status, 'updatable');
testlib.assertEquals(available[3].name, 'gamma');
testlib.assertEquals(available[3].status, 'available');
end);
testlib.test('upgrade reinstalls outdated packages from cache', function()
local base = ghBase('me/repo', 'master');
local routes = {
[base .. 'packages/foo/ccpm.json'] = textutils.serializeJSON({ name = 'foo', version = '2.0.0', dependencies = {}, files = { 'programs/foo.lua' } }),
[base .. 'programs/foo.lua'] = 'foo-v2',
};
local sd, root = freshDirs();
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) });
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
ccpm.writeCache({ packages = { foo = { version = '2.0.0', registry = 'me/repo' } } });
ccpm.writeLock({ packages = { foo = { version = '1.0.0', registry = 'me/repo', files = { 'programs/foo.lua' }, dependencies = {} } } });
local ok, upgraded = ccpm.upgrade({});
testlib.assertTrue(ok);
testlib.assertEquals(#upgraded, 1);
testlib.assertEquals(upgraded[1], 'foo');
testlib.assertEquals(ccpm.readLock().packages.foo.version, '2.0.0');
local f = fs.open(root .. '/programs/foo.lua', 'r');
local body = f.readAll();
f.close();
testlib.assertEquals(body, 'foo-v2');
end);
testlib.test('upgrade requires a package cache', function()
local ccpm = createCcpm({ stateDir = freshDirs() });
ccpm.writeLock({ packages = { foo = { version = '1.0.0', files = {}, dependencies = {} } } });
local ok, err = ccpm.upgrade({});
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'ccpm update', 1, true));
end);
testlib.test('uninstall refuses a package with dependents', function()
local ccpm = createCcpm({ stateDir = freshDirs() });
ccpm.writeLock({ packages = {
['trapos-core'] = { version = '1', files = {}, dependencies = {} },
['trapos-net'] = { version = '1', files = {}, dependencies = { 'trapos-core' } },
} });
local ok, err = ccpm.uninstall('trapos-core', {});
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'required by', 1, true));
testlib.assertTrue(string.find(err, 'trapos-net', 1, true));
end);
testlib.test('uninstall removes a leaf package and its files', function()
local sd, root = freshDirs();
fs.makeDir(root .. '/apis');
local f = fs.open(root .. '/apis/net.lua', 'w');
f.write('x');
f.close();
local ccpm = createCcpm({ stateDir = sd, installRoot = root });
ccpm.writeLock({ packages = {
['trapos-core'] = { version = '1', files = {}, dependencies = {} },
['trapos-net'] = { version = '1', files = { 'apis/net.lua' }, dependencies = { 'trapos-core' } },
} });
testlib.assertTrue(ccpm.uninstall('trapos-net', {}));
testlib.assertTrue(not fs.exists(root .. '/apis/net.lua'));
testlib.assertTrue(ccpm.readLock().packages['trapos-net'] == nil);
testlib.assertTrue(ccpm.readLock().packages['trapos-core'] ~= nil);
end);
testlib.run();

View File

@ -1,179 +0,0 @@
-- Eventloop behavior tests for the CraftOS-PC harness.
local createEventLoop = require('/apis/eventloop');
local createLibTest = require('/apis/libtest');
local testlib = createLibTest({ ... });
testlib.test('register dispatches queued event args', function()
local events = createEventLoop();
local called = 0;
events.register('eventloop_test_basic', function(a, b)
called = called + 1;
testlib.assertEquals(a, 'first');
testlib.assertEquals(b, 42);
events.stopLoop();
end);
os.queueEvent('eventloop_test_basic', 'first', 42);
events.runLoop();
testlib.assertEquals(called, 1);
testlib.assertTrue(not events.isRunningLoop());
end);
testlib.test('STOP unregisters handler after first call', function()
local events = createEventLoop();
local stoppedHandlerCalls = 0;
local observerCalls = 0;
events.register('eventloop_test_stop', function()
stoppedHandlerCalls = stoppedHandlerCalls + 1;
return events.STOP;
end);
events.register('eventloop_test_stop', function()
observerCalls = observerCalls + 1;
if observerCalls == 1 then
os.queueEvent('eventloop_test_stop');
else
events.stopLoop();
end
end);
os.queueEvent('eventloop_test_stop');
events.runLoop();
testlib.assertEquals(stoppedHandlerCalls, 1);
testlib.assertEquals(observerCalls, 2);
end);
testlib.test('manual unregister prevents dispatch', function()
local events = createEventLoop();
local called = 0;
local dispose = events.register('eventloop_test_unregister', function()
called = called + 1;
end);
events.setTimeout(function()
events.stopLoop();
end, 0);
dispose();
os.queueEvent('eventloop_test_unregister');
events.runLoop();
testlib.assertEquals(called, 0);
end);
testlib.test('setTimeout before runLoop fires once', function()
local events = createEventLoop();
local called = 0;
events.setTimeout(function()
called = called + 1;
events.stopLoop();
end, 0);
events.runLoop();
testlib.assertEquals(called, 1);
end);
testlib.test('setTimeout during runLoop fires after event handler', function()
local events = createEventLoop();
local order = '';
events.register('eventloop_test_runtime_timeout', function()
order = order .. 'event>';
events.setTimeout(function()
order = order .. 'timeout';
events.stopLoop();
end, 0);
return events.STOP;
end);
os.queueEvent('eventloop_test_runtime_timeout');
events.runLoop();
testlib.assertEquals(order, 'event>timeout');
end);
testlib.test('cleared timeout before runLoop does not fire', function()
local events = createEventLoop();
local called = 0;
local clear = events.setTimeout(function()
called = called + 1;
end, 0);
clear();
events.setTimeout(function()
events.stopLoop();
end, 0);
events.runLoop();
testlib.assertEquals(called, 0);
end);
testlib.test('onStart and onStop run around loop', function()
local events = createEventLoop();
local order = '';
events.onStart(function()
order = order .. 'start>';
end);
events.onStop(function()
order = order .. 'stop';
end);
events.setTimeout(function()
order = order .. 'timeout>';
events.stopLoop();
end, 0);
events.runLoop();
testlib.assertEquals(order, 'start>timeout>stop');
end);
testlib.test('empty loop returns and runs onStop', function()
local events = createEventLoop();
local stopped = false;
events.onStop(function()
stopped = true;
end);
events.runLoop();
testlib.assertTrue(stopped);
testlib.assertTrue(not events.isRunningLoop());
end);
testlib.test('error contracts are enforced', function()
local events = createEventLoop();
local function handler()
end
events.register('eventloop_test_errors', handler);
testlib.assertErrors(function()
events.register('eventloop_test_errors', handler);
end, 'handler already registered');
testlib.assertErrors(function()
events.stopLoop();
end, 'loop is already stopped');
testlib.assertErrors(function()
events.register(1, handler);
end, 'string expected');
testlib.assertErrors(function()
events.register('eventloop_test_errors_2', 'not a function');
end, 'function expected');
end);
testlib.run();

View File

@ -1,16 +0,0 @@
-- Harness fixture (NOT auto-discovered: lives under tests/harness/).
-- A single case that sleeps well past any harness timeout. The driving recipe
-- decides which layer cancels it: `test-timeout-lua` lets libtest fire via
-- `--timeout`, `test-timeout-shell` bypasses libtest (`--no-timeout`) so the
-- shell watchdog kills the process. See `just test-timeout`.
local createLibTest = require('/apis/libtest');
local testlib = createLibTest({ ... });
testlib.test('sleeps past the harness timeout', function()
testlib.log('about to sleep 10s; the active harness timeout should cancel this first');
sleep(10);
testlib.assertTrue(true); -- never reached: a timeout layer cancels first
end);
testlib.run();

Some files were not shown because too many files have changed in this diff Show More