agent-nook 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_nook/__init__.py +35 -0
- agent_nook/__main__.py +356 -0
- agent_nook/config/__init__.py +40 -0
- agent_nook/config/config.py +468 -0
- agent_nook/config/loader.py +776 -0
- agent_nook/config/opencode-sandbox.yaml +196 -0
- agent_nook/config/sandbox.yaml +197 -0
- agent_nook/sandbox/__init__.py +26 -0
- agent_nook/sandbox/builder.py +252 -0
- agent_nook/sandbox/bwrap_sandbox.py +140 -0
- agent_nook/utils/directories.py +33 -0
- agent_nook/utils/logger.py +257 -0
- agent_nook-0.1.2.dist-info/METADATA +506 -0
- agent_nook-0.1.2.dist-info/RECORD +17 -0
- agent_nook-0.1.2.dist-info/WHEEL +4 -0
- agent_nook-0.1.2.dist-info/entry_points.txt +2 -0
- agent_nook-0.1.2.dist-info/licenses/LICENSE +665 -0
agent_nook/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Agent Nook
|
|
2
|
+
"""A lightweight bwrap sandbox for AI agents.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from agent_nook.sandbox import BwrapSandbox, BwrapBuilder, SandboxConfig
|
|
6
|
+
|
|
7
|
+
# Preferred: use BwrapSandbox.run() which handles execution
|
|
8
|
+
result = BwrapSandbox(config).run(["python3", "agent.py"])
|
|
9
|
+
|
|
10
|
+
# Manual builder
|
|
11
|
+
cmd = BwrapBuilder(config).build(["python3", "agent.py"])
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
# Single source of truth for the version. pyproject.toml reads this
|
|
15
|
+
# dynamically via [tool.hatch.version]; __main__.py imports it.
|
|
16
|
+
__version__ = "0.1.2"
|
|
17
|
+
|
|
18
|
+
# Unified sandbox exports
|
|
19
|
+
# Config exports
|
|
20
|
+
from agent_nook.config.loader import ConfigLoader
|
|
21
|
+
from agent_nook.config.loader import ConfigValidationError
|
|
22
|
+
from agent_nook.sandbox import BwrapBuilder
|
|
23
|
+
from agent_nook.sandbox import BwrapSandbox
|
|
24
|
+
from agent_nook.sandbox import SandboxConfig
|
|
25
|
+
from agent_nook.sandbox import SandboxResult
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"BwrapBuilder",
|
|
30
|
+
"BwrapSandbox",
|
|
31
|
+
"ConfigLoader",
|
|
32
|
+
"ConfigValidationError",
|
|
33
|
+
"SandboxConfig",
|
|
34
|
+
"SandboxResult",
|
|
35
|
+
]
|
agent_nook/__main__.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Agent Nook CLI — Main entry point.
|
|
2
|
+
|
|
3
|
+
Provides a command-line interface for creating and running sandboxed agents.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
agent-nook run python3 agent.py arg1 arg2
|
|
7
|
+
agent-nook run --chdir /app echo hello
|
|
8
|
+
agent-nook run -- /bin/ls /tmp
|
|
9
|
+
agent-nook run -- /usr/bin/python3 -c "print(42)"
|
|
10
|
+
agent-nook status # Shows status of running sandboxes
|
|
11
|
+
agent-nook logs # Shows recent log output
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from agent_nook import __version__
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _auto_init() -> None:
|
|
27
|
+
"""Automatically initialize configuration and directories on first run.
|
|
28
|
+
|
|
29
|
+
This ensures that the required directories exist before any command is run:
|
|
30
|
+
- ~/.config/agent-nook/ (config)
|
|
31
|
+
- ~/.local/state/agent-nook/ (state, logs)
|
|
32
|
+
|
|
33
|
+
It also copies the bundled default config file if it doesn't exist.
|
|
34
|
+
"""
|
|
35
|
+
from agent_nook.config import copy_bundled_config
|
|
36
|
+
from agent_nook.config import get_config_path
|
|
37
|
+
from agent_nook.utils.directories import ensure_directories
|
|
38
|
+
from agent_nook.utils.logger import get_log_directory
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("agent_nook")
|
|
41
|
+
|
|
42
|
+
# Ensure directories exist using XDG-compliant paths
|
|
43
|
+
# This respects XDG_CONFIG_HOME and XDG_STATE_HOME environment variables
|
|
44
|
+
config_dir = Path(get_config_path()).parent
|
|
45
|
+
xdg_state_logs = Path(get_log_directory())
|
|
46
|
+
|
|
47
|
+
ensure_directories([config_dir, xdg_state_logs])
|
|
48
|
+
logger.debug("Ensured directories exist")
|
|
49
|
+
|
|
50
|
+
# Copy bundled config if user config doesn't exist
|
|
51
|
+
bundled_config_path = Path(__file__).parent / "config" / "sandbox.yaml"
|
|
52
|
+
if bundled_config_path.exists():
|
|
53
|
+
if not Path(get_config_path()).exists():
|
|
54
|
+
copy_bundled_config(bundled_config_path)
|
|
55
|
+
logger.debug("Copied bundled config to user config dir")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _setup_logging(verbose: bool) -> None:
|
|
59
|
+
"""Initialize logging at startup.
|
|
60
|
+
|
|
61
|
+
This must be called early in the application lifecycle,
|
|
62
|
+
before importing modules that use the logger.
|
|
63
|
+
"""
|
|
64
|
+
from agent_nook.utils.logger import main_logger
|
|
65
|
+
|
|
66
|
+
logger = main_logger("agent_nook", level="DEBUG" if verbose else "INFO")
|
|
67
|
+
logger.debug("Logger initialized")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> int:
|
|
71
|
+
"""Main entry point."""
|
|
72
|
+
parser = argparse.ArgumentParser(
|
|
73
|
+
prog="agent-nook",
|
|
74
|
+
description="A lightweight bwrap sandbox for AI agents",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
77
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output")
|
|
78
|
+
parser.add_argument("--quiet", action="store_true", help="Suppress non-error output")
|
|
79
|
+
|
|
80
|
+
subparsers = parser.add_subparsers(dest="subcommand", help="Available commands")
|
|
81
|
+
|
|
82
|
+
# run command
|
|
83
|
+
run_parser = subparsers.add_parser("run", help="Run a command in a sandbox")
|
|
84
|
+
# Use REMAINDER so that -c, --foo, etc. inside the command are not parsed
|
|
85
|
+
# as agent-nook options. Pass everything after first arg through to the sandbox.
|
|
86
|
+
run_parser.add_argument(
|
|
87
|
+
"command",
|
|
88
|
+
nargs=argparse.REMAINDER,
|
|
89
|
+
metavar="CMD [ARGS...]",
|
|
90
|
+
help="Command to execute in the sandbox (e.g. 'python3 agent.py')",
|
|
91
|
+
)
|
|
92
|
+
run_parser.add_argument(
|
|
93
|
+
"--config",
|
|
94
|
+
default=None,
|
|
95
|
+
help="Override config file path (default: ~/.config/agent-nook/sandbox.yaml)",
|
|
96
|
+
)
|
|
97
|
+
run_parser.add_argument("--chdir", metavar="DIR", help="Change working directory in sandbox (overrides config)")
|
|
98
|
+
run_parser.add_argument(
|
|
99
|
+
"--cap-add",
|
|
100
|
+
action="append",
|
|
101
|
+
default=[],
|
|
102
|
+
metavar="CAP",
|
|
103
|
+
help="Add capability (e.g. 'CAP_NET_BIND_SERVICE', repeat for multiple)",
|
|
104
|
+
)
|
|
105
|
+
run_parser.add_argument(
|
|
106
|
+
"--cap-drop", action="append", default=[], metavar="CAP", help="Drop capability (e.g. 'CAP_SYS_ADMIN', repeat for multiple)"
|
|
107
|
+
)
|
|
108
|
+
run_parser.add_argument(
|
|
109
|
+
"--unshare", action="append", default=[], metavar="NS", help="Unshare namespace (e.g. 'pid,uts', repeat for multiple)"
|
|
110
|
+
)
|
|
111
|
+
run_parser.add_argument(
|
|
112
|
+
"--bind", action="append", default=[], metavar="SRC:DEST", help="Bind mount host SRC to sandbox DEST (repeat for multiple)"
|
|
113
|
+
)
|
|
114
|
+
run_parser.add_argument(
|
|
115
|
+
"--ro-bind",
|
|
116
|
+
action="append",
|
|
117
|
+
default=[],
|
|
118
|
+
metavar="SRC:DEST",
|
|
119
|
+
help="Read-only bind mount host SRC to sandbox DEST (repeat for multiple)",
|
|
120
|
+
)
|
|
121
|
+
run_parser.add_argument(
|
|
122
|
+
"--env",
|
|
123
|
+
action="append",
|
|
124
|
+
default=[],
|
|
125
|
+
metavar="KEY=VALUE",
|
|
126
|
+
help="Set environment variable (e.g. 'KEY=value', repeat for multiple)",
|
|
127
|
+
)
|
|
128
|
+
run_parser.add_argument(
|
|
129
|
+
"--unset-env", action="append", default=[], metavar="KEY", help="Unset environment variable (repeat for multiple)"
|
|
130
|
+
)
|
|
131
|
+
run_parser.add_argument("--hostname", metavar="HOSTNAME", help="Set sandbox hostname")
|
|
132
|
+
run_parser.add_argument(
|
|
133
|
+
"--new-session", action="store_true", default=True, help="Create new session (prevents TIOCSTI attacks, default: on)"
|
|
134
|
+
)
|
|
135
|
+
run_parser.add_argument(
|
|
136
|
+
"--no-new-session", action="store_false", dest="new_session", help="Don't create new session (allows TIOCSTI)"
|
|
137
|
+
)
|
|
138
|
+
run_parser.add_argument("--caps", action="append", default=[], help="Additional capabilities to add (repeat for multiple)")
|
|
139
|
+
run_parser.add_argument("--drop-caps", action="append", default=[], help="Additional capabilities to drop (repeat for multiple)")
|
|
140
|
+
run_parser.add_argument(
|
|
141
|
+
"--die-with-parent", action="store_true", default=True, help="Kill sandbox child when parent dies (default: on)"
|
|
142
|
+
)
|
|
143
|
+
run_parser.add_argument(
|
|
144
|
+
"--no-die-with-parent", action="store_false", dest="die_with_parent", help="Keep sandbox alive after parent exits"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# status command
|
|
148
|
+
status_parser = subparsers.add_parser("status", help="Show sandbox status")
|
|
149
|
+
status_parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed information")
|
|
150
|
+
|
|
151
|
+
# logs command
|
|
152
|
+
logs_parser = subparsers.add_parser("logs", help="Show recent logs")
|
|
153
|
+
logs_parser.add_argument("--tail", "-n", type=int, default=50, help="Number of lines to show (default: 50)")
|
|
154
|
+
logs_parser.add_argument("-f", "-F", "--follow", action="store_true", help="Follow log output (like tail -F)")
|
|
155
|
+
|
|
156
|
+
# list command
|
|
157
|
+
list_parser = subparsers.add_parser("list", help="List available sandboxes")
|
|
158
|
+
list_parser.add_argument("--verbose", "-v", action="store_true", help="Show details")
|
|
159
|
+
|
|
160
|
+
args = parser.parse_args()
|
|
161
|
+
|
|
162
|
+
if args.subcommand is None:
|
|
163
|
+
parser.print_help()
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
if args.verbose:
|
|
167
|
+
os.environ["PYTHONVERBOSE"] = "1"
|
|
168
|
+
|
|
169
|
+
# Initialize logging early, before any modules are imported
|
|
170
|
+
_setup_logging(args.verbose)
|
|
171
|
+
|
|
172
|
+
# Auto-initialize configuration and directories
|
|
173
|
+
_auto_init()
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
return _dispatch_command(args)
|
|
177
|
+
except KeyboardInterrupt:
|
|
178
|
+
print("\nInterrupted.")
|
|
179
|
+
return 130
|
|
180
|
+
except SystemExit as e:
|
|
181
|
+
return e.code if e.code is not None else 0
|
|
182
|
+
except Exception as e:
|
|
183
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
184
|
+
return 1
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _dispatch_command(args: argparse.Namespace) -> int:
|
|
188
|
+
"""Dispatch to the appropriate command handler."""
|
|
189
|
+
subcommand = args.subcommand if hasattr(args, "subcommand") else args.command
|
|
190
|
+
if subcommand == "run":
|
|
191
|
+
return _cmd_run(args)
|
|
192
|
+
elif subcommand == "status":
|
|
193
|
+
return _cmd_status(args)
|
|
194
|
+
elif subcommand == "logs":
|
|
195
|
+
return _cmd_logs(args)
|
|
196
|
+
elif subcommand == "list":
|
|
197
|
+
return _cmd_list(args)
|
|
198
|
+
else:
|
|
199
|
+
print(f"Unknown command: {subcommand}", file=sys.stderr)
|
|
200
|
+
return 1
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
204
|
+
"""Run a command inside a sandbox."""
|
|
205
|
+
from agent_nook.config.loader import ConfigLoader
|
|
206
|
+
from agent_nook.config.loader import ConfigValidationError
|
|
207
|
+
from agent_nook.sandbox import BwrapSandbox
|
|
208
|
+
|
|
209
|
+
# Logging is already initialized by _setup_logging()
|
|
210
|
+
logger = logging.getLogger("agent_nook")
|
|
211
|
+
logger.info("Agent Nook v%s — Running command", __version__)
|
|
212
|
+
|
|
213
|
+
# Unified config loading: handles file resolution, validation, overrides, and construction
|
|
214
|
+
try:
|
|
215
|
+
config_loader = ConfigLoader(None) # Let load_with_overrides resolve the path
|
|
216
|
+
config = config_loader.load_with_overrides(args)
|
|
217
|
+
from agent_nook.utils.logger import set_sandbox_name
|
|
218
|
+
|
|
219
|
+
set_sandbox_name(config.name)
|
|
220
|
+
except FileNotFoundError as e:
|
|
221
|
+
logger.error("Config file not found: %s", e)
|
|
222
|
+
return 1
|
|
223
|
+
except ConfigValidationError as e:
|
|
224
|
+
logger.error("Configuration validation failed: %s", e)
|
|
225
|
+
return 1
|
|
226
|
+
except RuntimeError as e:
|
|
227
|
+
logger.error("Failed to load config: %s", e)
|
|
228
|
+
return 1
|
|
229
|
+
|
|
230
|
+
# Build the command from positional args
|
|
231
|
+
command: list[str] = args.command if args.command else []
|
|
232
|
+
if not command:
|
|
233
|
+
print("error: no command specified. Use 'agent-nook run <command>'", file=sys.stderr)
|
|
234
|
+
return 1
|
|
235
|
+
|
|
236
|
+
logger.debug("Executing: %s", " ".join(command))
|
|
237
|
+
|
|
238
|
+
# Run the command in the sandbox using the unified API
|
|
239
|
+
try:
|
|
240
|
+
result = BwrapSandbox(config).run(command)
|
|
241
|
+
if result.success:
|
|
242
|
+
logger.info("✓ Sandbox executed successfully")
|
|
243
|
+
return 0
|
|
244
|
+
else:
|
|
245
|
+
logger.error("✗ Sandbox execution failed with return code %d", result.return_code)
|
|
246
|
+
return result.return_code or 1
|
|
247
|
+
except RuntimeError as e:
|
|
248
|
+
logger.error("Bubblewrap error: %s", e)
|
|
249
|
+
return 1
|
|
250
|
+
except subprocess.TimeoutExpired:
|
|
251
|
+
logger.error("Sandbox execution timed out after %s seconds", config.timeout)
|
|
252
|
+
return 1
|
|
253
|
+
except Exception:
|
|
254
|
+
logger.exception("Unexpected error")
|
|
255
|
+
return 1
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _cmd_status(args: argparse.Namespace) -> int:
|
|
259
|
+
"""Display agent-nook status."""
|
|
260
|
+
from agent_nook.config import get_config_path
|
|
261
|
+
from agent_nook.utils.logger import get_log_file_path
|
|
262
|
+
from agent_nook.utils.logger import get_state_dir
|
|
263
|
+
|
|
264
|
+
logger = logging.getLogger("agent_nook")
|
|
265
|
+
logger.info("Agent Nook Status")
|
|
266
|
+
logger.info("=" * 40)
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
result = subprocess.run(["bwrap", "--version"], capture_output=True, text=True, timeout=5, check=False)
|
|
270
|
+
if result.returncode == 0:
|
|
271
|
+
logger.info(" Bubblewrap version: %s", result.stdout.strip())
|
|
272
|
+
else:
|
|
273
|
+
logger.warning(" Bubblewrap not found on PATH")
|
|
274
|
+
except FileNotFoundError:
|
|
275
|
+
logger.warning(" Bubblewrap not found")
|
|
276
|
+
|
|
277
|
+
config_path = get_config_path()
|
|
278
|
+
|
|
279
|
+
if os.path.exists(config_path):
|
|
280
|
+
logger.info(" Config: %s (found)", config_path)
|
|
281
|
+
else:
|
|
282
|
+
logger.info(" Config: %s (not found)", config_path)
|
|
283
|
+
|
|
284
|
+
state_path = get_state_dir()
|
|
285
|
+
|
|
286
|
+
if os.path.exists(state_path):
|
|
287
|
+
logger.info(" State: %s (found)", state_path)
|
|
288
|
+
log_path = get_log_file_path()
|
|
289
|
+
if os.path.exists(log_path):
|
|
290
|
+
logger.info(" Logs: %s (%d KB)", log_path, os.path.getsize(log_path) / 1024)
|
|
291
|
+
else:
|
|
292
|
+
logger.info(" State: %s (not found)", state_path)
|
|
293
|
+
|
|
294
|
+
logger.info("")
|
|
295
|
+
logger.info("Commands:")
|
|
296
|
+
logger.info(" agent-nook run python3 agent.py")
|
|
297
|
+
logger.info(" agent-nook logs")
|
|
298
|
+
logger.info(" agent-nook list")
|
|
299
|
+
|
|
300
|
+
return 0
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _cmd_logs(args: argparse.Namespace) -> int:
|
|
304
|
+
"""Show recent logs, optionally following them live (like tail -F)."""
|
|
305
|
+
from agent_nook.utils.logger import get_log_file_path
|
|
306
|
+
|
|
307
|
+
logger = logging.getLogger("agent_nook")
|
|
308
|
+
|
|
309
|
+
log_file = get_log_file_path()
|
|
310
|
+
|
|
311
|
+
if args.follow:
|
|
312
|
+
# tail -F (--follow=name --retry) reopens the file after log rotation
|
|
313
|
+
# and waits if it does not exist yet, so this can be started before
|
|
314
|
+
# any sandbox has run.
|
|
315
|
+
try:
|
|
316
|
+
proc = subprocess.Popen(["tail", "-n", str(args.tail), "-F", log_file])
|
|
317
|
+
except FileNotFoundError:
|
|
318
|
+
logger.error("tail command not found; cannot follow logs")
|
|
319
|
+
return 1
|
|
320
|
+
try:
|
|
321
|
+
returncode = proc.wait()
|
|
322
|
+
except KeyboardInterrupt:
|
|
323
|
+
# In a terminal, Ctrl+C reaches both processes; the kill covers
|
|
324
|
+
# the case where only this process received the signal.
|
|
325
|
+
proc.kill()
|
|
326
|
+
proc.wait()
|
|
327
|
+
raise # main() converts this to exit code 130
|
|
328
|
+
return returncode or 0
|
|
329
|
+
|
|
330
|
+
if not os.path.exists(log_file):
|
|
331
|
+
logger.info("No log file found yet.")
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
result = subprocess.run(["tail", "-n", str(args.tail), log_file], check=False)
|
|
336
|
+
except FileNotFoundError:
|
|
337
|
+
logger.error("tail command not found; cannot show logs")
|
|
338
|
+
return 1
|
|
339
|
+
return result.returncode or 0
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _cmd_list(args: argparse.Namespace) -> int:
|
|
343
|
+
"""List available sandboxes (placeholder)."""
|
|
344
|
+
logger = logging.getLogger("agent_nook")
|
|
345
|
+
logger.info("Agent Nook — Available Sandboxes")
|
|
346
|
+
logger.info("=" * 40)
|
|
347
|
+
logger.info("No custom sandboxes configured.")
|
|
348
|
+
logger.info("")
|
|
349
|
+
logger.info("Create a sandbox by editing ~/.config/agent-nook/sandbox.yaml")
|
|
350
|
+
logger.info("or by adding entries to the --mounts option.")
|
|
351
|
+
|
|
352
|
+
return 0
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
if __name__ == "__main__":
|
|
356
|
+
sys.exit(main())
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Configuration module for Agent Nook.
|
|
2
|
+
|
|
3
|
+
Provides XDG-compliant path resolution and configuration management.
|
|
4
|
+
|
|
5
|
+
XDG directories:
|
|
6
|
+
~/.config/agent-nook/ (configuration)
|
|
7
|
+
~/.local/state/agent-nook/ (state, logs)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from agent_nook.config.loader import ConfigLoader
|
|
16
|
+
from agent_nook.config.loader import ConfigValidationError
|
|
17
|
+
from agent_nook.config.loader import get_config_path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def copy_bundled_config(source_path: str | Path) -> None:
|
|
21
|
+
"""Copy a bundled configuration file to the user's config directory.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
source_path: Path to the bundled configuration file.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
>>> copy_bundled_config("/path/to/bundled/sandbox.yaml")
|
|
28
|
+
"""
|
|
29
|
+
target_path = Path(source_path)
|
|
30
|
+
dest_path = Path(get_config_path())
|
|
31
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
shutil.copy2(target_path, dest_path)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ConfigLoader",
|
|
37
|
+
"ConfigValidationError",
|
|
38
|
+
"copy_bundled_config",
|
|
39
|
+
"get_config_path",
|
|
40
|
+
]
|