clerk-sdk 0.4.17__py3-none-any.whl → 0.5.0.dev0__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.
- clerk/__init__.py +1 -1
- clerk/development/__init__.py +0 -0
- clerk/development/cli.py +113 -0
- clerk/development/gui/test_session.py +327 -0
- clerk/development/init_project.py +325 -0
- clerk/development/schema/fetch_schema.py +339 -0
- clerk/development/templates/exceptions.py.template +16 -0
- clerk/development/templates/main_basic.py.template +22 -0
- clerk/development/templates/main_gui.py.template +52 -0
- clerk/development/templates/rollbacks.py.template +17 -0
- clerk/development/templates/states.py.template +15 -0
- clerk/development/templates/transitions.py.template +26 -0
- clerk/gui_automation/requirements.txt +2 -0
- clerk/gui_automation/ui_actions/base.py +17 -1
- clerk/gui_automation/ui_state_machine/Readme.md +79 -0
- {clerk_sdk-0.4.17.dist-info → clerk_sdk-0.5.0.dev0.dist-info}/METADATA +13 -26
- {clerk_sdk-0.4.17.dist-info → clerk_sdk-0.5.0.dev0.dist-info}/RECORD +20 -7
- {clerk_sdk-0.4.17.dist-info → clerk_sdk-0.5.0.dev0.dist-info}/WHEEL +1 -2
- clerk_sdk-0.5.0.dev0.dist-info/entry_points.txt +2 -0
- clerk_sdk-0.4.17.dist-info/top_level.txt +0 -1
- {clerk_sdk-0.4.17.dist-info → clerk_sdk-0.5.0.dev0.dist-info}/licenses/LICENSE +0 -0
clerk/__init__.py
CHANGED
|
File without changes
|
clerk/development/cli.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Clerk CLI - Unified command-line interface for Clerk development tools"""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import argparse
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def find_project_root() -> Path:
|
|
10
|
+
"""Find the project root by looking for common markers"""
|
|
11
|
+
cwd = Path.cwd()
|
|
12
|
+
|
|
13
|
+
project_root_files = ["pyproject.toml", ".env"]
|
|
14
|
+
|
|
15
|
+
# Check current directory and parents
|
|
16
|
+
for path in [cwd] + list(cwd.parents):
|
|
17
|
+
for marker in project_root_files:
|
|
18
|
+
if (path / marker).exists():
|
|
19
|
+
return path
|
|
20
|
+
|
|
21
|
+
return cwd
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main():
|
|
25
|
+
"""Main CLI entry point with subcommands"""
|
|
26
|
+
# Find project root and load environment variables from there
|
|
27
|
+
project_root = find_project_root()
|
|
28
|
+
dotenv_path = project_root / ".env"
|
|
29
|
+
load_dotenv(dotenv_path)
|
|
30
|
+
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="clerk",
|
|
33
|
+
description="Clerk development tools",
|
|
34
|
+
epilog="Run 'clerk <command> --help' for more information on a command."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
38
|
+
|
|
39
|
+
# Init project subcommand
|
|
40
|
+
init_parser = subparsers.add_parser(
|
|
41
|
+
"init", help="Initialize a new Clerk custom code project"
|
|
42
|
+
)
|
|
43
|
+
init_parser.add_argument(
|
|
44
|
+
"--target-dir",
|
|
45
|
+
type=str,
|
|
46
|
+
default=None,
|
|
47
|
+
help="Target directory for the project (default: ./src)",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# GUI command group
|
|
51
|
+
gui_parser = subparsers.add_parser(
|
|
52
|
+
"gui",
|
|
53
|
+
help="GUI automation commands"
|
|
54
|
+
)
|
|
55
|
+
gui_subparsers = gui_parser.add_subparsers(dest="gui_command", help="GUI subcommands")
|
|
56
|
+
|
|
57
|
+
# GUI connect subcommand
|
|
58
|
+
gui_connect_parser = gui_subparsers.add_parser(
|
|
59
|
+
"connect",
|
|
60
|
+
help="Start interactive GUI automation test session"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Schema command group
|
|
64
|
+
schema_parser = subparsers.add_parser(
|
|
65
|
+
"schema",
|
|
66
|
+
help="Schema management commands"
|
|
67
|
+
)
|
|
68
|
+
schema_subparsers = schema_parser.add_subparsers(dest="schema_command", help="Schema subcommands")
|
|
69
|
+
|
|
70
|
+
# Schema fetch subcommand
|
|
71
|
+
schema_fetch_parser = schema_subparsers.add_parser(
|
|
72
|
+
"fetch",
|
|
73
|
+
help="Fetch and generate Pydantic models from project schema"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
args = parser.parse_args()
|
|
77
|
+
|
|
78
|
+
# Show help if no command specified
|
|
79
|
+
if not args.command:
|
|
80
|
+
parser.print_help()
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
|
|
83
|
+
# Route to appropriate handler
|
|
84
|
+
if args.command == "init":
|
|
85
|
+
from clerk.development.init_project import main_with_args
|
|
86
|
+
|
|
87
|
+
main_with_args(gui_automation=None, target_dir=args.target_dir)
|
|
88
|
+
|
|
89
|
+
elif args.command == "gui":
|
|
90
|
+
if not hasattr(args, 'gui_command') or not args.gui_command:
|
|
91
|
+
gui_parser.print_help()
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
|
|
94
|
+
if args.gui_command == "connect":
|
|
95
|
+
from clerk.development.gui.test_session import main as gui_main
|
|
96
|
+
gui_main()
|
|
97
|
+
|
|
98
|
+
elif args.command == "schema":
|
|
99
|
+
if not hasattr(args, 'schema_command') or not args.schema_command:
|
|
100
|
+
schema_parser.print_help()
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
if args.schema_command == "fetch":
|
|
104
|
+
from clerk.development.schema.fetch_schema import main_with_args
|
|
105
|
+
project_id = os.getenv("PROJECT_ID")
|
|
106
|
+
if not project_id:
|
|
107
|
+
print("Error: PROJECT_ID environment variable not set.")
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
main_with_args(project_id, project_root)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
main()
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
import traceback
|
|
6
|
+
import importlib
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from clerk.gui_automation.ui_actions.actions import (
|
|
15
|
+
File,
|
|
16
|
+
LeftClick,
|
|
17
|
+
RightClick,
|
|
18
|
+
DoubleClick,
|
|
19
|
+
PressKeys,
|
|
20
|
+
SendKeys,
|
|
21
|
+
WaitFor,
|
|
22
|
+
Scroll,
|
|
23
|
+
OpenApplication,
|
|
24
|
+
ForceCloseApplication,
|
|
25
|
+
SaveFiles,
|
|
26
|
+
DeleteFiles,
|
|
27
|
+
GetFile,
|
|
28
|
+
MaximizeWindow,
|
|
29
|
+
MinimizeWindow,
|
|
30
|
+
CloseWindow,
|
|
31
|
+
ActivateWindow,
|
|
32
|
+
GetText,
|
|
33
|
+
PasteText,
|
|
34
|
+
BaseAction
|
|
35
|
+
)
|
|
36
|
+
from clerk.gui_automation.decorators import gui_automation
|
|
37
|
+
from clerk.decorator.models import ClerkCodePayload, Document
|
|
38
|
+
from clerk.gui_automation.ui_state_machine.state_machine import ScreenPilot
|
|
39
|
+
from clerk.gui_automation.ui_state_inspector.gui_vision import Vision, BaseState
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Initialize rich console
|
|
43
|
+
console = Console()
|
|
44
|
+
|
|
45
|
+
# Store session state
|
|
46
|
+
SESSION_FILE = Path(".test_session_active")
|
|
47
|
+
ACTION_HISTORY = []
|
|
48
|
+
VISION_CLIENT = Vision()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_project_root() -> Path:
|
|
52
|
+
"""Find the project root by looking for common markers"""
|
|
53
|
+
cwd = Path.cwd()
|
|
54
|
+
|
|
55
|
+
project_root_files = ["pyproject.toml"]
|
|
56
|
+
|
|
57
|
+
# Check current directory and parents
|
|
58
|
+
for path in [cwd] + list(cwd.parents):
|
|
59
|
+
for marker in project_root_files:
|
|
60
|
+
if (path / marker).exists():
|
|
61
|
+
return path
|
|
62
|
+
|
|
63
|
+
return cwd
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def reload_states() -> int:
|
|
67
|
+
"""Reload states from conventional paths. Returns number of states loaded."""
|
|
68
|
+
project_root = find_project_root()
|
|
69
|
+
|
|
70
|
+
# Common module paths where states might be defined
|
|
71
|
+
# These are module paths (dot-separated), not file paths
|
|
72
|
+
state_module_paths = ["src.gui.states", "states"]
|
|
73
|
+
|
|
74
|
+
loaded_count = 0
|
|
75
|
+
|
|
76
|
+
# Add project root to sys.path if not already there
|
|
77
|
+
if str(project_root) not in sys.path:
|
|
78
|
+
sys.path.insert(0, str(project_root))
|
|
79
|
+
|
|
80
|
+
# Try to import/reload each module path
|
|
81
|
+
for module_path in state_module_paths:
|
|
82
|
+
try:
|
|
83
|
+
# Reload if already imported, otherwise import fresh
|
|
84
|
+
if module_path in sys.modules:
|
|
85
|
+
importlib.reload(sys.modules[module_path])
|
|
86
|
+
else:
|
|
87
|
+
importlib.import_module(module_path)
|
|
88
|
+
loaded_count += 1
|
|
89
|
+
except Exception:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
return loaded_count
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_registered_states() -> dict:
|
|
96
|
+
"""Get all registered states from ScreenPilot"""
|
|
97
|
+
states = {}
|
|
98
|
+
for state_name, data in ScreenPilot._graph.nodes(data=True):
|
|
99
|
+
state_cls = data.get("cls")
|
|
100
|
+
if state_cls:
|
|
101
|
+
states[state_name] = {
|
|
102
|
+
"description": getattr(state_cls, "description", "No description"),
|
|
103
|
+
"class": state_cls,
|
|
104
|
+
}
|
|
105
|
+
return states
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def classify_current_state() -> tuple[bool, str, str]:
|
|
109
|
+
"""Classify the current GUI state using Vision. Reloads states first."""
|
|
110
|
+
try:
|
|
111
|
+
# Always reload states to pick up any changes
|
|
112
|
+
with console.status("[dim]Reloading states...", spinner="dots") as status:
|
|
113
|
+
reload_states()
|
|
114
|
+
status.update("[green]+[/green] Reloaded states")
|
|
115
|
+
time.sleep(0.3) # Brief pause to show success
|
|
116
|
+
|
|
117
|
+
states = get_registered_states()
|
|
118
|
+
|
|
119
|
+
if not states:
|
|
120
|
+
return (
|
|
121
|
+
False,
|
|
122
|
+
"",
|
|
123
|
+
"No states found. Make sure state definitions exist in your project.",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Convert to format expected by Vision.classify_state
|
|
127
|
+
possible_states = [
|
|
128
|
+
{"id": name, "description": data["description"]}
|
|
129
|
+
for name, data in states.items()
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
with console.status(
|
|
133
|
+
"[dim]Classifying current state (waiting for AI)...", spinner="dots"
|
|
134
|
+
):
|
|
135
|
+
# Pass output_model=None to get tuple instead of BaseModel
|
|
136
|
+
result: BaseState = VISION_CLIENT.classify_state(possible_states) # type: ignore[arg-type]
|
|
137
|
+
|
|
138
|
+
console.print("[green]+[/green] Classification complete")
|
|
139
|
+
return True, result.id, result.description
|
|
140
|
+
except Exception:
|
|
141
|
+
return False, "", f"Classification failed: {traceback.format_exc()}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def print_welcome():
|
|
145
|
+
"""Print welcome message"""
|
|
146
|
+
title = Text("GUI Automation Interactive Test Session", style="bold cyan")
|
|
147
|
+
panel = Panel(title, border_style="cyan", padding=(1, 2))
|
|
148
|
+
console.print()
|
|
149
|
+
console.print(panel)
|
|
150
|
+
console.print()
|
|
151
|
+
console.print("[bold blue]Commands:[/bold blue]")
|
|
152
|
+
console.print(
|
|
153
|
+
" [dim]classify_state: Classify current GUI state (auto-reloads states)[/dim]"
|
|
154
|
+
)
|
|
155
|
+
console.print(" [dim]exit: End session[/dim]")
|
|
156
|
+
console.print()
|
|
157
|
+
console.print("[bold blue]Testing actions:[/bold blue]")
|
|
158
|
+
console.print(" [dim]Type an action and press Enter to execute[/dim]")
|
|
159
|
+
console.print()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def perform_single_action(action_string: str) -> tuple[bool, Any, str]:
|
|
163
|
+
"""Execute a single action and return success status, result, and error message"""
|
|
164
|
+
try:
|
|
165
|
+
# Ensure action has .do() call
|
|
166
|
+
if not "do(" in action_string:
|
|
167
|
+
action_string = f"{action_string}.do()"
|
|
168
|
+
|
|
169
|
+
# Execute and capture result
|
|
170
|
+
result = eval(action_string)
|
|
171
|
+
return True, result, ""
|
|
172
|
+
except Exception as e:
|
|
173
|
+
error_msg = traceback.format_exc()
|
|
174
|
+
return False, None, error_msg
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def handle_special_command(command: str) -> tuple[bool, str]:
|
|
178
|
+
"""Handle special commands like classify. Returns (is_special, message)"""
|
|
179
|
+
command = command.strip()
|
|
180
|
+
|
|
181
|
+
# Classify command
|
|
182
|
+
if command == "classify_state":
|
|
183
|
+
success, state_id, description = classify_current_state()
|
|
184
|
+
if success:
|
|
185
|
+
# Return empty string since console.print handles it
|
|
186
|
+
console.print()
|
|
187
|
+
console.print(f"[green]Current State:[/green] [bold]{state_id}[/bold]")
|
|
188
|
+
console.print(f" [dim]{description}[/dim]")
|
|
189
|
+
console.print()
|
|
190
|
+
return True, ""
|
|
191
|
+
else:
|
|
192
|
+
console.print(f"[red]{description}[/red]")
|
|
193
|
+
return True, ""
|
|
194
|
+
|
|
195
|
+
return False, ""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def format_result(result):
|
|
199
|
+
"""Format action result for rich display"""
|
|
200
|
+
if result is None:
|
|
201
|
+
return "[dim](no return value)[/dim]"
|
|
202
|
+
elif isinstance(result, bool):
|
|
203
|
+
color = "green" if result else "yellow"
|
|
204
|
+
return f"[{color}]{result}[/{color}]"
|
|
205
|
+
elif isinstance(result, (str, int, float)):
|
|
206
|
+
return f"[cyan]{repr(result)}[/cyan]"
|
|
207
|
+
else:
|
|
208
|
+
return f"[cyan]{type(result).__name__}: {str(result)[:100]}[/cyan]"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@gui_automation()
|
|
212
|
+
def start_interactive_session(payload: ClerkCodePayload):
|
|
213
|
+
"""Start an interactive test session with websocket connection"""
|
|
214
|
+
session_start = datetime.now()
|
|
215
|
+
action_count = 0
|
|
216
|
+
|
|
217
|
+
print_welcome()
|
|
218
|
+
|
|
219
|
+
# The gui_automation decorator establishes the connection before this function runs
|
|
220
|
+
# By the time we get here, connection is already established
|
|
221
|
+
console.print("[green]+[/green] WebSocket connection established")
|
|
222
|
+
console.print()
|
|
223
|
+
|
|
224
|
+
# Mark session as active
|
|
225
|
+
SESSION_FILE.touch()
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
while True:
|
|
229
|
+
# Get input from user
|
|
230
|
+
try:
|
|
231
|
+
action_string = console.input(
|
|
232
|
+
"[bold blue]command/action>[/bold blue] "
|
|
233
|
+
).strip()
|
|
234
|
+
except EOFError:
|
|
235
|
+
break
|
|
236
|
+
|
|
237
|
+
# Check for exit command
|
|
238
|
+
if action_string.lower() in ["exit", "quit", "q"]:
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
# Skip empty input
|
|
242
|
+
if not action_string:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
# Check if it's a special command
|
|
246
|
+
is_special, message = handle_special_command(action_string)
|
|
247
|
+
if is_special:
|
|
248
|
+
if message: # Only print if there's a message
|
|
249
|
+
console.print(message)
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
# Record action
|
|
253
|
+
ACTION_HISTORY.append(
|
|
254
|
+
{"timestamp": datetime.now(), "action": action_string, "success": None}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Execute action with status
|
|
258
|
+
start_time = time.time()
|
|
259
|
+
|
|
260
|
+
with console.status(
|
|
261
|
+
"[dim]Executing action (waiting for tool)...", spinner="dots"
|
|
262
|
+
):
|
|
263
|
+
success, result, error_msg = perform_single_action(action_string)
|
|
264
|
+
execution_time = time.time() - start_time
|
|
265
|
+
|
|
266
|
+
# Show completion message
|
|
267
|
+
if success:
|
|
268
|
+
console.print(
|
|
269
|
+
f"[green]+[/green] Action completed ({execution_time:.3f}s)"
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
console.print(f"[red]x[/red] Action failed ({execution_time:.3f}s)")
|
|
273
|
+
|
|
274
|
+
# Update history
|
|
275
|
+
ACTION_HISTORY[-1]["success"] = success
|
|
276
|
+
ACTION_HISTORY[-1]["execution_time"] = execution_time
|
|
277
|
+
ACTION_HISTORY[-1]["result"] = result
|
|
278
|
+
|
|
279
|
+
# Display result
|
|
280
|
+
if success:
|
|
281
|
+
action_count += 1
|
|
282
|
+
if result is not None:
|
|
283
|
+
console.print(f" Result: {format_result(result)}")
|
|
284
|
+
else:
|
|
285
|
+
console.print(f"[red]{error_msg}[/red]")
|
|
286
|
+
|
|
287
|
+
console.print() # Extra newline for spacing
|
|
288
|
+
|
|
289
|
+
except KeyboardInterrupt:
|
|
290
|
+
console.print("\n\n[yellow]Session interrupted by user[/yellow]")
|
|
291
|
+
finally:
|
|
292
|
+
if SESSION_FILE.exists():
|
|
293
|
+
SESSION_FILE.unlink()
|
|
294
|
+
|
|
295
|
+
# Print summary
|
|
296
|
+
console.print("\n[bold]" + "=" * 80 + "[/bold]")
|
|
297
|
+
console.print("[bold]Session Summary[/bold]")
|
|
298
|
+
console.print("[bold]" + "=" * 80 + "[/bold]")
|
|
299
|
+
console.print(f" Total actions executed: {action_count}")
|
|
300
|
+
console.print(f" Session duration: {datetime.now() - session_start}")
|
|
301
|
+
|
|
302
|
+
if ACTION_HISTORY:
|
|
303
|
+
successful = sum(1 for a in ACTION_HISTORY if a.get("success"))
|
|
304
|
+
failed = len(ACTION_HISTORY) - successful
|
|
305
|
+
console.print(f" Successful: [green]{successful}[/green]")
|
|
306
|
+
console.print(f" Failed: [red]{failed}[/red]")
|
|
307
|
+
|
|
308
|
+
console.print("\n[blue]WebSocket connection closed[/blue]\n")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def main():
|
|
312
|
+
"""Main entry point for the gui_test_session command"""
|
|
313
|
+
# Start interactive session
|
|
314
|
+
load_dotenv()
|
|
315
|
+
payload = ClerkCodePayload(
|
|
316
|
+
document=Document(id="test-session"),
|
|
317
|
+
structured_data={},
|
|
318
|
+
run_id="test-session-run",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Show spinner while the decorator establishes WebSocket connection
|
|
322
|
+
with console.status("[dim]Waiting for tool to connect...", spinner="dots"):
|
|
323
|
+
start_interactive_session(payload)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if __name__ == "__main__":
|
|
327
|
+
main()
|