devsync 0.5.5__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.
- aiconfigkit/__init__.py +0 -0
- aiconfigkit/__main__.py +6 -0
- aiconfigkit/ai_tools/__init__.py +0 -0
- aiconfigkit/ai_tools/base.py +236 -0
- aiconfigkit/ai_tools/capability_registry.py +262 -0
- aiconfigkit/ai_tools/claude.py +91 -0
- aiconfigkit/ai_tools/claude_desktop.py +97 -0
- aiconfigkit/ai_tools/cline.py +92 -0
- aiconfigkit/ai_tools/copilot.py +92 -0
- aiconfigkit/ai_tools/cursor.py +109 -0
- aiconfigkit/ai_tools/detector.py +169 -0
- aiconfigkit/ai_tools/kiro.py +85 -0
- aiconfigkit/ai_tools/mcp_syncer.py +291 -0
- aiconfigkit/ai_tools/roo.py +110 -0
- aiconfigkit/ai_tools/translator.py +390 -0
- aiconfigkit/ai_tools/winsurf.py +102 -0
- aiconfigkit/cli/__init__.py +0 -0
- aiconfigkit/cli/delete.py +118 -0
- aiconfigkit/cli/download.py +274 -0
- aiconfigkit/cli/install.py +237 -0
- aiconfigkit/cli/install_new.py +937 -0
- aiconfigkit/cli/list.py +275 -0
- aiconfigkit/cli/main.py +454 -0
- aiconfigkit/cli/mcp_configure.py +232 -0
- aiconfigkit/cli/mcp_install.py +166 -0
- aiconfigkit/cli/mcp_sync.py +165 -0
- aiconfigkit/cli/package.py +383 -0
- aiconfigkit/cli/package_create.py +323 -0
- aiconfigkit/cli/package_install.py +472 -0
- aiconfigkit/cli/template.py +19 -0
- aiconfigkit/cli/template_backup.py +261 -0
- aiconfigkit/cli/template_init.py +499 -0
- aiconfigkit/cli/template_install.py +261 -0
- aiconfigkit/cli/template_list.py +172 -0
- aiconfigkit/cli/template_uninstall.py +146 -0
- aiconfigkit/cli/template_update.py +225 -0
- aiconfigkit/cli/template_validate.py +234 -0
- aiconfigkit/cli/tools.py +47 -0
- aiconfigkit/cli/uninstall.py +125 -0
- aiconfigkit/cli/update.py +309 -0
- aiconfigkit/core/__init__.py +0 -0
- aiconfigkit/core/checksum.py +211 -0
- aiconfigkit/core/component_detector.py +905 -0
- aiconfigkit/core/conflict_resolution.py +329 -0
- aiconfigkit/core/git_operations.py +539 -0
- aiconfigkit/core/mcp/__init__.py +1 -0
- aiconfigkit/core/mcp/credentials.py +279 -0
- aiconfigkit/core/mcp/manager.py +308 -0
- aiconfigkit/core/mcp/set_manager.py +1 -0
- aiconfigkit/core/mcp/validator.py +1 -0
- aiconfigkit/core/models.py +1661 -0
- aiconfigkit/core/package_creator.py +743 -0
- aiconfigkit/core/package_manifest.py +248 -0
- aiconfigkit/core/repository.py +298 -0
- aiconfigkit/core/secret_detector.py +438 -0
- aiconfigkit/core/template_manifest.py +283 -0
- aiconfigkit/core/version.py +201 -0
- aiconfigkit/storage/__init__.py +0 -0
- aiconfigkit/storage/library.py +429 -0
- aiconfigkit/storage/mcp_tracker.py +1 -0
- aiconfigkit/storage/package_tracker.py +234 -0
- aiconfigkit/storage/template_library.py +229 -0
- aiconfigkit/storage/template_tracker.py +296 -0
- aiconfigkit/storage/tracker.py +416 -0
- aiconfigkit/tui/__init__.py +5 -0
- aiconfigkit/tui/installer.py +511 -0
- aiconfigkit/utils/__init__.py +0 -0
- aiconfigkit/utils/atomic_write.py +90 -0
- aiconfigkit/utils/backup.py +169 -0
- aiconfigkit/utils/dotenv.py +128 -0
- aiconfigkit/utils/git_helpers.py +187 -0
- aiconfigkit/utils/logging.py +60 -0
- aiconfigkit/utils/namespace.py +134 -0
- aiconfigkit/utils/paths.py +205 -0
- aiconfigkit/utils/project.py +109 -0
- aiconfigkit/utils/streaming.py +216 -0
- aiconfigkit/utils/ui.py +194 -0
- aiconfigkit/utils/validation.py +187 -0
- devsync-0.5.5.dist-info/LICENSE +21 -0
- devsync-0.5.5.dist-info/METADATA +477 -0
- devsync-0.5.5.dist-info/RECORD +84 -0
- devsync-0.5.5.dist-info/WHEEL +5 -0
- devsync-0.5.5.dist-info/entry_points.txt +2 -0
- devsync-0.5.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Textual TUI for installing instructions from library."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from textual import on
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
8
|
+
from textual.screen import Screen
|
|
9
|
+
from textual.widgets import (
|
|
10
|
+
Button,
|
|
11
|
+
Checkbox,
|
|
12
|
+
DataTable,
|
|
13
|
+
Footer,
|
|
14
|
+
Header,
|
|
15
|
+
Input,
|
|
16
|
+
Label,
|
|
17
|
+
Select,
|
|
18
|
+
Static,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from aiconfigkit.ai_tools.detector import get_detector
|
|
22
|
+
from aiconfigkit.core.models import InstallationScope
|
|
23
|
+
from aiconfigkit.storage.library import LibraryManager
|
|
24
|
+
from aiconfigkit.utils.project import find_project_root
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InstructionInstallerScreen(Screen):
|
|
28
|
+
"""Main screen for selecting and installing instructions."""
|
|
29
|
+
|
|
30
|
+
CSS = """
|
|
31
|
+
Screen {
|
|
32
|
+
background: $background;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#title-container {
|
|
36
|
+
height: 3;
|
|
37
|
+
background: $boost;
|
|
38
|
+
padding: 1;
|
|
39
|
+
border-bottom: solid $primary;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#app-title {
|
|
43
|
+
text-align: center;
|
|
44
|
+
text-style: bold;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#search-container {
|
|
48
|
+
height: 3;
|
|
49
|
+
padding: 0 1;
|
|
50
|
+
margin-top: 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#filter-container {
|
|
54
|
+
height: 3;
|
|
55
|
+
padding: 0 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#instructions-table {
|
|
59
|
+
height: 1fr;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#installation-settings {
|
|
63
|
+
height: auto;
|
|
64
|
+
padding: 1;
|
|
65
|
+
background: $panel;
|
|
66
|
+
border-top: solid $primary;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#tools-container {
|
|
70
|
+
height: auto;
|
|
71
|
+
padding: 0 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#scope-container {
|
|
75
|
+
height: auto;
|
|
76
|
+
padding: 0 1;
|
|
77
|
+
margin-bottom: 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#status-bar {
|
|
81
|
+
height: auto;
|
|
82
|
+
padding: 1;
|
|
83
|
+
background: $surface;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#actions-container {
|
|
87
|
+
height: 3;
|
|
88
|
+
padding: 0 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
Button {
|
|
92
|
+
margin: 0 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
Checkbox {
|
|
96
|
+
margin: 0 2;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.selected-row {
|
|
100
|
+
background: $accent;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.setting-label {
|
|
104
|
+
text-style: bold;
|
|
105
|
+
color: $primary;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.help-text {
|
|
109
|
+
color: $text-muted;
|
|
110
|
+
}
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
BINDINGS = [
|
|
114
|
+
("escape", "quit", "Quit"),
|
|
115
|
+
("space", "toggle_selection", "Toggle Selection"),
|
|
116
|
+
("enter", "toggle_selection", "Toggle Selection"),
|
|
117
|
+
("ctrl+a", "select_all", "Select All"),
|
|
118
|
+
("ctrl+d", "deselect_all", "Deselect All"),
|
|
119
|
+
("ctrl+l", "clear_search", "Clear Search"),
|
|
120
|
+
("/", "focus_search", "Search"),
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
library: LibraryManager,
|
|
126
|
+
tool: Optional[str] = None,
|
|
127
|
+
):
|
|
128
|
+
"""
|
|
129
|
+
Initialize installer screen.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
library: Library manager instance
|
|
133
|
+
tool: AI tool to install to (ignored - user must select)
|
|
134
|
+
"""
|
|
135
|
+
super().__init__()
|
|
136
|
+
self.library = library
|
|
137
|
+
# Always use project scope
|
|
138
|
+
self.scope = InstallationScope.PROJECT
|
|
139
|
+
self.instructions = library.list_instructions()
|
|
140
|
+
self.filtered_instructions = self.instructions.copy()
|
|
141
|
+
self.selected_ids: set[str] = set()
|
|
142
|
+
|
|
143
|
+
# Get current directory info for display
|
|
144
|
+
from pathlib import Path
|
|
145
|
+
|
|
146
|
+
self.current_dir = Path.cwd()
|
|
147
|
+
self.project_root = find_project_root()
|
|
148
|
+
|
|
149
|
+
# Detect available AI tools
|
|
150
|
+
detector = get_detector()
|
|
151
|
+
self.available_tools = detector.detect_installed_tools()
|
|
152
|
+
|
|
153
|
+
# No default tool selection - user must explicitly select
|
|
154
|
+
self.selected_tools: set[str] = set()
|
|
155
|
+
|
|
156
|
+
def compose(self) -> ComposeResult:
|
|
157
|
+
"""Create child widgets."""
|
|
158
|
+
yield Header(show_clock=True)
|
|
159
|
+
|
|
160
|
+
# Branded title section
|
|
161
|
+
with Container(id="title-container"):
|
|
162
|
+
yield Static(
|
|
163
|
+
"🎯 [bold cyan]InstructionKit[/bold cyan] [dim]│[/dim] " "Browse & Install Instructions",
|
|
164
|
+
id="app-title",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Search container
|
|
168
|
+
with Container(id="search-container"):
|
|
169
|
+
yield Input(placeholder="🔍 Search instructions by name or description...", id="search-input")
|
|
170
|
+
|
|
171
|
+
# Filter container
|
|
172
|
+
with Horizontal(id="filter-container"):
|
|
173
|
+
# Repository filter
|
|
174
|
+
repo_options = [("All Repositories", "")]
|
|
175
|
+
repos = {inst.repo_namespace: inst.repo_name for inst in self.instructions}
|
|
176
|
+
repo_options.extend([(name, namespace) for namespace, name in repos.items()])
|
|
177
|
+
|
|
178
|
+
yield Label("Filter by Repo:")
|
|
179
|
+
yield Select(
|
|
180
|
+
options=repo_options,
|
|
181
|
+
value="",
|
|
182
|
+
id="repo-filter",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Instructions table
|
|
186
|
+
yield DataTable(id="instructions-table")
|
|
187
|
+
|
|
188
|
+
# Installation Settings Section
|
|
189
|
+
with Container(id="installation-settings"):
|
|
190
|
+
yield Label("⚙️ Installation Settings (REQUIRED)", classes="setting-label")
|
|
191
|
+
|
|
192
|
+
# Show installation location info
|
|
193
|
+
with Vertical(id="scope-container"):
|
|
194
|
+
yield Label("Installation location:")
|
|
195
|
+
|
|
196
|
+
# Display where files will be installed
|
|
197
|
+
if self.project_root:
|
|
198
|
+
help_text = f"Files will be installed to: {self.project_root}/<tool-specific-dir>/rules/"
|
|
199
|
+
else:
|
|
200
|
+
help_text = f"Files will be installed to: {self.current_dir}/<tool-specific-dir>/rules/"
|
|
201
|
+
yield Static(help_text, id="scope-help", classes="help-text")
|
|
202
|
+
|
|
203
|
+
# Target tools selection
|
|
204
|
+
with Vertical(id="tools-container"):
|
|
205
|
+
yield Label("Install to which AI tools: *")
|
|
206
|
+
if self.available_tools:
|
|
207
|
+
for tool in self.available_tools:
|
|
208
|
+
tool_id = tool.tool_type.value
|
|
209
|
+
# Start with nothing checked - user must select
|
|
210
|
+
yield Checkbox(
|
|
211
|
+
f"{tool.tool_name}",
|
|
212
|
+
value=False,
|
|
213
|
+
id=f"tool-{tool_id}",
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
yield Static("⚠️ No AI coding tools detected!", classes="help-text")
|
|
217
|
+
|
|
218
|
+
# Status bar
|
|
219
|
+
with Container(id="status-bar"):
|
|
220
|
+
yield Static("", id="status-text")
|
|
221
|
+
|
|
222
|
+
# Action buttons
|
|
223
|
+
with Horizontal(id="actions-container"):
|
|
224
|
+
yield Button("Cancel", variant="default", id="cancel-btn")
|
|
225
|
+
yield Button("Select All", variant="primary", id="select-all-btn")
|
|
226
|
+
yield Button("Clear Selection", variant="default", id="deselect-all-btn")
|
|
227
|
+
yield Button("📦 Install Selected", variant="success", id="install-btn")
|
|
228
|
+
|
|
229
|
+
yield Footer()
|
|
230
|
+
|
|
231
|
+
def on_mount(self) -> None:
|
|
232
|
+
"""Set up the table when mounted."""
|
|
233
|
+
table = self.query_one("#instructions-table", DataTable)
|
|
234
|
+
|
|
235
|
+
# Add columns
|
|
236
|
+
table.add_column("☑", key="selected", width=3)
|
|
237
|
+
table.add_column("Name", key="name", width=25)
|
|
238
|
+
table.add_column("Description", key="description", width=40)
|
|
239
|
+
table.add_column("Repository", key="repo", width=20)
|
|
240
|
+
table.add_column("Author", key="author", width=15)
|
|
241
|
+
table.add_column("Ver", key="version", width=8)
|
|
242
|
+
table.add_column("Tags", key="tags", width=20)
|
|
243
|
+
|
|
244
|
+
# Populate table
|
|
245
|
+
self.refresh_table()
|
|
246
|
+
self.update_status()
|
|
247
|
+
|
|
248
|
+
# Set focus to the table instead of search input
|
|
249
|
+
table.focus()
|
|
250
|
+
|
|
251
|
+
def refresh_table(self) -> None:
|
|
252
|
+
"""Refresh the table with filtered instructions."""
|
|
253
|
+
table = self.query_one("#instructions-table", DataTable)
|
|
254
|
+
table.clear()
|
|
255
|
+
|
|
256
|
+
for inst in self.filtered_instructions:
|
|
257
|
+
is_selected = inst.id in self.selected_ids
|
|
258
|
+
checkbox = "[✓]" if is_selected else "[ ]"
|
|
259
|
+
|
|
260
|
+
# Truncate long text
|
|
261
|
+
name = inst.name[:23] + "..." if len(inst.name) > 23 else inst.name
|
|
262
|
+
desc = inst.description[:38] + "..." if len(inst.description) > 38 else inst.description
|
|
263
|
+
repo = inst.repo_name[:18] + "..." if len(inst.repo_name) > 18 else inst.repo_name
|
|
264
|
+
author = inst.author[:13] + "..." if len(inst.author) > 13 else inst.author
|
|
265
|
+
tags = ", ".join(inst.tags[:2]) if inst.tags else "-"
|
|
266
|
+
if len(inst.tags) > 2:
|
|
267
|
+
tags += f" +{len(inst.tags) - 2}"
|
|
268
|
+
|
|
269
|
+
table.add_row(
|
|
270
|
+
checkbox,
|
|
271
|
+
name,
|
|
272
|
+
desc,
|
|
273
|
+
repo,
|
|
274
|
+
author,
|
|
275
|
+
inst.version,
|
|
276
|
+
tags,
|
|
277
|
+
key=inst.id,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def update_status(self) -> None:
|
|
281
|
+
"""Update the status bar."""
|
|
282
|
+
status = self.query_one("#status-text", Static)
|
|
283
|
+
total = len(self.filtered_instructions)
|
|
284
|
+
selected_instructions = len(self.selected_ids)
|
|
285
|
+
selected_tools_count = len(self.selected_tools)
|
|
286
|
+
|
|
287
|
+
# Tools text
|
|
288
|
+
if selected_tools_count == 0:
|
|
289
|
+
tools_text = "⚠️ None selected"
|
|
290
|
+
elif selected_tools_count == len(self.available_tools):
|
|
291
|
+
tools_text = f"All {selected_tools_count} tools"
|
|
292
|
+
else:
|
|
293
|
+
tools_text = f"{selected_tools_count} tool(s)"
|
|
294
|
+
|
|
295
|
+
status.update(
|
|
296
|
+
f"Instructions: {selected_instructions} selected | {total} shown | "
|
|
297
|
+
f"Target: {tools_text} | Install to: Project"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def filter_instructions(
|
|
301
|
+
self,
|
|
302
|
+
search: str = "",
|
|
303
|
+
repo_namespace: str = "",
|
|
304
|
+
) -> None:
|
|
305
|
+
"""
|
|
306
|
+
Filter instructions based on criteria.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
search: Search query
|
|
310
|
+
repo_namespace: Repository namespace filter
|
|
311
|
+
"""
|
|
312
|
+
self.filtered_instructions = self.instructions.copy()
|
|
313
|
+
|
|
314
|
+
# Apply search filter
|
|
315
|
+
if search:
|
|
316
|
+
query = search.lower()
|
|
317
|
+
self.filtered_instructions = [
|
|
318
|
+
inst
|
|
319
|
+
for inst in self.filtered_instructions
|
|
320
|
+
if query in inst.name.lower() or query in inst.description.lower()
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
# Apply repo filter
|
|
324
|
+
if repo_namespace:
|
|
325
|
+
self.filtered_instructions = [
|
|
326
|
+
inst for inst in self.filtered_instructions if inst.repo_namespace == repo_namespace
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
self.refresh_table()
|
|
330
|
+
self.update_status()
|
|
331
|
+
|
|
332
|
+
@on(Input.Changed, "#search-input")
|
|
333
|
+
def on_search_changed(self, event: Input.Changed) -> None:
|
|
334
|
+
"""Handle search input changes."""
|
|
335
|
+
repo_filter = self.query_one("#repo-filter", Select).value
|
|
336
|
+
# Convert to string, handling NoSelection or other non-string values
|
|
337
|
+
repo_filter_str = str(repo_filter) if repo_filter is not None else ""
|
|
338
|
+
self.filter_instructions(search=event.value, repo_namespace=repo_filter_str)
|
|
339
|
+
|
|
340
|
+
@on(Select.Changed, "#repo-filter")
|
|
341
|
+
def on_repo_filter_changed(self, event: Select.Changed) -> None:
|
|
342
|
+
"""Handle repository filter changes."""
|
|
343
|
+
search = self.query_one("#search-input", Input).value
|
|
344
|
+
self.filter_instructions(search=search, repo_namespace=str(event.value))
|
|
345
|
+
|
|
346
|
+
@on(Checkbox.Changed)
|
|
347
|
+
def on_tool_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
|
348
|
+
"""Handle tool checkbox changes."""
|
|
349
|
+
# Extract tool name from checkbox ID (format: "tool-cursor")
|
|
350
|
+
checkbox_id = event.checkbox.id
|
|
351
|
+
if checkbox_id and checkbox_id.startswith("tool-"):
|
|
352
|
+
tool_name = checkbox_id[5:] # Remove "tool-" prefix
|
|
353
|
+
|
|
354
|
+
if event.value:
|
|
355
|
+
self.selected_tools.add(tool_name)
|
|
356
|
+
else:
|
|
357
|
+
self.selected_tools.discard(tool_name)
|
|
358
|
+
|
|
359
|
+
self.update_status()
|
|
360
|
+
|
|
361
|
+
@on(DataTable.RowSelected)
|
|
362
|
+
def on_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
363
|
+
"""Toggle selection when row is clicked."""
|
|
364
|
+
if event.row_key:
|
|
365
|
+
instruction_id = str(event.row_key.value)
|
|
366
|
+
|
|
367
|
+
if instruction_id in self.selected_ids:
|
|
368
|
+
self.selected_ids.remove(instruction_id)
|
|
369
|
+
else:
|
|
370
|
+
self.selected_ids.add(instruction_id)
|
|
371
|
+
|
|
372
|
+
self.refresh_table()
|
|
373
|
+
self.update_status()
|
|
374
|
+
|
|
375
|
+
@on(Button.Pressed, "#select-all-btn")
|
|
376
|
+
def action_select_all(self) -> None:
|
|
377
|
+
"""Select all filtered instructions."""
|
|
378
|
+
self.selected_ids.update(inst.id for inst in self.filtered_instructions)
|
|
379
|
+
self.refresh_table()
|
|
380
|
+
self.update_status()
|
|
381
|
+
|
|
382
|
+
@on(Button.Pressed, "#deselect-all-btn")
|
|
383
|
+
def action_deselect_all(self) -> None:
|
|
384
|
+
"""Deselect all instructions."""
|
|
385
|
+
self.selected_ids.clear()
|
|
386
|
+
self.refresh_table()
|
|
387
|
+
self.update_status()
|
|
388
|
+
|
|
389
|
+
def action_toggle_selection(self) -> None:
|
|
390
|
+
"""Toggle selection of the currently highlighted row."""
|
|
391
|
+
table = self.query_one("#instructions-table", DataTable)
|
|
392
|
+
|
|
393
|
+
# Get the currently highlighted row
|
|
394
|
+
if table.cursor_row is not None and table.cursor_row >= 0:
|
|
395
|
+
# Get all row keys as a list
|
|
396
|
+
row_keys = list(table.rows.keys())
|
|
397
|
+
if table.cursor_row < len(row_keys):
|
|
398
|
+
row_key = row_keys[table.cursor_row]
|
|
399
|
+
# Access the .value property of the RowKey to get the actual instruction ID
|
|
400
|
+
instruction_id = str(row_key.value)
|
|
401
|
+
|
|
402
|
+
# Toggle selection
|
|
403
|
+
if instruction_id in self.selected_ids:
|
|
404
|
+
self.selected_ids.remove(instruction_id)
|
|
405
|
+
else:
|
|
406
|
+
self.selected_ids.add(instruction_id)
|
|
407
|
+
|
|
408
|
+
self.refresh_table()
|
|
409
|
+
self.update_status()
|
|
410
|
+
|
|
411
|
+
def action_clear_search(self) -> None:
|
|
412
|
+
"""Clear search input."""
|
|
413
|
+
search_input = self.query_one("#search-input", Input)
|
|
414
|
+
search_input.value = ""
|
|
415
|
+
search_input.focus()
|
|
416
|
+
|
|
417
|
+
def action_focus_search(self) -> None:
|
|
418
|
+
"""Focus the search input."""
|
|
419
|
+
self.query_one("#search-input", Input).focus()
|
|
420
|
+
|
|
421
|
+
@on(Button.Pressed, "#cancel-btn")
|
|
422
|
+
def action_quit(self) -> None:
|
|
423
|
+
"""Cancel and exit."""
|
|
424
|
+
self.dismiss(None)
|
|
425
|
+
|
|
426
|
+
@on(Button.Pressed, "#install-btn")
|
|
427
|
+
def on_install_pressed(self) -> None:
|
|
428
|
+
"""Handle install button press."""
|
|
429
|
+
# Validate all required selections
|
|
430
|
+
errors = []
|
|
431
|
+
|
|
432
|
+
if not self.selected_ids:
|
|
433
|
+
errors.append("Please select at least one instruction")
|
|
434
|
+
|
|
435
|
+
if not self.selected_tools:
|
|
436
|
+
errors.append("Please select at least one AI tool")
|
|
437
|
+
|
|
438
|
+
# Show all errors
|
|
439
|
+
if errors:
|
|
440
|
+
for error in errors:
|
|
441
|
+
self.app.notify(error, severity="error", timeout=4)
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
# Get selected instructions
|
|
445
|
+
selected_instructions = [inst for inst in self.instructions if inst.id in self.selected_ids]
|
|
446
|
+
|
|
447
|
+
# Return result with selected tools as a list
|
|
448
|
+
self.dismiss(
|
|
449
|
+
{
|
|
450
|
+
"instructions": selected_instructions,
|
|
451
|
+
"tools": list(self.selected_tools), # Return as list
|
|
452
|
+
}
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class InstructionInstallerApp(App):
|
|
457
|
+
"""Application for installing instructions."""
|
|
458
|
+
|
|
459
|
+
TITLE = "InstructionKit Installer"
|
|
460
|
+
SUB_TITLE = "Browse, Select & Install Instructions"
|
|
461
|
+
|
|
462
|
+
def __init__(
|
|
463
|
+
self,
|
|
464
|
+
library: LibraryManager,
|
|
465
|
+
tool: Optional[str] = None,
|
|
466
|
+
):
|
|
467
|
+
"""
|
|
468
|
+
Initialize installer app.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
library: Library manager instance
|
|
472
|
+
tool: AI tool to install to (None = all)
|
|
473
|
+
"""
|
|
474
|
+
super().__init__()
|
|
475
|
+
self.library = library
|
|
476
|
+
self.tool = tool
|
|
477
|
+
self.result: Optional[dict] = None
|
|
478
|
+
|
|
479
|
+
def on_mount(self) -> None:
|
|
480
|
+
"""Push the installer screen when app mounts."""
|
|
481
|
+
screen = InstructionInstallerScreen(
|
|
482
|
+
library=self.library,
|
|
483
|
+
tool=self.tool,
|
|
484
|
+
)
|
|
485
|
+
self.push_screen(screen, self.handle_result)
|
|
486
|
+
|
|
487
|
+
def handle_result(self, result: Optional[dict]) -> None:
|
|
488
|
+
"""Handle result from installer screen."""
|
|
489
|
+
self.result = result
|
|
490
|
+
self.exit()
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def show_installer_tui(
|
|
494
|
+
library: LibraryManager,
|
|
495
|
+
tool: Optional[str] = None,
|
|
496
|
+
) -> Optional[dict]:
|
|
497
|
+
"""
|
|
498
|
+
Show the instruction installer TUI.
|
|
499
|
+
|
|
500
|
+
All installations are at project level.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
library: Library manager instance
|
|
504
|
+
tool: AI tool to install to (None = all)
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
Dictionary with selected instructions and settings, or None if cancelled
|
|
508
|
+
"""
|
|
509
|
+
app = InstructionInstallerApp(library=library, tool=tool)
|
|
510
|
+
app.run()
|
|
511
|
+
return app.result
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Atomic file write utilities for safe config file updates."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Generator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@contextmanager
|
|
11
|
+
def atomic_write(
|
|
12
|
+
file_path: Path,
|
|
13
|
+
mode: str = "w",
|
|
14
|
+
encoding: str = "utf-8",
|
|
15
|
+
create_backup: bool = True,
|
|
16
|
+
) -> Generator:
|
|
17
|
+
"""
|
|
18
|
+
Context manager for atomic file writes using temp file + os.replace().
|
|
19
|
+
|
|
20
|
+
This ensures that file writes are atomic - if the write fails, the original
|
|
21
|
+
file is left unchanged. If create_backup is True, creates a backup before
|
|
22
|
+
replacing the original file.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
file_path: Path to file to write
|
|
26
|
+
mode: File open mode ("w" for text, "wb" for binary)
|
|
27
|
+
encoding: Text encoding (ignored for binary mode)
|
|
28
|
+
create_backup: Whether to create .bak backup before replacing
|
|
29
|
+
|
|
30
|
+
Yields:
|
|
31
|
+
File object for writing
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
with atomic_write(Path("config.json")) as f:
|
|
35
|
+
json.dump(config, f)
|
|
36
|
+
"""
|
|
37
|
+
file_path = Path(file_path).resolve()
|
|
38
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
# Create backup if requested and file exists
|
|
41
|
+
backup_path = None
|
|
42
|
+
if create_backup and file_path.exists():
|
|
43
|
+
backup_path = file_path.with_suffix(file_path.suffix + ".bak")
|
|
44
|
+
# Copy original to backup
|
|
45
|
+
import shutil
|
|
46
|
+
|
|
47
|
+
shutil.copy2(file_path, backup_path)
|
|
48
|
+
|
|
49
|
+
# Create temporary file in same directory as target
|
|
50
|
+
# (ensures atomic rename on all platforms)
|
|
51
|
+
fd, temp_path = tempfile.mkstemp(dir=file_path.parent, prefix=f".{file_path.name}.", suffix=".tmp")
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
# Open temp file with requested mode
|
|
55
|
+
if "b" in mode:
|
|
56
|
+
# Binary mode
|
|
57
|
+
temp_file = os.fdopen(fd, mode)
|
|
58
|
+
else:
|
|
59
|
+
# Text mode
|
|
60
|
+
temp_file = os.fdopen(fd, mode, encoding=encoding)
|
|
61
|
+
|
|
62
|
+
with temp_file as f:
|
|
63
|
+
yield f
|
|
64
|
+
# Ensure data is written to disk
|
|
65
|
+
f.flush()
|
|
66
|
+
os.fsync(f.fileno())
|
|
67
|
+
|
|
68
|
+
# Atomic rename (replaces target file)
|
|
69
|
+
# os.replace() is atomic on all platforms
|
|
70
|
+
os.replace(temp_path, file_path)
|
|
71
|
+
|
|
72
|
+
except Exception:
|
|
73
|
+
# Clean up temp file on error
|
|
74
|
+
try:
|
|
75
|
+
os.unlink(temp_path)
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
# Restore from backup if available
|
|
80
|
+
if backup_path and backup_path.exists():
|
|
81
|
+
import shutil
|
|
82
|
+
|
|
83
|
+
shutil.copy2(backup_path, file_path)
|
|
84
|
+
|
|
85
|
+
raise
|
|
86
|
+
|
|
87
|
+
finally:
|
|
88
|
+
# Clean up backup file (optional - could keep for user reference)
|
|
89
|
+
# For now, keep backups for safety
|
|
90
|
+
pass
|