Coverage for smith / delivery / cli.py: 100%

0 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-01 18:48 +0000

1"""CLI entry point for the smith command-line tool.""" 

2 

3import argparse 

4import sys 

5from pathlib import Path 

6 

7from smith.application.connect import ConnectUseCase 

8from smith.application.disconnect import DisconnectUseCase 

9from smith.application.status import StatusUseCase 

10from smith.application.update import UpdateUseCase 

11from smith.domain.ports import TemplateSourceError 

12from smith.domain.value_objects import TemplateSource 

13 

14EXIT_SUCCESS = 0 

15EXIT_ERROR = 1 

16 

17 

18def build_parser() -> argparse.ArgumentParser: 

19 """Build and return the argument parser for the smith CLI.""" 

20 parser = argparse.ArgumentParser( 

21 prog="smith", description="Connect AI agent configurations to any project" 

22 ) 

23 parser.add_argument("--version", action="version", version=_get_version()) 

24 

25 subparsers = parser.add_subparsers(dest="command", help="Available commands") 

26 

27 connect_parser = subparsers.add_parser( 

28 "connect", help="Connect agentic files to a project" 

29 ) 

30 connect_parser.add_argument( 

31 "--from", 

32 dest="source", 

33 help="Template source (bundled:agents-smith, local path, or URL)", 

34 ) 

35 connect_parser.add_argument( 

36 "--overwrite", 

37 action="store_true", 

38 dest="overwrite", 

39 help="Replace existing agentic files without prompting", 

40 ) 

41 connect_parser.set_defaults(func=handle_connect) 

42 

43 disconnect_parser = subparsers.add_parser( 

44 "disconnect", help="Disconnect agentic files from a project" 

45 ) 

46 disconnect_parser.set_defaults(func=handle_disconnect) 

47 

48 update_parser = subparsers.add_parser("update", help="Update agentic files") 

49 update_parser.add_argument( 

50 "--from", 

51 dest="source", 

52 help="Template source (bundled:agents-smith, local path, or URL)", 

53 ) 

54 update_parser.set_defaults(func=handle_update) 

55 

56 status_parser = subparsers.add_parser("status", help="Show connection status") 

57 status_parser.add_argument("--json", action="store_true", help="Output as JSON") 

58 status_parser.set_defaults(func=handle_status) 

59 

60 return parser 

61 

62 

63def _get_version() -> str: 

64 """Return the current smith version string.""" 

65 try: 

66 from importlib.metadata import metadata 

67 

68 meta = metadata("agents-smith") 

69 return f"smith {meta['Version']}" 

70 except Exception: 

71 return "smith 0.1.0" 

72 

73 

74def _parse_source(source_arg: str | None) -> TemplateSource: 

75 """Parse a source argument string into a TemplateSource value object.""" 

76 if source_arg is None: 

77 return TemplateSource(kind="bundled", location="agents-smith") 

78 if source_arg.startswith("/"): 

79 return TemplateSource(kind="local", location=source_arg) 

80 if source_arg.startswith("http://") or source_arg.startswith("https://"): 

81 return TemplateSource(kind="url", location=source_arg) 

82 if ":" in source_arg: 

83 kind, _, location = source_arg.partition(":") 

84 return TemplateSource(kind=kind, location=location) # type: ignore[arg-type] 

85 return TemplateSource(kind="local", location=source_arg) 

86 

87 

88def handle_connect(args: argparse.Namespace) -> int: 

89 """Handle the ``connect`` sub-command.""" 

90 project_dir = Path(getattr(args, "project_dir", ".")).resolve() 

91 source = _parse_source(getattr(args, "source", None)) 

92 overwrite = getattr(args, "overwrite", False) 

93 try: 

94 ConnectUseCase(project_dir=project_dir).execute( 

95 source=source, overwrite=overwrite 

96 ) 

97 return EXIT_SUCCESS 

98 except TemplateSourceError as e: 

99 sys.stderr.write(f"Error: {e}\n") 

100 return EXIT_ERROR 

101 except Exception as e: 

102 sys.stderr.write(f"Error: {e}\n") 

103 return EXIT_ERROR 

104 

105 

106def handle_disconnect(args: argparse.Namespace) -> int: 

107 """Handle the ``disconnect`` sub-command.""" 

108 project_dir = Path(getattr(args, "project_dir", ".")).resolve() 

109 try: 

110 DisconnectUseCase(project_dir=project_dir).execute() 

111 return EXIT_SUCCESS 

112 except Exception as e: 

113 sys.stderr.write(f"Error: {e}\n") 

114 return EXIT_ERROR 

115 

116 

117def handle_update(args: argparse.Namespace) -> int: 

118 """Handle the ``update`` sub-command.""" 

119 project_dir = Path(getattr(args, "project_dir", ".")).resolve() 

120 source = _parse_source(getattr(args, "source", None)) 

121 try: 

122 UpdateUseCase(project_dir=project_dir).execute( 

123 source=source if args.source else None 

124 ) 

125 return EXIT_SUCCESS 

126 except TemplateSourceError as e: 

127 sys.stderr.write(f"Error: {e}\n") 

128 return EXIT_ERROR 

129 except Exception as e: 

130 sys.stderr.write(f"Error: {e}\n") 

131 return EXIT_ERROR 

132 

133 

134def handle_status(args: argparse.Namespace) -> int: 

135 """Handle the ``status`` sub-command.""" 

136 project_dir = Path(getattr(args, "project_dir", ".")).resolve() 

137 try: 

138 status = StatusUseCase(project_dir=project_dir).execute() 

139 if getattr(args, "json", False): 

140 import json 

141 

142 sys.stdout.write(json.dumps(status.to_dict(), indent=2) + "\n") 

143 else: 

144 sys.stdout.write(f"State: {status.state.value}\n") 

145 if status.source: 

146 sys.stdout.write( 

147 f"Source: {status.source.kind}:{status.source.location}\n" 

148 ) 

149 if status.present_files: 

150 sys.stdout.write("Present files:\n") 

151 for f in status.present_files: 

152 sys.stdout.write(f" {f}\n") 

153 if status.missing_files: 

154 sys.stdout.write("Missing files:\n") 

155 for f in status.missing_files: 

156 sys.stdout.write(f" {f}\n") 

157 return EXIT_SUCCESS 

158 except Exception as e: 

159 sys.stderr.write(f"Error: {e}\n") 

160 return EXIT_ERROR 

161 

162 

163def main(argv: list[str] | None = None) -> int: 

164 """Run the smith CLI and return an exit code.""" 

165 parser = build_parser() 

166 args = parser.parse_args(argv) 

167 if not hasattr(args, "func"): 

168 parser.print_help() 

169 return EXIT_ERROR 

170 return args.func(args)