@@ -29,33 +29,48 @@ def _normalize_tag(tag: str) -> str:
2929 return re .sub (r'[^a-z0-9]+' , '-' , tag .lower ()).strip ('-' )
3030
3131
32- def _normalize_command_name (summary : str ) -> str :
33- """Derive a CLI subcommand name from an endpoint summary."""
34- s = summary .strip ()
35- lower = s .lower ()
36-
37- if lower .startswith (('get all ' , 'fetch all ' , 'list all ' , 'retrieve a paginated list of ' )):
38- return 'list'
39- if lower .startswith ('list ' ):
40- return 'list'
41- if lower .startswith (('fetch ' , 'retrieve ' , 'get all' )):
42- return 'list'
43-
44- if re .match (r'^get\s+\w+\s+by\s+id$' , lower ):
45- return 'get'
46-
47- if re .match (r'^(get|retrieve)\s+(a\s+)?\w+(\s+details)?$' , lower ):
48- return 'get'
32+ def _find_common_prefix (paths : list [str ]) -> str :
33+ """Find the longest common path prefix shared by all paths."""
34+ if not paths :
35+ return ''
36+ if len (paths ) == 1 :
37+ # For single-path tags, use the parent directory as prefix
38+ return '/' .join (paths [0 ].split ('/' )[:- 1 ])
39+
40+ common = paths [0 ]
41+ for p in paths [1 :]:
42+ while not p .startswith (common + '/' ) and common != p :
43+ common = '/' .join (common .split ('/' )[:- 1 ])
44+ return common
45+
46+
47+ def _path_to_command_name (path : str , common_prefix : str , has_path_params : bool ) -> str :
48+ """Derive a CLI command name from an API path relative to the tag's common prefix.
49+
50+ Rules:
51+ 1. Strip the common prefix shared by all endpoints in the tag
52+ 2. Remove path parameter segments ({id})
53+ 3. If nothing remains: 'list' (no path params) or 'view' (has path params)
54+ 4. Otherwise: use remaining segments joined with hyphens
55+
56+ Examples:
57+ /v4/projects (prefix=/v4/projects) -> list
58+ /v4/projects/{id} (prefix=/v4/projects) -> view
59+ /v4/projects/assets (prefix=/v4/projects) -> assets
60+ /v4/violations/count (prefix=/v4/violations) -> count
61+ """
62+ # Strip common prefix
63+ relative = path [len (common_prefix ) :] if path .startswith (common_prefix ) else path
64+ relative = relative .strip ('/' )
4965
50- # Take meaningful words after the verb
51- words = re .sub (r'[^a-z0-9\s]' , '' , lower ).split ()
52- skip_verbs = {'get' , 'fetch' , 'retrieve' , 'list' , 'find' , 'search' , 'a' , 'an' , 'the' , 'all' , 'by' }
53- meaningful = [w for w in words if w not in skip_verbs ]
66+ # Remove path parameter segments and empty parts
67+ parts = [p for p in relative .split ('/' ) if p and not p .startswith ('{' )]
5468
55- if not meaningful :
56- return re . sub ( r'[^a-z0-9]+' , '-' , lower ). strip ( '-' )
69+ if not parts :
70+ return 'view' if has_path_params else 'list'
5771
58- return '-' .join (meaningful [:3 ])
72+ # Join remaining segments with hyphens, normalize to kebab-case
73+ return re .sub (r'[^a-z0-9]+' , '-' , '-' .join (parts ).lower ()).strip ('-' )
5974
6075
6176def _param_to_option_name (name : str ) -> str :
@@ -121,20 +136,29 @@ def build_api_command_groups(
121136
122137 group = click .Group (name = tag_name , help = f'[EXPERIMENT] Cycode API: { tag } ' )
123138
139+ # Compute common prefix from all GET endpoint paths in this tag
140+ get_endpoints = [ep for ep in endpoints if ep ['method' ] == 'get' ]
141+ if not get_endpoints :
142+ continue
143+
144+ clean_paths = [re .sub (r'/\{[^}]+\}' , '' , ep ['path' ]) for ep in get_endpoints ]
145+ common_prefix = _find_common_prefix (clean_paths )
146+
124147 used_names : dict [str , int ] = {}
125148
126- for endpoint in endpoints :
127- if endpoint ['method' ] != 'get' :
128- continue
149+ for endpoint in get_endpoints :
150+ has_path_params = bool ( endpoint ['path_params' ])
151+ cmd_name = _path_to_command_name ( endpoint [ 'path' ], common_prefix , has_path_params )
129152
130- cmd_name = _normalize_command_name (endpoint ['summary' ])
153+ # Fix redundancy: if command name matches the tag name, use list/view
154+ # e.g. "cycode groups groups" -> "cycode groups list"
155+ if cmd_name == tag_name or cmd_name == tag_name .replace ('-' , '_' ):
156+ cmd_name = 'view' if has_path_params else 'list'
131157
158+ # Handle duplicate names (e.g. deprecated + new endpoint for same resource)
132159 if cmd_name in used_names :
133160 used_names [cmd_name ] += 1
134- path_parts = endpoint ['path' ].strip ('/' ).split ('/' )
135- suffix = path_parts [- 1 ] if path_parts else str (used_names [cmd_name ])
136- suffix = re .sub (r'[{}]' , '' , suffix )
137- cmd_name = f'{ cmd_name } -{ suffix } '
161+ cmd_name = f'{ cmd_name } -v{ used_names [cmd_name ]} '
138162 else :
139163 used_names [cmd_name ] = 1
140164
0 commit comments