afd 0.1.0__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.
afd/__init__.py ADDED
@@ -0,0 +1,134 @@
1
+ """Agent-First Development (AFD) toolkit for Python.
2
+
3
+ AFD is a software development methodology where AI agents are treated as
4
+ first-class users from day one.
5
+
6
+ Example:
7
+ >>> from afd import CommandResult, success, error
8
+ >>> result = success(data={"id": "123"}, reasoning="Created successfully")
9
+ >>> result.success
10
+ True
11
+
12
+ Server Example:
13
+ >>> from afd.server import create_server
14
+ >>> from afd import success
15
+ >>> from pydantic import BaseModel
16
+ >>>
17
+ >>> server = create_server("my-app")
18
+ >>>
19
+ >>> class GreetInput(BaseModel):
20
+ ... name: str
21
+ >>>
22
+ >>> @server.command(name="greet", description="Greet someone")
23
+ ... async def greet(input: GreetInput):
24
+ ... return success({"message": f"Hello, {input.name}!"})
25
+
26
+ Handoff Example:
27
+ >>> from afd import create_handoff, is_handoff, success
28
+ >>> from afd.server import define_command
29
+ >>>
30
+ >>> @define_command(
31
+ ... name="chat.connect",
32
+ ... description="Connect to a chat room",
33
+ ... handoff=True,
34
+ ... handoff_protocol="websocket",
35
+ ... )
36
+ ... async def connect_chat(input):
37
+ ... return success(create_handoff(
38
+ ... protocol="websocket",
39
+ ... endpoint="wss://chat.example.com/room/123",
40
+ ... token="auth-token",
41
+ ... ))
42
+ """
43
+
44
+ from afd.core.result import (
45
+ CommandResult,
46
+ ResultMetadata,
47
+ success,
48
+ failure,
49
+ error,
50
+ is_success,
51
+ is_failure,
52
+ )
53
+ from afd.core.errors import (
54
+ CommandError,
55
+ ErrorCodes,
56
+ create_error,
57
+ validation_error,
58
+ not_found_error,
59
+ rate_limit_error,
60
+ timeout_error,
61
+ internal_error,
62
+ wrap_error,
63
+ is_command_error,
64
+ )
65
+ from afd.core.metadata import (
66
+ Source,
67
+ PlanStep,
68
+ PlanStepStatus,
69
+ Alternative,
70
+ Warning,
71
+ create_source,
72
+ create_step,
73
+ update_step_status,
74
+ create_warning,
75
+ )
76
+ from afd.core.handoff import (
77
+ HandoffProtocol,
78
+ HandoffCredentials,
79
+ HandoffMetadata,
80
+ HandoffResult,
81
+ ReconnectPolicy,
82
+ is_handoff,
83
+ is_handoff_protocol,
84
+ is_handoff_command,
85
+ get_handoff_protocol,
86
+ create_handoff,
87
+ )
88
+
89
+ __version__ = "0.1.0"
90
+
91
+ __all__ = [
92
+ # Version
93
+ "__version__",
94
+ # Result types
95
+ "CommandResult",
96
+ "ResultMetadata",
97
+ "success",
98
+ "failure",
99
+ "error",
100
+ "is_success",
101
+ "is_failure",
102
+ # Error types
103
+ "CommandError",
104
+ "ErrorCodes",
105
+ "create_error",
106
+ "validation_error",
107
+ "not_found_error",
108
+ "rate_limit_error",
109
+ "timeout_error",
110
+ "internal_error",
111
+ "wrap_error",
112
+ "is_command_error",
113
+ # Metadata types
114
+ "Source",
115
+ "PlanStep",
116
+ "PlanStepStatus",
117
+ "Alternative",
118
+ "Warning",
119
+ "create_source",
120
+ "create_step",
121
+ "update_step_status",
122
+ "create_warning",
123
+ # Handoff types
124
+ "HandoffProtocol",
125
+ "HandoffCredentials",
126
+ "HandoffMetadata",
127
+ "HandoffResult",
128
+ "ReconnectPolicy",
129
+ "is_handoff",
130
+ "is_handoff_protocol",
131
+ "is_handoff_command",
132
+ "get_handoff_protocol",
133
+ "create_handoff",
134
+ ]
afd/cli/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """
2
+ AFD CLI Module.
3
+
4
+ A command-line interface for interacting with AFD/MCP servers.
5
+
6
+ Example:
7
+ $ afd connect my-server
8
+ $ afd tools
9
+ $ afd call user.create '{"name": "Alice"}'
10
+ """
11
+
12
+ from afd.cli.main import cli, main
13
+ from afd.cli.output import (
14
+ print_error,
15
+ print_result,
16
+ print_success,
17
+ print_tools,
18
+ print_warning,
19
+ )
20
+
21
+ __all__ = [
22
+ # Main CLI
23
+ "cli",
24
+ "main",
25
+ # Output helpers
26
+ "print_result",
27
+ "print_error",
28
+ "print_success",
29
+ "print_warning",
30
+ "print_tools",
31
+ ]
afd/cli/main.py ADDED
@@ -0,0 +1,447 @@
1
+ """
2
+ AFD CLI main module.
3
+
4
+ Provides the Click-based command-line interface.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import click
14
+ from rich.console import Console
15
+
16
+ from afd.cli.output import (
17
+ console,
18
+ error_console,
19
+ print_connecting,
20
+ print_disconnecting,
21
+ print_error,
22
+ print_info,
23
+ print_result,
24
+ print_success,
25
+ print_tool_detail,
26
+ print_tools,
27
+ )
28
+ from afd.transports import FastMCPTransport, MockTransport, Transport, TransportConfig
29
+
30
+ # State file for persistent connection info
31
+ STATE_FILE = Path.home() / ".afd" / "state.json"
32
+
33
+
34
+ def _load_state() -> dict[str, Any]:
35
+ """Load CLI state from disk."""
36
+ if STATE_FILE.exists():
37
+ try:
38
+ return json.loads(STATE_FILE.read_text())
39
+ except (json.JSONDecodeError, OSError):
40
+ pass
41
+ return {}
42
+
43
+
44
+ def _save_state(state: dict[str, Any]) -> None:
45
+ """Save CLI state to disk."""
46
+ STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
47
+ STATE_FILE.write_text(json.dumps(state, indent=2))
48
+
49
+
50
+ def _get_transport(server: str | None = None) -> Transport:
51
+ """
52
+ Get a transport instance.
53
+
54
+ Args:
55
+ server: Server name or URL. Special values:
56
+ - "mock" or "mock:" prefix for MockTransport
57
+ - Otherwise uses FastMCPTransport
58
+
59
+ Returns:
60
+ Configured transport instance
61
+ """
62
+ if server is None:
63
+ # Load from state
64
+ state = _load_state()
65
+ server = state.get("server")
66
+ if not server:
67
+ raise click.ClickException(
68
+ "No server connected. Use 'afd connect <server>' first."
69
+ )
70
+
71
+ # Check for mock transport
72
+ if server == "mock" or server.startswith("mock:"):
73
+ return MockTransport()
74
+
75
+ # Default to FastMCP transport
76
+ return FastMCPTransport(server_name=server)
77
+
78
+
79
+ @click.group()
80
+ @click.version_option(package_name="afd")
81
+ @click.option("--json", "json_output", is_flag=True, help="Output raw JSON")
82
+ @click.option("--quiet", "-q", is_flag=True, help="Suppress non-essential output")
83
+ @click.pass_context
84
+ def cli(ctx: click.Context, json_output: bool, quiet: bool) -> None:
85
+ """
86
+ AFD - Agent-First Development CLI.
87
+
88
+ Interact with MCP servers using the AFD command pattern.
89
+
90
+ \b
91
+ Examples:
92
+ afd connect my-server
93
+ afd tools
94
+ afd call user.create '{"name": "Alice"}'
95
+ """
96
+ ctx.ensure_object(dict)
97
+ ctx.obj["json_output"] = json_output
98
+ ctx.obj["quiet"] = quiet
99
+
100
+
101
+ @cli.command()
102
+ @click.argument("server")
103
+ @click.option("--timeout", "-t", default=30.0, help="Connection timeout in seconds")
104
+ @click.pass_context
105
+ def connect(ctx: click.Context, server: str, timeout: float) -> None:
106
+ """
107
+ Connect to an MCP server.
108
+
109
+ SERVER can be a server name, URL, or special value:
110
+ \b
111
+ - mock Use mock transport for testing
112
+ - <name> Connect to named server
113
+
114
+ \b
115
+ Examples:
116
+ afd connect my-server
117
+ afd connect mock
118
+ """
119
+ quiet = ctx.obj.get("quiet", False)
120
+
121
+ if not quiet:
122
+ print_connecting(server)
123
+
124
+ async def _connect() -> None:
125
+ transport = _get_transport(server)
126
+ try:
127
+ await transport.connect()
128
+
129
+ # Save connection state
130
+ _save_state({"server": server})
131
+
132
+ if not quiet:
133
+ print_success(f"Connected to {server}")
134
+
135
+ # List available tools
136
+ tools = await transport.list_tools()
137
+ if tools:
138
+ print_info(f"Available tools: {len(tools)}")
139
+ finally:
140
+ await transport.disconnect()
141
+
142
+ try:
143
+ asyncio.run(_connect())
144
+ except Exception as e:
145
+ print_error(str(e))
146
+ sys.exit(1)
147
+
148
+
149
+ @cli.command()
150
+ @click.pass_context
151
+ def disconnect(ctx: click.Context) -> None:
152
+ """
153
+ Disconnect from the current server.
154
+
155
+ Clears the saved connection state.
156
+ """
157
+ quiet = ctx.obj.get("quiet", False)
158
+
159
+ state = _load_state()
160
+ server = state.get("server")
161
+
162
+ if not server:
163
+ if not quiet:
164
+ print_info("No active connection")
165
+ return
166
+
167
+ if not quiet:
168
+ print_disconnecting()
169
+
170
+ # Clear state
171
+ _save_state({})
172
+
173
+ if not quiet:
174
+ print_success(f"Disconnected from {server}")
175
+
176
+
177
+ @cli.command()
178
+ @click.option("--server", "-s", help="Server to query (uses saved connection if omitted)")
179
+ @click.option("--filter", "-f", "pattern", help="Filter tools by name pattern")
180
+ @click.option("--detail", "-d", "tool_name", help="Show detailed info for a specific tool")
181
+ @click.pass_context
182
+ def tools(ctx: click.Context, server: str | None, pattern: str | None, tool_name: str | None) -> None:
183
+ """
184
+ List available tools from the connected server.
185
+
186
+ \b
187
+ Examples:
188
+ afd tools
189
+ afd tools --filter user
190
+ afd tools --detail user.create
191
+ """
192
+ json_output = ctx.obj.get("json_output", False)
193
+
194
+ async def _list_tools() -> None:
195
+ transport = _get_transport(server)
196
+ try:
197
+ await transport.connect()
198
+ tool_list = await transport.list_tools()
199
+
200
+ # Filter if pattern provided
201
+ if pattern:
202
+ tool_list = [t for t in tool_list if pattern.lower() in t.name.lower()]
203
+
204
+ # Show detail for specific tool
205
+ if tool_name:
206
+ matching = [t for t in tool_list if t.name == tool_name]
207
+ if not matching:
208
+ raise click.ClickException(f"Tool '{tool_name}' not found")
209
+
210
+ if json_output:
211
+ import json as json_module
212
+ console.print(json_module.dumps({
213
+ "name": matching[0].name,
214
+ "description": matching[0].description,
215
+ "input_schema": matching[0].input_schema,
216
+ }, indent=2))
217
+ else:
218
+ print_tool_detail(matching[0])
219
+ return
220
+
221
+ # List all tools
222
+ if json_output:
223
+ import json as json_module
224
+ console.print(json_module.dumps([
225
+ {"name": t.name, "description": t.description}
226
+ for t in tool_list
227
+ ], indent=2))
228
+ else:
229
+ print_tools(tool_list)
230
+ finally:
231
+ await transport.disconnect()
232
+
233
+ try:
234
+ asyncio.run(_list_tools())
235
+ except click.ClickException:
236
+ raise
237
+ except Exception as e:
238
+ print_error(str(e))
239
+ sys.exit(1)
240
+
241
+
242
+ @cli.command()
243
+ @click.argument("tool")
244
+ @click.argument("args", required=False, default="{}")
245
+ @click.option("--server", "-s", help="Server to use (uses saved connection if omitted)")
246
+ @click.pass_context
247
+ def call(ctx: click.Context, tool: str, args: str, server: str | None) -> None:
248
+ """
249
+ Call a tool on the connected server.
250
+
251
+ TOOL is the name of the tool to call.
252
+ ARGS is a JSON string of arguments (default: {}).
253
+
254
+ \b
255
+ Examples:
256
+ afd call user.list
257
+ afd call user.create '{"name": "Alice", "email": "alice@example.com"}'
258
+ afd call doc.get '{"id": "123"}'
259
+ """
260
+ json_output = ctx.obj.get("json_output", False)
261
+
262
+ # Parse args
263
+ try:
264
+ parsed_args = json.loads(args)
265
+ except json.JSONDecodeError as e:
266
+ print_error(f"Invalid JSON arguments: {e}")
267
+ sys.exit(1)
268
+
269
+ async def _call_tool() -> None:
270
+ transport = _get_transport(server)
271
+ try:
272
+ await transport.connect()
273
+ result = await transport.call_tool(tool, parsed_args)
274
+ print_result(result, json_output=json_output)
275
+ finally:
276
+ await transport.disconnect()
277
+
278
+ try:
279
+ asyncio.run(_call_tool())
280
+ except Exception as e:
281
+ print_error(str(e))
282
+ sys.exit(1)
283
+
284
+
285
+ @cli.command()
286
+ @click.pass_context
287
+ def status(ctx: click.Context) -> None:
288
+ """
289
+ Show current connection status.
290
+
291
+ Displays the currently connected server and connection state.
292
+ """
293
+ json_output = ctx.obj.get("json_output", False)
294
+
295
+ state = _load_state()
296
+ server = state.get("server")
297
+
298
+ if json_output:
299
+ console.print(json.dumps({
300
+ "connected": server is not None,
301
+ "server": server,
302
+ }, indent=2))
303
+ return
304
+
305
+ if server:
306
+ print_success(f"Connected to: {server}")
307
+ else:
308
+ print_info("Not connected to any server")
309
+ print_info("Use 'afd connect <server>' to connect")
310
+
311
+
312
+ @cli.command()
313
+ @click.option("--server", "-s", help="Server to validate")
314
+ @click.pass_context
315
+ def validate(ctx: click.Context, server: str | None) -> None:
316
+ """
317
+ Validate server connection and tools.
318
+
319
+ Connects to the server, lists tools, and verifies each can be inspected.
320
+ """
321
+ quiet = ctx.obj.get("quiet", False)
322
+ json_output = ctx.obj.get("json_output", False)
323
+
324
+ async def _validate() -> None:
325
+ transport = _get_transport(server)
326
+ results: dict[str, Any] = {
327
+ "connection": False,
328
+ "tools_listed": False,
329
+ "tool_count": 0,
330
+ "errors": [],
331
+ }
332
+
333
+ try:
334
+ await transport.connect()
335
+ results["connection"] = True
336
+
337
+ tools = await transport.list_tools()
338
+ results["tools_listed"] = True
339
+ results["tool_count"] = len(tools)
340
+
341
+ if json_output:
342
+ console.print(json.dumps(results, indent=2))
343
+ else:
344
+ print_success("Connection: OK")
345
+ print_success(f"Tools listed: {len(tools)}")
346
+ print_success("Validation passed!")
347
+
348
+ except Exception as e:
349
+ results["errors"].append(str(e))
350
+ if json_output:
351
+ console.print(json.dumps(results, indent=2))
352
+ else:
353
+ print_error(f"Validation failed: {e}")
354
+ sys.exit(1)
355
+ finally:
356
+ await transport.disconnect()
357
+
358
+ try:
359
+ asyncio.run(_validate())
360
+ except Exception as e:
361
+ print_error(str(e))
362
+ sys.exit(1)
363
+
364
+
365
+ @cli.command()
366
+ @click.pass_context
367
+ def shell(ctx: click.Context) -> None:
368
+ """
369
+ Start an interactive shell.
370
+
371
+ Provides a REPL for calling tools interactively.
372
+ Use 'exit' or Ctrl+D to quit.
373
+ """
374
+ from rich.prompt import Prompt
375
+
376
+ state = _load_state()
377
+ server = state.get("server")
378
+
379
+ if not server:
380
+ print_error("No server connected. Use 'afd connect <server>' first.")
381
+ sys.exit(1)
382
+
383
+ console.print(f"[bold]AFD Shell[/bold] - Connected to [cyan]{server}[/cyan]")
384
+ console.print("[dim]Type 'help' for commands, 'exit' to quit[/dim]\n")
385
+
386
+ async def _shell() -> None:
387
+ transport = _get_transport(server)
388
+ await transport.connect()
389
+
390
+ try:
391
+ while True:
392
+ try:
393
+ line = Prompt.ask("[cyan]afd[/cyan]")
394
+ except EOFError:
395
+ break
396
+
397
+ line = line.strip()
398
+ if not line:
399
+ continue
400
+
401
+ if line in ("exit", "quit"):
402
+ break
403
+
404
+ if line == "help":
405
+ console.print("""
406
+ [bold]Commands:[/bold]
407
+ tools List available tools
408
+ call <tool> [args] Call a tool with optional JSON args
409
+ help Show this help
410
+ exit Exit the shell
411
+ """)
412
+ continue
413
+
414
+ if line == "tools":
415
+ tool_list = await transport.list_tools()
416
+ print_tools(tool_list)
417
+ continue
418
+
419
+ if line.startswith("call "):
420
+ parts = line[5:].strip().split(maxsplit=1)
421
+ tool_name = parts[0]
422
+ tool_args = json.loads(parts[1]) if len(parts) > 1 else {}
423
+
424
+ result = await transport.call_tool(tool_name, tool_args)
425
+ print_result(result)
426
+ continue
427
+
428
+ console.print(f"[yellow]Unknown command: {line}[/yellow]")
429
+
430
+ finally:
431
+ await transport.disconnect()
432
+
433
+ try:
434
+ asyncio.run(_shell())
435
+ except KeyboardInterrupt:
436
+ pass
437
+
438
+ console.print("\n[dim]Goodbye![/dim]")
439
+
440
+
441
+ def main() -> None:
442
+ """Entry point for the CLI."""
443
+ cli()
444
+
445
+
446
+ if __name__ == "__main__":
447
+ main()