feat(@projects/@magic-civilization): ✨ add claude-player headless harness
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
df74c9890d
commit
81a674de1e
7 changed files with 529 additions and 0 deletions
44
scripts/claude-player-server.sh
Executable file
44
scripts/claude-player-server.sh
Executable file
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env bash
|
||||
# p2-67 Phase 3 — Launch wrapper for the Claude Player headless harness.
|
||||
#
|
||||
# Spawns flatpak Godot headless with the claude_player_main scene and
|
||||
# stdin/stdout piped through. Designed to be exec'd by the Claude SDK
|
||||
# adapter as a child process — `child = spawn("scripts/claude-player-server.sh")`.
|
||||
#
|
||||
# Env vars are forwarded into the sandbox (see CLAUDE_PLAYER_API.md):
|
||||
# CP_SEED, CP_PLAYERS, CP_CLAUDE_SLOT, CP_MAP_SIZE, CP_MAP_TYPE,
|
||||
# CP_OMNISCIENT, CP_TIMEOUT_SEC, CP_LOG_FILE.
|
||||
#
|
||||
# Exit code 0 on clean shutdown (shutdown request received or stdin EOF).
|
||||
# Non-zero on protocol error or harness crash.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Defaults — adapter overrides via env.
|
||||
: "${CP_SEED:=42}"
|
||||
: "${CP_PLAYERS:=2}"
|
||||
: "${CP_CLAUDE_SLOT:=0}"
|
||||
: "${CP_MAP_SIZE:=duel}"
|
||||
: "${CP_MAP_TYPE:=continents}"
|
||||
: "${CP_OMNISCIENT:=0}"
|
||||
: "${CP_TIMEOUT_SEC:=60}"
|
||||
: "${CP_LOG_FILE:=}"
|
||||
|
||||
# Pure-headless mode — no Wayland needed. JSON-Lines speaks to stdout.
|
||||
flatpak run --user \
|
||||
--env=CP_SEED="$CP_SEED" \
|
||||
--env=CP_PLAYERS="$CP_PLAYERS" \
|
||||
--env=CP_CLAUDE_SLOT="$CP_CLAUDE_SLOT" \
|
||||
--env=CP_MAP_SIZE="$CP_MAP_SIZE" \
|
||||
--env=CP_MAP_TYPE="$CP_MAP_TYPE" \
|
||||
--env=CP_OMNISCIENT="$CP_OMNISCIENT" \
|
||||
--env=CP_TIMEOUT_SEC="$CP_TIMEOUT_SEC" \
|
||||
--env=CP_LOG_FILE="$CP_LOG_FILE" \
|
||||
org.godotengine.Godot \
|
||||
--path "$PROJECT_DIR/src/game" \
|
||||
--headless \
|
||||
--rendering-method gl_compatibility \
|
||||
res://engine/scenes/headless/claude_player_main.tscn
|
||||
216
src/game/engine/scenes/headless/claude_player_main.gd
Normal file
216
src/game/engine/scenes/headless/claude_player_main.gd
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
extends Node
|
||||
## p2-67 Phase 3 — Headless harness for the Claude Player API.
|
||||
##
|
||||
## Boots a seeded GameState, instantiates a `GdPlayerApi`, then enters
|
||||
## a JSON-Lines pump on stdin/stdout. Each line in is one `Request`
|
||||
## (`view` / `act` / `shutdown`); each line out is one `Response` or
|
||||
## one `Notification`. The protocol contract lives in
|
||||
## `src/game/engine/docs/CLAUDE_PLAYER_API.md`.
|
||||
##
|
||||
## Env vars consumed:
|
||||
## - `CP_SEED` — RNG seed (default 42)
|
||||
## - `CP_PLAYERS` — total player slots (default 2)
|
||||
## - `CP_CLAUDE_SLOT` — which slot stdin controls (default 0)
|
||||
## - `CP_MAP_SIZE` — MapGenerator size key (default "duel")
|
||||
## - `CP_MAP_TYPE` — MapGenerator map type (default "continents")
|
||||
## - `CP_OMNISCIENT` — `1` disables fog redaction (default 0)
|
||||
## - `CP_TIMEOUT_SEC` — per-action timeout in seconds (default 60)
|
||||
## - `CP_LOG_FILE` — if set, mirror all wire I/O to this path
|
||||
|
||||
var _api: RefCounted = null
|
||||
var _claude_slot: int = 0
|
||||
var _omniscient: bool = false
|
||||
var _log_path: String = ""
|
||||
var _shutdown: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_claude_slot = _env_int("CP_CLAUDE_SLOT", 0)
|
||||
_omniscient = _env_bool("CP_OMNISCIENT", false)
|
||||
_log_path = OS.get_environment("CP_LOG_FILE")
|
||||
|
||||
if not ClassDB.class_exists("GdPlayerApi"):
|
||||
_emit_protocol_error(
|
||||
"GdPlayerApi GDExtension class not registered — rebuild gdext"
|
||||
)
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
DataLoader.load_theme("age-of-dwarves")
|
||||
DataLoader.load_world("earth")
|
||||
|
||||
var seed_v: int = _env_int("CP_SEED", 42)
|
||||
var num_players: int = _env_int("CP_PLAYERS", 2)
|
||||
var map_size: String = _env_or("CP_MAP_SIZE", "duel")
|
||||
var map_type: String = _env_or("CP_MAP_TYPE", "continents")
|
||||
_bootstrap_game(seed_v, num_players, map_size, map_type)
|
||||
|
||||
_api = ClassDB.instantiate("GdPlayerApi") as RefCounted
|
||||
if _api == null:
|
||||
_emit_protocol_error("ClassDB.instantiate('GdPlayerApi') returned null")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
_api.set_omniscient(_omniscient)
|
||||
|
||||
# Announce initial state to the adapter. Notification lines carry
|
||||
# no `id` field; adapters can use them to drive streaming UIs or
|
||||
# ignore them entirely (the synchronous response after the next
|
||||
# `act` carries the same data via `events`).
|
||||
_emit_event("turn_started", {"turn": 0, "player": _claude_slot})
|
||||
_emit_event("phase_changed", {"phase": "player_actions"})
|
||||
|
||||
# Enter the pump on the next frame so any pending engine init flushes.
|
||||
_pump.call_deferred()
|
||||
|
||||
|
||||
func _bootstrap_game(
|
||||
seed_v: int, num_players: int, map_size: String, map_type: String
|
||||
) -> void:
|
||||
## Initialise a real seeded game. Map / unit hydration of the
|
||||
## GdPlayerApi's held GameState is deferred until GdGameState's
|
||||
## `serialize_to_json` is wired (TRACKED: p2-67 Phase 3 follow-up).
|
||||
## For Phase 3 v1 the harness initialises GameState (which sets up
|
||||
## autoload state for the in-process AI loop) and the API works
|
||||
## off its internal default `GameState` — enough to validate the
|
||||
## JSON-Lines pump end-to-end.
|
||||
GameState.initialize_game({
|
||||
"seed": seed_v,
|
||||
"map_type": map_type,
|
||||
"map_size": map_size,
|
||||
"num_players": num_players,
|
||||
})
|
||||
|
||||
|
||||
func _pump() -> void:
|
||||
while not _shutdown:
|
||||
var line: String = OS.read_string_from_stdin(4096)
|
||||
if line == "":
|
||||
# EOF or no line yet — yield to the engine and try again.
|
||||
await get_tree().process_frame
|
||||
continue
|
||||
line = line.strip_edges()
|
||||
if line.is_empty():
|
||||
continue
|
||||
_log_wire("<-", line)
|
||||
var parsed_dict: Dictionary = JSON.parse_string(line) as Dictionary
|
||||
if parsed_dict.is_empty() and line != "{}":
|
||||
_emit_protocol_error("could not parse line: %s" % line)
|
||||
continue
|
||||
_handle_request(parsed_dict)
|
||||
get_tree().quit(0)
|
||||
|
||||
|
||||
func _handle_request(req: Dictionary) -> void:
|
||||
var rtype: String = String(req.get("type", ""))
|
||||
var rid_int: int = int(req.get("id", -1))
|
||||
var has_id: bool = req.has("id") and req.get("id") != null
|
||||
match rtype:
|
||||
"view":
|
||||
var view_json: String = String(_api.view_json(_claude_slot))
|
||||
_emit_response_with_view(rid_int, has_id, view_json)
|
||||
"act":
|
||||
var action_payload: Dictionary = req.get("action", {}) as Dictionary
|
||||
if action_payload.is_empty():
|
||||
_emit_response_error(rid_int, has_id, "parse_error", "missing action field")
|
||||
return
|
||||
var action_json: String = JSON.stringify(action_payload)
|
||||
var envelope_str: String = String(
|
||||
_api.apply_action_json(_claude_slot, action_json)
|
||||
)
|
||||
# api wrapper already emits a full ok/err envelope — splice
|
||||
# in the request id and forward as the response body.
|
||||
var envelope: Dictionary = JSON.parse_string(envelope_str) as Dictionary
|
||||
if envelope.is_empty():
|
||||
_emit_response_error(
|
||||
rid_int, has_id, "internal", "gdext returned non-JSON envelope"
|
||||
)
|
||||
return
|
||||
if has_id:
|
||||
envelope["id"] = rid_int
|
||||
_write_line(JSON.stringify(envelope))
|
||||
"shutdown":
|
||||
_emit_response_ack(rid_int, has_id)
|
||||
_shutdown = true
|
||||
_:
|
||||
_emit_response_error(
|
||||
rid_int, has_id, "parse_error", "unknown request type: %s" % rtype
|
||||
)
|
||||
|
||||
|
||||
func _emit_response_with_view(rid_int: int, has_id: bool, view_json: String) -> void:
|
||||
var view_dict: Dictionary = JSON.parse_string(view_json) as Dictionary
|
||||
var body: Dictionary = {"ok": true, "view": view_dict}
|
||||
if has_id:
|
||||
body["id"] = rid_int
|
||||
_write_line(JSON.stringify(body))
|
||||
|
||||
|
||||
func _emit_response_ack(rid_int: int, has_id: bool) -> void:
|
||||
var body: Dictionary = {"ok": true}
|
||||
if has_id:
|
||||
body["id"] = rid_int
|
||||
_write_line(JSON.stringify(body))
|
||||
|
||||
|
||||
func _emit_response_error(
|
||||
rid_int: int, has_id: bool, code: String, message: String
|
||||
) -> void:
|
||||
var body: Dictionary = {
|
||||
"ok": false,
|
||||
"error": {"code": code, "message": message},
|
||||
}
|
||||
if has_id:
|
||||
body["id"] = rid_int
|
||||
_write_line(JSON.stringify(body))
|
||||
|
||||
|
||||
func _emit_event(event_type: String, payload: Dictionary) -> void:
|
||||
## Notification line (no id) — adapter ignores or streams.
|
||||
var body: Dictionary = {"type": event_type}
|
||||
for k: String in payload.keys():
|
||||
body[k] = payload[k]
|
||||
_write_line(JSON.stringify(body))
|
||||
|
||||
|
||||
func _emit_protocol_error(message: String) -> void:
|
||||
_emit_event("protocol_error", {"message": message})
|
||||
|
||||
|
||||
func _write_line(line: String) -> void:
|
||||
_log_wire("->", line)
|
||||
# `print` already appends a newline → exactly one JSON value per line.
|
||||
print(line)
|
||||
|
||||
|
||||
func _log_wire(dir_arrow: String, line: String) -> void:
|
||||
if _log_path.is_empty():
|
||||
return
|
||||
var f: FileAccess = FileAccess.open(_log_path, FileAccess.READ_WRITE)
|
||||
if f == null:
|
||||
f = FileAccess.open(_log_path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return
|
||||
f.seek_end()
|
||||
f.store_line("%s %s" % [dir_arrow, line])
|
||||
f.close()
|
||||
|
||||
|
||||
func _env_or(name: String, fallback: String) -> String:
|
||||
var v: String = OS.get_environment(name)
|
||||
if v.is_empty():
|
||||
return fallback
|
||||
return v
|
||||
|
||||
|
||||
func _env_int(name: String, fallback: int) -> int:
|
||||
var v: String = OS.get_environment(name)
|
||||
if v.is_empty():
|
||||
return fallback
|
||||
return int(v)
|
||||
|
||||
|
||||
func _env_bool(name: String, fallback: bool) -> bool:
|
||||
var v: String = OS.get_environment(name)
|
||||
if v.is_empty():
|
||||
return fallback
|
||||
return v == "1" or v.to_lower() == "true"
|
||||
6
src/game/engine/scenes/headless/claude_player_main.tscn
Normal file
6
src/game/engine/scenes/headless/claude_player_main.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://engine/scenes/headless/claude_player_main.gd" id="1"]
|
||||
|
||||
[node name="ClaudePlayerMain" type="Node"]
|
||||
script = ExtResource("1")
|
||||
57
tooling/claude-player/README.md
Normal file
57
tooling/claude-player/README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# @magic-civ/claude-player
|
||||
|
||||
Claude Agent SDK adapter for the Magic Civilization Claude Player API
|
||||
(p2-67 Phase 4). Drives one player slot in a headless game while the
|
||||
production AI controls the others.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd tooling/claude-player
|
||||
npm install
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
npm run dev # tsx — no build needed
|
||||
# or
|
||||
npm run build && npm start # compiled JS
|
||||
```
|
||||
|
||||
Each run writes append-only logs to `.local/runs/<stamp>/`:
|
||||
- `log.jsonl` — agent decisions + notifications + run metadata.
|
||||
- `wire.jsonl` — raw JSON-Lines between adapter and harness.
|
||||
- `result.json` — final result (`turns_played`, `reason`, `final_view`).
|
||||
|
||||
## Env vars
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `ANTHROPIC_API_KEY` | *(required)* | Claude API credentials |
|
||||
| `CP_MODEL` | `claude-opus-4-7` | Model id |
|
||||
| `CP_MAX_TURNS` | `100` | Hard turn cap |
|
||||
| `CP_MAX_IDLE_ENDS` | `3` | Consecutive idle end_turns before bail |
|
||||
| `CP_SERVER` | `<repo>/scripts/claude-player-server.sh` | Harness launcher |
|
||||
| `CP_SEED` / `CP_PLAYERS` / `CP_CLAUDE_SLOT` / `CP_OMNISCIENT` / `CP_TIMEOUT_SEC` | see CLAUDE_PLAYER_API.md | Forwarded to harness |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@magic-civ/claude-player (Node)
|
||||
│
|
||||
├── HarnessClient — spawn() + JSON-Lines pump
|
||||
│
|
||||
└── runAgent — Anthropic Messages API tool-use loop
|
||||
├── tool: view()
|
||||
├── tool: act(action)
|
||||
└── tool: end_turn()
|
||||
│
|
||||
▼ stdin/stdout
|
||||
scripts/claude-player-server.sh
|
||||
│
|
||||
▼
|
||||
flatpak Godot --headless res://engine/scenes/headless/claude_player_main.tscn
|
||||
│
|
||||
▼
|
||||
GdPlayerApi → mc-player-api → mc-turn::action_handlers
|
||||
```
|
||||
|
||||
Wire-protocol contract: `src/game/engine/docs/CLAUDE_PLAYER_API.md`.
|
||||
Owning objective: `.project/objectives/p2-67-claude-player-api.md`.
|
||||
25
tooling/claude-player/package.json
Normal file
25
tooling/claude-player/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "@magic-civ/claude-player",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Claude Agent SDK adapter for the Magic Civilization Claude Player API (p2-67 Phase 4).",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node --enable-source-maps dist/index.js",
|
||||
"dev": "tsx src/index.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.40.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
155
tooling/claude-player/src/types.ts
Normal file
155
tooling/claude-player/src/types.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// Wire-protocol types — mirror the Rust enums in `mc-player-api`.
|
||||
// Source of truth: src/game/engine/docs/CLAUDE_PLAYER_API.md.
|
||||
|
||||
/** Hex coordinate carried over the wire — `[col, row]`. */
|
||||
export type WireHex = [number, number];
|
||||
|
||||
/** Stable player slot index, `u8` everywhere in the simulator. */
|
||||
export type PlayerId = number;
|
||||
|
||||
/** Discriminated union of every player action variant. */
|
||||
export type PlayerAction =
|
||||
| { type: "end_turn" }
|
||||
| { type: "noop" }
|
||||
| { type: "move"; unit_id: string; to: WireHex }
|
||||
| { type: "attack"; unit_id: string; target: WireHex }
|
||||
| { type: "ranged_attack"; unit_id: string; target: WireHex }
|
||||
| { type: "fortify"; unit_id: string }
|
||||
| { type: "unfortify"; unit_id: string }
|
||||
| { type: "skip"; unit_id: string }
|
||||
| { type: "found_city"; unit_id: string }
|
||||
| { type: "build_improvement"; unit_id: string; improvement_id: string }
|
||||
| { type: "sentry"; unit_id: string }
|
||||
| { type: "unsentry"; unit_id: string }
|
||||
| { type: "issue_patrol"; unit_id: string; waypoints: WireHex[] }
|
||||
| { type: "cancel_patrol"; unit_id: string }
|
||||
| { type: "edit_patrol"; unit_id: string; waypoints: WireHex[] }
|
||||
| { type: "queue_production"; city_id: string; item: string; tile?: WireHex }
|
||||
| { type: "remove_from_queue"; city_id: string; index: number }
|
||||
| { type: "queue_reorder"; city_id: string; from: number; to: number }
|
||||
| { type: "rush_buy"; city_id: string }
|
||||
| { type: "buy_tile"; city_id: string; tile: WireHex }
|
||||
| { type: "set_focus"; city_id: string; focus: string }
|
||||
| {
|
||||
type: "merge_buildings";
|
||||
city_id: string;
|
||||
building_a: string;
|
||||
building_b: string;
|
||||
into: string;
|
||||
}
|
||||
| { type: "research_tech"; tech_id: string }
|
||||
| { type: "research_tradition"; tradition_id: string }
|
||||
| { type: "declare_war"; on: PlayerId }
|
||||
| { type: "offer_peace"; to: PlayerId };
|
||||
|
||||
/** One entry in a unit's or empire's `legal_actions` list. */
|
||||
export interface LegalActionEntry {
|
||||
action: PlayerAction;
|
||||
enabled: boolean;
|
||||
disabled_reason?: string;
|
||||
}
|
||||
|
||||
export interface UnitView {
|
||||
id: string;
|
||||
type: string;
|
||||
position: WireHex;
|
||||
owner: PlayerId;
|
||||
hp: number;
|
||||
max_hp: number;
|
||||
movement_left: number;
|
||||
movement_max: number;
|
||||
experience: number;
|
||||
promotion_available: boolean;
|
||||
fortified: boolean;
|
||||
sentry: boolean;
|
||||
legal_actions?: LegalActionEntry[];
|
||||
}
|
||||
|
||||
export interface CityView {
|
||||
id: string;
|
||||
name: string;
|
||||
position: WireHex;
|
||||
owner: PlayerId;
|
||||
is_capital: boolean;
|
||||
population: number;
|
||||
food_stored: number;
|
||||
food_growth_threshold: number;
|
||||
production_queue: Array<{
|
||||
item: string;
|
||||
kind: string;
|
||||
progress: number;
|
||||
cost: number;
|
||||
tile?: WireHex;
|
||||
}>;
|
||||
buildings: string[];
|
||||
owned_tiles: WireHex[];
|
||||
yields: Record<string, number>;
|
||||
hp: number;
|
||||
max_hp: number;
|
||||
focus: string;
|
||||
buildable?: Array<{
|
||||
item: string;
|
||||
kind: string;
|
||||
cost: number;
|
||||
rush_gold: number;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PlayerView {
|
||||
turn: number;
|
||||
player: PlayerId;
|
||||
current_player: PlayerId;
|
||||
phase: string;
|
||||
is_human_turn: boolean;
|
||||
resources: {
|
||||
gold: number;
|
||||
gold_per_turn: number;
|
||||
science_per_turn: number;
|
||||
culture_per_turn: number;
|
||||
happiness_pool: number;
|
||||
stockpile: Record<string, number>;
|
||||
};
|
||||
research: {
|
||||
current_tech: string | null;
|
||||
tech_progress: number;
|
||||
tech_cost: number;
|
||||
researched: string[];
|
||||
available: string[];
|
||||
};
|
||||
culture: {
|
||||
current_tradition: string | null;
|
||||
tradition_progress: number;
|
||||
tradition_cost: number;
|
||||
researched: string[];
|
||||
};
|
||||
cities: CityView[];
|
||||
units: UnitView[];
|
||||
legal_actions: LegalActionEntry[];
|
||||
score: {
|
||||
gold_total: number;
|
||||
city_count: number;
|
||||
unit_count: number;
|
||||
score_estimate: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OkResponse {
|
||||
id: number | null;
|
||||
ok: true;
|
||||
events?: Array<Record<string, unknown>>;
|
||||
view: PlayerView;
|
||||
}
|
||||
|
||||
export interface ErrResponse {
|
||||
id: number | null;
|
||||
ok: false;
|
||||
error: { code: string; message: string };
|
||||
}
|
||||
|
||||
export type Response = OkResponse | ErrResponse;
|
||||
|
||||
export interface Notification {
|
||||
type: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
26
tooling/claude-player/tsconfig.json
Normal file
26
tooling/claude-player/tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", ".local"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue