haywire-studio 0.0.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.
- haywire_studio/__init__.py +3 -0
- haywire_studio/__main__.py +3 -0
- haywire_studio/app.py +406 -0
- haywire_studio/config.py +121 -0
- haywire_studio/init.py +471 -0
- haywire_studio/py.typed +0 -0
- haywire_studio/share.py +913 -0
- haywire_studio/workspace/__init__.py +0 -0
- haywire_studio-0.0.2.dist-info/METADATA +9 -0
- haywire_studio-0.0.2.dist-info/RECORD +12 -0
- haywire_studio-0.0.2.dist-info/WHEEL +4 -0
- haywire_studio-0.0.2.dist-info/entry_points.txt +2 -0
haywire_studio/app.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HaywireApp — main application entry point.
|
|
3
|
+
|
|
4
|
+
Manages shared services (libraries) and per-session UI shells.
|
|
5
|
+
Each browser connection gets its own Session and AppShell; all sessions share
|
|
6
|
+
the same library registry.
|
|
7
|
+
|
|
8
|
+
Execution is per-graph: each GraphEntry owns its own Interpreter,
|
|
9
|
+
started/stopped via entry.start_execution() / entry.stop_execution().
|
|
10
|
+
|
|
11
|
+
Haystack lifecycle (open graphs, auto-load on startup) is handled by
|
|
12
|
+
HaystackState, accessed via ctx.app_data[HaystackState].
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import logging
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from nicegui import ui, app
|
|
22
|
+
|
|
23
|
+
# Core imports
|
|
24
|
+
from haywire.core.graph.editor import Editor
|
|
25
|
+
from haywire.core.graph.base import BaseGraph
|
|
26
|
+
from haywire.core.undo.config import DEVELOPMENT_CONFIG
|
|
27
|
+
from haywire.core.di.config import create_library_system_service
|
|
28
|
+
from haywire.core.di.context import set_workspace_root
|
|
29
|
+
from haywire.core.host import HostStore
|
|
30
|
+
|
|
31
|
+
# UI imports
|
|
32
|
+
from haywire.ui.console_bridge import get_bridge
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from haywire.ui.app.shell import AppShell
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HaywireApp:
|
|
41
|
+
"""Main Haywire application.
|
|
42
|
+
|
|
43
|
+
Constructs shared services (library system, session manager, workspace manager)
|
|
44
|
+
and registers per-session UI shells. Graph/haystack lifecycle is delegated
|
|
45
|
+
to HaystackState (accessed via ctx.app_data[HaystackState]).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, workspace_root: str | None = None):
|
|
49
|
+
self.workspace_root = workspace_root or os.getcwd()
|
|
50
|
+
set_workspace_root(self.workspace_root)
|
|
51
|
+
print(f"Haywire workspace: {self.workspace_root}")
|
|
52
|
+
print("Setting up Haywire application...")
|
|
53
|
+
|
|
54
|
+
self.setup_library_system()
|
|
55
|
+
self.setup_shared_services()
|
|
56
|
+
|
|
57
|
+
self._is_shutting_down = False
|
|
58
|
+
self._shells: dict[str, "AppShell"] = {}
|
|
59
|
+
# Maps NiceGUI client.id → haywire session_id so on_disconnect can
|
|
60
|
+
# resolve which session to tear down without monkey-patching the
|
|
61
|
+
# Client object.
|
|
62
|
+
self._client_to_session: dict[str, str] = {}
|
|
63
|
+
|
|
64
|
+
app.on_disconnect(self.on_disconnect)
|
|
65
|
+
app.on_shutdown(self.on_app_shutdown)
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# Lifecycle
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def on_app_shutdown(self):
|
|
72
|
+
"""Clean up all resources on application shutdown."""
|
|
73
|
+
if self._is_shutting_down:
|
|
74
|
+
return
|
|
75
|
+
self._is_shutting_down = True
|
|
76
|
+
print("Application shutdown initiated...")
|
|
77
|
+
|
|
78
|
+
# 1. Clean up all sessions
|
|
79
|
+
print(f" Cleaning up {self.session_manager.session_count} sessions...")
|
|
80
|
+
self.session_manager.cleanup_all()
|
|
81
|
+
|
|
82
|
+
# 2. Clean up console bridge
|
|
83
|
+
try:
|
|
84
|
+
bridge = get_bridge()
|
|
85
|
+
bridge.log_elements.clear()
|
|
86
|
+
bridge.clear_history()
|
|
87
|
+
except Exception as e:
|
|
88
|
+
print(f" Error cleaning up console bridge: {e}")
|
|
89
|
+
|
|
90
|
+
# 3. Cleanup library system
|
|
91
|
+
try:
|
|
92
|
+
if hasattr(self.library_service, "cleanup"):
|
|
93
|
+
self.library_service.cleanup()
|
|
94
|
+
except Exception as e:
|
|
95
|
+
print(f" Error cleaning up library system: {e}")
|
|
96
|
+
|
|
97
|
+
print("Application shutdown complete")
|
|
98
|
+
|
|
99
|
+
def on_disconnect(self, client):
|
|
100
|
+
"""Handle client disconnect.
|
|
101
|
+
|
|
102
|
+
Shell-upstream model (Q7A): tear down the AppShell first, then
|
|
103
|
+
detach the session. SessionManager.remove_session does only state
|
|
104
|
+
cleanup now — UI cleanup is the shell's responsibility.
|
|
105
|
+
"""
|
|
106
|
+
if self._is_shutting_down:
|
|
107
|
+
return
|
|
108
|
+
session_id = self._client_to_session.pop(client.id, None)
|
|
109
|
+
if not session_id:
|
|
110
|
+
return
|
|
111
|
+
print(f"Client disconnected, cleaning up session {session_id[:8]}")
|
|
112
|
+
|
|
113
|
+
shell = self._shells.pop(session_id, None)
|
|
114
|
+
if shell is not None:
|
|
115
|
+
try:
|
|
116
|
+
shell.cleanup()
|
|
117
|
+
except Exception as e:
|
|
118
|
+
print(f" Error cleaning up shell for session {session_id[:8]}: {e}")
|
|
119
|
+
|
|
120
|
+
self.session_manager.remove_session(session_id)
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
# Shared services setup
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def setup_library_system(self):
|
|
127
|
+
"""Initialize the library system service (shared across sessions)."""
|
|
128
|
+
self.undo_config = DEVELOPMENT_CONFIG
|
|
129
|
+
|
|
130
|
+
library_paths = []
|
|
131
|
+
workspace_libs = os.path.join(self.workspace_root, "barn")
|
|
132
|
+
if os.path.isdir(workspace_libs):
|
|
133
|
+
library_paths.append(workspace_libs)
|
|
134
|
+
|
|
135
|
+
# HostStore — engine bootstrap persistence the LibraryRegistry uses
|
|
136
|
+
# to remember which libraries the user has disabled. File-backed
|
|
137
|
+
# because this is a real workspace; an embedded / headless host
|
|
138
|
+
# could pass HostStore.in_memory() or omit the argument entirely.
|
|
139
|
+
host_store = HostStore(Path(self.workspace_root) / ".haywire" / "host.toml")
|
|
140
|
+
|
|
141
|
+
# create_library_system_service.initialize() publishes both the
|
|
142
|
+
# service (via set_library_system) and the injector (via
|
|
143
|
+
# set_global_injector) BEFORE the enable phase, so AppState.on_enable
|
|
144
|
+
# hooks can resolve framework services from the ambient context.
|
|
145
|
+
self.library_service = create_library_system_service(
|
|
146
|
+
workspace_root=self.workspace_root,
|
|
147
|
+
library_paths=library_paths if library_paths else None,
|
|
148
|
+
enable_file_watching=True,
|
|
149
|
+
watch_settings=False,
|
|
150
|
+
host_store=host_store,
|
|
151
|
+
)
|
|
152
|
+
print("Library system initialized.")
|
|
153
|
+
|
|
154
|
+
def setup_shared_services(self):
|
|
155
|
+
"""Setup services shared across all sessions."""
|
|
156
|
+
from haywire.core.state import LibraryStateContainer
|
|
157
|
+
from haywire.core.session.session_manager import SessionManager
|
|
158
|
+
|
|
159
|
+
# Registries and factories (from DI)
|
|
160
|
+
self.node_registry = self.library_service.get_node_registry()
|
|
161
|
+
self.node_factory = self.library_service.get_node_factory()
|
|
162
|
+
self.skin_factory = self.library_service.get_skin_factory()
|
|
163
|
+
self.adapter_factory = self.library_service.get_adapter_factory()
|
|
164
|
+
self.panel_registry = self.library_service.get_panel_registry()
|
|
165
|
+
self.library_state_container = self.library_service.injector.get(LibraryStateContainer)
|
|
166
|
+
|
|
167
|
+
# SessionManager comes from the DI container; provide_session_manager()
|
|
168
|
+
# also publishes it via set_session_manager() into the ambient context.
|
|
169
|
+
self.session_manager = self.library_service.injector.get(SessionManager)
|
|
170
|
+
|
|
171
|
+
from haywire.core.session.workspace.manager import WorkspaceManager
|
|
172
|
+
|
|
173
|
+
self.workspace_manager = WorkspaceManager(project_path=Path(self.workspace_root))
|
|
174
|
+
|
|
175
|
+
# LibraryManager is now published by haybale-marketplace as
|
|
176
|
+
# LibraryManagerState. Persisted disabled-state is applied by the
|
|
177
|
+
# library system during create_library_system_service. See ADR-0001.
|
|
178
|
+
|
|
179
|
+
print("Shared services configured successfully.")
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Graph factory
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def _graph_factory(self, graph_id: str, name: str) -> tuple[BaseGraph, Editor]:
|
|
186
|
+
"""Standard factory producing (BaseGraph, Editor) pairs."""
|
|
187
|
+
g = BaseGraph(graph_id, name)
|
|
188
|
+
e = Editor(g, self.node_factory, undo_config=self.undo_config)
|
|
189
|
+
return g, e
|
|
190
|
+
|
|
191
|
+
def save_workspace(self, shell=None, active_graph_path=None) -> None:
|
|
192
|
+
"""Save workspace snapshot atomically.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
shell: The active AppShell. When provided, collects the current slot
|
|
196
|
+
snapshot from it. When None, re-saves the existing snapshot.
|
|
197
|
+
active_graph_path: Path of the currently active graph (unused here;
|
|
198
|
+
retained for call-site compatibility — callers that need to persist
|
|
199
|
+
haystack state call persistence.dump_haystack before this).
|
|
200
|
+
"""
|
|
201
|
+
snapshot = self.workspace_manager.snapshot.copy()
|
|
202
|
+
if shell is not None:
|
|
203
|
+
slot_data = shell.collect_snapshot()
|
|
204
|
+
snapshot.update(slot_data)
|
|
205
|
+
self.workspace_manager.save(snapshot)
|
|
206
|
+
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
# UI creation
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def setup_services(self):
|
|
212
|
+
"""Stub kept for compatibility."""
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
def create_ui(self):
|
|
216
|
+
"""Register NiceGUI page routes."""
|
|
217
|
+
|
|
218
|
+
@ui.page("/", title="Haywire")
|
|
219
|
+
def main_page():
|
|
220
|
+
from haywire.ui.app.shell import AppShell
|
|
221
|
+
from haywire.ui.editor.registry import EditorTypeRegistry
|
|
222
|
+
from nicegui import context
|
|
223
|
+
|
|
224
|
+
print(f"Creating UI for session: {context.client.id[:8]}")
|
|
225
|
+
|
|
226
|
+
editor_registry = self.library_service.injector.get(EditorTypeRegistry)
|
|
227
|
+
|
|
228
|
+
haywire_session = self.session_manager.create_session(
|
|
229
|
+
project_state=self,
|
|
230
|
+
workspace_manager=self.workspace_manager,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Map this client to its session so on_disconnect can resolve
|
|
234
|
+
# which session to tear down.
|
|
235
|
+
self._client_to_session[context.client.id] = haywire_session.session_id
|
|
236
|
+
|
|
237
|
+
# Set studio theme defaults on context before rendering
|
|
238
|
+
haywire_session.context.active_workbench_theme_key = "core:theme:workbench:haywire-dark"
|
|
239
|
+
haywire_session.context.active_node_theme_key = "core:theme:node:default"
|
|
240
|
+
|
|
241
|
+
app_shell = AppShell(haywire_session, editor_registry=editor_registry)
|
|
242
|
+
self._shells[haywire_session.session_id] = app_shell
|
|
243
|
+
app_shell.render()
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# Run / cleanup
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def cleanup(self):
|
|
250
|
+
"""Manual cleanup fallback."""
|
|
251
|
+
self.on_app_shutdown()
|
|
252
|
+
|
|
253
|
+
def run(self):
|
|
254
|
+
"""Run the application."""
|
|
255
|
+
print("Starting Haywire...")
|
|
256
|
+
self.create_ui()
|
|
257
|
+
try:
|
|
258
|
+
ui.run(
|
|
259
|
+
port=8082,
|
|
260
|
+
show=True,
|
|
261
|
+
title="Haywire",
|
|
262
|
+
reload=False,
|
|
263
|
+
)
|
|
264
|
+
except KeyboardInterrupt:
|
|
265
|
+
print("\nKeyboard interrupt received")
|
|
266
|
+
finally:
|
|
267
|
+
if not self._is_shutting_down:
|
|
268
|
+
self.cleanup()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# Entry points
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def run_app():
|
|
277
|
+
"""Launch the Haywire application."""
|
|
278
|
+
# logging.getLogger("haywire.ui.editor.graph_canvas_manager").setLevel(logging.DEBUG)
|
|
279
|
+
# use DebugSettings.log_ui instead
|
|
280
|
+
app_instance = HaywireApp()
|
|
281
|
+
app.on_shutdown(app_instance.cleanup)
|
|
282
|
+
app_instance.run()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def main():
|
|
286
|
+
"""Main entry point — routes CLI subcommands."""
|
|
287
|
+
import argparse
|
|
288
|
+
|
|
289
|
+
parser = argparse.ArgumentParser(
|
|
290
|
+
prog="haywire",
|
|
291
|
+
description="Haywire visual programming system",
|
|
292
|
+
)
|
|
293
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
294
|
+
|
|
295
|
+
init_parser = subparsers.add_parser("init", help="Create a new haywire project")
|
|
296
|
+
init_parser.add_argument("name", help="Project name")
|
|
297
|
+
init_parser.add_argument(
|
|
298
|
+
"--no-sync",
|
|
299
|
+
action="store_true",
|
|
300
|
+
help="Skip running uv sync after scaffolding",
|
|
301
|
+
)
|
|
302
|
+
init_parser.add_argument(
|
|
303
|
+
"--dev",
|
|
304
|
+
action="store_true",
|
|
305
|
+
help="Use editable local sources from this dev repo instead of PyPI",
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
share_parser = subparsers.add_parser(
|
|
309
|
+
"share", help="Generate a marketplace.toml snippet for sharing a library"
|
|
310
|
+
)
|
|
311
|
+
share_parser.add_argument(
|
|
312
|
+
"library_path",
|
|
313
|
+
nargs="?",
|
|
314
|
+
default=None,
|
|
315
|
+
help="Path to the library directory (e.g. libs/haybale-myproject). "
|
|
316
|
+
"Auto-detected if libs/ contains exactly one library.",
|
|
317
|
+
)
|
|
318
|
+
share_parser.add_argument(
|
|
319
|
+
"--save",
|
|
320
|
+
action="store_true",
|
|
321
|
+
help="Aggregate every barn/* library into <repo-root>/marketstall.toml.",
|
|
322
|
+
)
|
|
323
|
+
share_parser.add_argument(
|
|
324
|
+
"--strict",
|
|
325
|
+
action="store_true",
|
|
326
|
+
help="Exit non-zero if any library has dependency drift (declared vs. imported).",
|
|
327
|
+
)
|
|
328
|
+
share_parser.add_argument(
|
|
329
|
+
"--fix",
|
|
330
|
+
action="store_true",
|
|
331
|
+
help="Auto-correct dependency drift by updating pyproject.toml and the "
|
|
332
|
+
"@library decorator before sharing.",
|
|
333
|
+
)
|
|
334
|
+
share_parser.add_argument(
|
|
335
|
+
"--ref",
|
|
336
|
+
type=str,
|
|
337
|
+
default=None,
|
|
338
|
+
help="Specific ref (branch, tag, or SHA) to encode in the share URL.",
|
|
339
|
+
)
|
|
340
|
+
share_parser.add_argument(
|
|
341
|
+
"--tag",
|
|
342
|
+
type=str,
|
|
343
|
+
default=None,
|
|
344
|
+
help="Tag to encode in the share URL. Use 'latest' to resolve to the most recent tag reachable from HEAD.", # noqa: E501
|
|
345
|
+
)
|
|
346
|
+
share_parser.add_argument(
|
|
347
|
+
"--no-update-readme",
|
|
348
|
+
dest="update_readme",
|
|
349
|
+
action="store_false",
|
|
350
|
+
default=True,
|
|
351
|
+
help="Don't rewrite the marketstall:share-url marker block in any README.",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
args = parser.parse_args()
|
|
355
|
+
|
|
356
|
+
if args.command == "init":
|
|
357
|
+
from .init import init_project, _get_dev_repo_root
|
|
358
|
+
|
|
359
|
+
dev_repo = _get_dev_repo_root() if args.dev else None
|
|
360
|
+
init_project(args.name, auto_sync=not args.no_sync, dev_repo=dev_repo)
|
|
361
|
+
elif args.command == "share":
|
|
362
|
+
if args.save:
|
|
363
|
+
from pathlib import Path
|
|
364
|
+
|
|
365
|
+
from .share import DriftError, NoBarnError, share_save_repo
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
result = share_save_repo(
|
|
369
|
+
Path.cwd(),
|
|
370
|
+
strict=args.strict,
|
|
371
|
+
fix=args.fix,
|
|
372
|
+
ref=args.ref,
|
|
373
|
+
tag=args.tag,
|
|
374
|
+
update_readme=args.update_readme,
|
|
375
|
+
)
|
|
376
|
+
print(f"✓ Wrote {result.out_path}")
|
|
377
|
+
if result.share_url is not None:
|
|
378
|
+
print(f"✓ Share this URL:\n {result.share_url}")
|
|
379
|
+
elif result.warning is not None:
|
|
380
|
+
print(f"⚠ {result.warning}")
|
|
381
|
+
except NoBarnError as exc:
|
|
382
|
+
print(f"Error: {exc}")
|
|
383
|
+
sys.exit(1)
|
|
384
|
+
except DriftError as exc:
|
|
385
|
+
print(str(exc), file=sys.stderr)
|
|
386
|
+
sys.exit(1)
|
|
387
|
+
elif args.library_path is not None:
|
|
388
|
+
from .share import share_library
|
|
389
|
+
|
|
390
|
+
share_library(args.library_path, strict=args.strict, fix=args.fix)
|
|
391
|
+
else:
|
|
392
|
+
from pathlib import Path
|
|
393
|
+
|
|
394
|
+
from .share import derive_share_url_only
|
|
395
|
+
|
|
396
|
+
result = derive_share_url_only(Path.cwd(), ref=args.ref, tag=args.tag)
|
|
397
|
+
if result.share_url is not None:
|
|
398
|
+
print(f"✓ Share this URL:\n {result.share_url}")
|
|
399
|
+
elif result.warning is not None:
|
|
400
|
+
print(f"⚠ {result.warning}")
|
|
401
|
+
else:
|
|
402
|
+
run_app()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
if __name__ in {"__main__", "__mp_main__"}:
|
|
406
|
+
main()
|
haywire_studio/config.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Global and project-level configuration management for Haywire.
|
|
3
|
+
|
|
4
|
+
Global config lives at ~/.haywire/ and stores user preferences,
|
|
5
|
+
marketplace sources, and recently opened projects.
|
|
6
|
+
|
|
7
|
+
Per spec §3.1, the global marketplace file lives under a forward-reference
|
|
8
|
+
subdirectory `~/.haywire/db/haybale-marketplace/` so the future
|
|
9
|
+
haybale-marketplace library carve-out doesn't require a migration. The
|
|
10
|
+
`GLOBAL_MARKETPLACE_DIR` constant is the canonical home for ALL marketplace
|
|
11
|
+
state (marketplace.toml, stalls/, cache/) — every caller that
|
|
12
|
+
reads or writes marketplace files must use it, not `GLOBAL_CONFIG_DIR`.
|
|
13
|
+
|
|
14
|
+
Project config lives at <project>/.haywire/ and stores
|
|
15
|
+
project-specific overrides.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import toml
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
GLOBAL_CONFIG_DIR = Path.home() / ".haywire"
|
|
25
|
+
GLOBAL_MARKETPLACE_DIR = GLOBAL_CONFIG_DIR / "db" / "haybale-marketplace"
|
|
26
|
+
|
|
27
|
+
DEFAULT_GLOBAL_CONFIG = {
|
|
28
|
+
"haywire": {
|
|
29
|
+
"version": "0.1.0",
|
|
30
|
+
},
|
|
31
|
+
"ui": {
|
|
32
|
+
"theme": "modern",
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Spec §3.3 / §11: the official haywire feed is pre-seeded as a [[markets]]
|
|
37
|
+
# subscription on first run. Per slice 1's §14 rename, the section name is
|
|
38
|
+
# `markets` (not the legacy `marketplaces`).
|
|
39
|
+
DEFAULT_MARKETPLACE: dict[str, list[dict]] = {
|
|
40
|
+
"markets": [
|
|
41
|
+
{
|
|
42
|
+
"url": "https://maybites.github.io/haywire/marketplace.toml",
|
|
43
|
+
"ignores": [],
|
|
44
|
+
"doubles": [],
|
|
45
|
+
"blocked": [],
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
DEFAULT_PROJECT_CONFIG = {
|
|
51
|
+
"haywire": {
|
|
52
|
+
"version": "0.1.0",
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def ensure_global_config():
|
|
58
|
+
"""Create ~/.haywire/ + ~/.haywire/db/haybale-marketplace/ with defaults if missing."""
|
|
59
|
+
GLOBAL_CONFIG_DIR.mkdir(exist_ok=True)
|
|
60
|
+
GLOBAL_MARKETPLACE_DIR.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
config_file = GLOBAL_CONFIG_DIR / "config.toml"
|
|
63
|
+
if not config_file.exists():
|
|
64
|
+
config_file.write_text(toml.dumps(DEFAULT_GLOBAL_CONFIG))
|
|
65
|
+
|
|
66
|
+
marketplace_file = GLOBAL_MARKETPLACE_DIR / "marketplace.toml"
|
|
67
|
+
if not marketplace_file.exists():
|
|
68
|
+
marketplace_file.write_text(toml.dumps(DEFAULT_MARKETPLACE))
|
|
69
|
+
|
|
70
|
+
recent_file = GLOBAL_CONFIG_DIR / "recent_projects.toml"
|
|
71
|
+
if not recent_file.exists():
|
|
72
|
+
recent_file.write_text(toml.dumps({"projects": []}))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_global_config() -> dict:
|
|
76
|
+
"""Read ~/.haywire/config.toml."""
|
|
77
|
+
ensure_global_config()
|
|
78
|
+
return toml.loads((GLOBAL_CONFIG_DIR / "config.toml").read_text())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_recent_projects() -> list[str]:
|
|
82
|
+
"""Read ~/.haywire/recent_projects.toml."""
|
|
83
|
+
ensure_global_config()
|
|
84
|
+
data = toml.loads((GLOBAL_CONFIG_DIR / "recent_projects.toml").read_text())
|
|
85
|
+
return data.get("projects", [])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def add_recent_project(project_path: str):
|
|
89
|
+
"""Add a project path to the recent projects list."""
|
|
90
|
+
ensure_global_config()
|
|
91
|
+
recent_file = GLOBAL_CONFIG_DIR / "recent_projects.toml"
|
|
92
|
+
data = toml.loads(recent_file.read_text())
|
|
93
|
+
projects = data.get("projects", [])
|
|
94
|
+
|
|
95
|
+
# Normalize and deduplicate
|
|
96
|
+
abs_path = os.path.abspath(project_path)
|
|
97
|
+
if abs_path in projects:
|
|
98
|
+
projects.remove(abs_path)
|
|
99
|
+
projects.insert(0, abs_path)
|
|
100
|
+
|
|
101
|
+
# Keep only last 20
|
|
102
|
+
data["projects"] = projects[:20]
|
|
103
|
+
recent_file.write_text(toml.dumps(data))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ensure_project_config(project_dir: Path):
|
|
107
|
+
"""Create <project>/.haywire/ with defaults if it doesn't exist."""
|
|
108
|
+
haywire_dir = project_dir / ".haywire"
|
|
109
|
+
haywire_dir.mkdir(exist_ok=True)
|
|
110
|
+
|
|
111
|
+
config_file = haywire_dir / "config.toml"
|
|
112
|
+
if not config_file.exists():
|
|
113
|
+
config_file.write_text(toml.dumps(DEFAULT_PROJECT_CONFIG))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_project_config(project_dir: Path) -> dict:
|
|
117
|
+
"""Read <project>/.haywire/config.toml."""
|
|
118
|
+
config_file = project_dir / ".haywire" / "config.toml"
|
|
119
|
+
if config_file.exists():
|
|
120
|
+
return toml.loads(config_file.read_text())
|
|
121
|
+
return {}
|