cycode 3.13.0__py3-none-any.whl → 3.13.1.dev2__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.
cycode/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = '3.13.0' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
1
+ __version__ = '3.13.1.dev2' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
cycode/cli/app.py CHANGED
@@ -2,6 +2,7 @@ import logging
2
2
  import sys
3
3
  from typing import Annotated, Optional
4
4
 
5
+ import click
5
6
  import typer
6
7
  from typer import rich_utils
7
8
  from typer._completion_classes import completion_init
@@ -10,6 +11,7 @@ from typer.completion import install_callback, show_callback
10
11
 
11
12
  from cycode import __version__
12
13
  from cycode.cli.apps import ai_guardrails, ai_remediation, auth, configure, ignore, report, report_import, scan, status
14
+ from cycode.cli.apps.api import get_platform_group
13
15
 
14
16
  if sys.version_info >= (3, 10):
15
17
  from cycode.cli.apps import mcp
@@ -56,6 +58,27 @@ app.add_typer(status.app)
56
58
  if sys.version_info >= (3, 10):
57
59
  app.add_typer(mcp.app)
58
60
 
61
+ # Register the `platform` command group (dynamically built from the OpenAPI spec).
62
+ # The group itself is constructed cheaply at import time; the spec is only fetched
63
+ # when the user actually invokes `cycode platform ...`. Unrelated commands like
64
+ # `cycode scan` and `cycode status` never trigger a spec fetch.
65
+ #
66
+ # Typer doesn't support adding native Click groups directly, so we monkey-patch
67
+ # typer.main.get_group to inject our `platform` group into the resolved Click group.
68
+ # The `app_typer is app` guard ensures we only modify our own app.
69
+ _platform_group = get_platform_group()
70
+ _original_get_group = typer.main.get_group
71
+
72
+
73
+ def _get_group_with_platform(app_typer: typer.Typer) -> click.Group:
74
+ group = _original_get_group(app_typer)
75
+ if app_typer is app and _platform_group.name not in group.commands:
76
+ group.add_command(_platform_group, _platform_group.name)
77
+ return group
78
+
79
+
80
+ typer.main.get_group = _get_group_with_platform
81
+
59
82
 
60
83
  def check_latest_version_on_close(ctx: typer.Context) -> None:
61
84
  output = ctx.obj.get('output')
@@ -1,23 +1,24 @@
1
1
  import typer
2
2
 
3
- from cycode.cli.apps.ai_guardrails.ensure_auth_command import ensure_auth_command
4
- from cycode.cli.apps.ai_guardrails.install_command import install_command
5
- from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command
6
- from cycode.cli.apps.ai_guardrails.status_command import status_command
7
- from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command
3
+ from cycode.cli.apps.ai_guardrails.install_command import install_command as _install_command
4
+ from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command as _scan_command
5
+ from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command as _session_start_command
6
+ from cycode.cli.apps.ai_guardrails.status_command import status_command as _status_command
7
+ from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command as _uninstall_command
8
8
 
9
9
  app = typer.Typer(name='ai-guardrails', no_args_is_help=True, hidden=True)
10
10
 
11
- app.command(hidden=True, name='install', short_help='Install AI guardrails hooks for supported IDEs.')(install_command)
11
+ app.command(hidden=True, name='install', short_help='Install AI guardrails hooks for supported IDEs.')(_install_command)
12
12
  app.command(hidden=True, name='uninstall', short_help='Remove AI guardrails hooks from supported IDEs.')(
13
- uninstall_command
13
+ _uninstall_command
14
14
  )
15
- app.command(hidden=True, name='status', short_help='Show AI guardrails hook installation status.')(status_command)
15
+ app.command(hidden=True, name='status', short_help='Show AI guardrails hook installation status.')(_status_command)
16
16
  app.command(
17
17
  hidden=True,
18
18
  name='scan',
19
19
  short_help='Scan content from AI IDE hooks for secrets (reads JSON from stdin).',
20
- )(scan_command)
21
- app.command(hidden=True, name='ensure-auth', short_help='Ensure authentication, triggering auth if needed.')(
22
- ensure_auth_command
20
+ )(_scan_command)
21
+ app.command(hidden=True, name='session-start', short_help='Handle session start: auth, conversation, session context.')(
22
+ _session_start_command
23
23
  )
24
+ app.command(hidden=True, name='ensure-auth', short_help='[Deprecated] Alias for session-start.')(_session_start_command)
@@ -84,7 +84,7 @@ DEFAULT_IDE = AIIDEType.CURSOR
84
84
 
85
85
  # Command used in hooks
86
86
  CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'
87
- CYCODE_ENSURE_AUTH_COMMAND = 'cycode ai-guardrails ensure-auth'
87
+ CYCODE_SESSION_START_COMMAND = 'cycode ai-guardrails session-start'
88
88
 
89
89
 
90
90
  def _get_cursor_hooks_config(async_mode: bool = False) -> dict:
@@ -92,7 +92,7 @@ def _get_cursor_hooks_config(async_mode: bool = False) -> dict:
92
92
  config = IDE_CONFIGS[AIIDEType.CURSOR]
93
93
  command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND
94
94
  hooks = {event: [{'command': command}] for event in config.hook_events}
95
- hooks['sessionStart'] = [{'command': CYCODE_ENSURE_AUTH_COMMAND}]
95
+ hooks['sessionStart'] = [{'command': f'{CYCODE_SESSION_START_COMMAND} --ide cursor'}]
96
96
 
97
97
  return {
98
98
  'version': 1,
@@ -119,7 +119,7 @@ def _get_claude_code_hooks_config(async_mode: bool = False) -> dict:
119
119
  'SessionStart': [
120
120
  {
121
121
  'matcher': 'startup',
122
- 'hooks': [{'type': 'command', 'command': CYCODE_ENSURE_AUTH_COMMAND}],
122
+ 'hooks': [{'type': 'command', 'command': f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'}],
123
123
  }
124
124
  ],
125
125
  'UserPromptSubmit': [
@@ -13,6 +13,7 @@ from cycode.logger import get_logger
13
13
  logger = get_logger('AI Guardrails Claude Config')
14
14
 
15
15
  _CLAUDE_CONFIG_PATH = Path.home() / '.claude.json'
16
+ _CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json'
16
17
 
17
18
 
18
19
  def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]:
@@ -42,3 +43,117 @@ def get_user_email(config: dict) -> Optional[str]:
42
43
  Reads oauthAccount.emailAddress from the config dict.
43
44
  """
44
45
  return config.get('oauthAccount', {}).get('emailAddress')
46
+
47
+
48
+ def get_mcp_servers(config: dict) -> Optional[dict]:
49
+ """Extract MCP servers from Claude config.
50
+
51
+ Reads mcpServers from the config dict.
52
+ """
53
+ return config.get('mcpServers')
54
+
55
+
56
+ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]:
57
+ """Load and parse ~/.claude/settings.json.
58
+
59
+ Args:
60
+ settings_path: Override path for testing. Defaults to ~/.claude/settings.json.
61
+
62
+ Returns:
63
+ Parsed dict or None if file is missing or invalid.
64
+ """
65
+ path = settings_path or _CLAUDE_SETTINGS_PATH
66
+ if not path.exists():
67
+ logger.debug('Claude settings file not found', extra={'path': str(path)})
68
+ return None
69
+ try:
70
+ content = path.read_text(encoding='utf-8')
71
+ return json.loads(content)
72
+ except Exception as e:
73
+ logger.debug('Failed to load Claude settings file', exc_info=e)
74
+ return None
75
+
76
+
77
+ def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]:
78
+ """
79
+ Resolve filesystem path for a directory-type marketplace.
80
+ """
81
+ source = marketplace.get('source', {})
82
+ if source.get('source') != 'directory':
83
+ return None
84
+ raw = source.get('path')
85
+ if not raw:
86
+ return None
87
+ path = Path(raw)
88
+ return path if path.is_dir() else None
89
+
90
+
91
+ def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]:
92
+ """Load and parse a JSON file inside a plugin directory.
93
+
94
+ Returns None if the file is missing, unreadable, or has invalid JSON.
95
+ """
96
+ target = plugin_path / relative_path
97
+ if not target.exists():
98
+ return None
99
+ try:
100
+ return json.loads(target.read_text(encoding='utf-8'))
101
+ except Exception as e:
102
+ logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e)
103
+ return None
104
+
105
+
106
+ def resolve_plugins(settings: dict) -> tuple[dict, dict]:
107
+ """Resolve enabled plugins to their MCP servers and metadata.
108
+
109
+ Walks enabledPlugins from claude settings, resolves each plugin's 'marketplace' directory
110
+ via the 'extraKnownMarketplaces' field, and reads:
111
+ - <path>/.mcp.json for MCP servers (merged into a flat dict)
112
+ - <path>/.claude-plugin/plugin.json for metadata (name, version, description)
113
+
114
+ Args:
115
+ settings: Parsed ~/.claude/settings.json dict.
116
+
117
+ Returns:
118
+ Tuple of (merged_mcp_servers, enriched_plugins):
119
+ - merged_mcp_servers: {server_name: server_config, ...}
120
+ - enriched_plugins: {plugin_key: {"enabled": True, "name": ..., ...}, ...}
121
+ """
122
+ enabled = settings.get('enabledPlugins') or {}
123
+ marketplaces = settings.get('extraKnownMarketplaces') or {}
124
+ merged_mcp: dict = {}
125
+ enriched: dict = {}
126
+
127
+ for plugin_key, is_enabled in enabled.items():
128
+ if not is_enabled:
129
+ continue
130
+
131
+ entry: dict = {'enabled': True}
132
+ enriched[plugin_key] = entry
133
+
134
+ if '@' not in plugin_key:
135
+ continue
136
+
137
+ _plugin_name, marketplace_name = plugin_key.split('@', 1)
138
+ marketplace = marketplaces.get(marketplace_name)
139
+ if not marketplace:
140
+ continue
141
+
142
+ plugin_path = _resolve_marketplace_path(marketplace)
143
+ if plugin_path is None:
144
+ continue
145
+
146
+ metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {}
147
+ for field in ('name', 'version', 'description'):
148
+ if field in metadata:
149
+ entry[field] = metadata[field]
150
+
151
+ mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {}
152
+ plugin_server_names = []
153
+ for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items():
154
+ merged_mcp[server_name] = server_cfg
155
+ plugin_server_names.append(server_name)
156
+ if plugin_server_names:
157
+ entry['mcp_server_names'] = plugin_server_names
158
+
159
+ return merged_mcp, enriched
@@ -0,0 +1,36 @@
1
+ """Reader for ~/.cursor/mcp.json configuration file.
2
+
3
+ Extracts MCP server definitions from the Cursor global config file
4
+ for use in AI guardrails session-context reporting.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from cycode.logger import get_logger
12
+
13
+ logger = get_logger('AI Guardrails Cursor Config')
14
+
15
+ _CURSOR_MCP_CONFIG_PATH = Path.home() / '.cursor' / 'mcp.json'
16
+
17
+
18
+ def load_cursor_config(config_path: Optional[Path] = None) -> Optional[dict]:
19
+ """Load and parse ~/.cursor/mcp.json.
20
+
21
+ Args:
22
+ config_path: Override path for testing. Defaults to ~/.cursor/mcp.json.
23
+
24
+ Returns:
25
+ Parsed dict or None if file is missing or invalid.
26
+ """
27
+ path = config_path or _CURSOR_MCP_CONFIG_PATH
28
+ if not path.exists():
29
+ logger.debug('Cursor MCP config file not found', extra={'path': str(path)})
30
+ return None
31
+ try:
32
+ content = path.read_text(encoding='utf-8')
33
+ return json.loads(content)
34
+ except Exception as e:
35
+ logger.debug('Failed to load Cursor MCP config file', exc_info=e)
36
+ return None
@@ -42,7 +42,6 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
42
42
  response_builder = get_response_builder(ide)
43
43
 
44
44
  prompt_config = get_policy_value(policy, 'prompt', default={})
45
- ai_client.create_conversation(payload)
46
45
  if not get_policy_value(prompt_config, 'enabled', default=True):
47
46
  ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED)
48
47
  return response_builder.allow_prompt()
@@ -100,7 +99,6 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
100
99
  response_builder = get_response_builder(ide)
101
100
 
102
101
  file_read_config = get_policy_value(policy, 'file_read', default={})
103
- ai_client.create_conversation(payload)
104
102
  if not get_policy_value(file_read_config, 'enabled', default=True):
105
103
  ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED)
106
104
  return response_builder.allow_permission()
@@ -203,7 +201,6 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
203
201
  response_builder = get_response_builder(ide)
204
202
 
205
203
  mcp_config = get_policy_value(policy, 'mcp', default={})
206
- ai_client.create_conversation(payload)
207
204
  if not get_policy_value(mcp_config, 'enabled', default=True):
208
205
  ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED)
209
206
  return response_builder.allow_permission()
@@ -71,7 +71,7 @@ def _extract_generation_id(entry: dict) -> Optional[str]:
71
71
  return None
72
72
 
73
73
 
74
- def _extract_from_claude_transcript(
74
+ def extract_from_claude_transcript(
75
75
  transcript_path: str,
76
76
  ) -> tuple[Optional[str], Optional[str], Optional[str]]:
77
77
  """Extract IDE version, model, and latest generation ID from Claude Code transcript file.
@@ -123,7 +123,7 @@ class AIHookPayload:
123
123
  """Unified payload object that normalizes field names from different AI tools."""
124
124
 
125
125
  # Event identification
126
- event_name: str # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution')
126
+ event_name: Optional[str] = None # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution')
127
127
  conversation_id: Optional[str] = None
128
128
  generation_id: Optional[str] = None
129
129
 
@@ -206,7 +206,7 @@ class AIHookPayload:
206
206
  mcp_tool_name = parts[2]
207
207
 
208
208
  # Extract IDE version, model, and generation ID from transcript file
209
- ide_version, model, generation_id = _extract_from_claude_transcript(payload.get('transcript_path'))
209
+ ide_version, model, generation_id = extract_from_claude_transcript(payload.get('transcript_path'))
210
210
 
211
211
  # Extract user email from ~/.claude.json
212
212
  claude_config = load_claude_config()
@@ -0,0 +1,150 @@
1
+ import sys
2
+ from typing import TYPE_CHECKING, Annotated
3
+
4
+ import typer
5
+
6
+ from cycode.cli.apps.ai_guardrails.consts import AIIDEType
7
+ from cycode.cli.apps.ai_guardrails.scan.claude_config import (
8
+ get_mcp_servers,
9
+ get_user_email,
10
+ load_claude_config,
11
+ load_claude_settings,
12
+ resolve_plugins,
13
+ )
14
+ from cycode.cli.apps.ai_guardrails.scan.cursor_config import load_cursor_config
15
+ from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload, extract_from_claude_transcript
16
+ from cycode.cli.apps.ai_guardrails.scan.utils import safe_json_parse
17
+ from cycode.cli.apps.auth.auth_common import get_authorization_info
18
+ from cycode.cli.apps.auth.auth_manager import AuthManager
19
+ from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception
20
+ from cycode.cli.utils.get_api_client import get_ai_security_manager_client
21
+ from cycode.logger import get_logger
22
+
23
+ if TYPE_CHECKING:
24
+ from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient
25
+
26
+ logger = get_logger('AI Guardrails')
27
+
28
+
29
+ def _build_session_payload(payload: dict, ide: str) -> AIHookPayload:
30
+ """Build an AIHookPayload from a session-start stdin payload."""
31
+ if ide == AIIDEType.CLAUDE_CODE:
32
+ claude_config = load_claude_config()
33
+ ide_user_email = get_user_email(claude_config) if claude_config else None
34
+ ide_version, _, _ = extract_from_claude_transcript(payload.get('transcript_path'))
35
+
36
+ return AIHookPayload(
37
+ conversation_id=payload.get('session_id'),
38
+ ide_user_email=ide_user_email,
39
+ model=payload.get('model'),
40
+ ide_provider=AIIDEType.CLAUDE_CODE.value,
41
+ ide_version=ide_version,
42
+ )
43
+
44
+ # Cursor
45
+ return AIHookPayload(
46
+ conversation_id=payload.get('conversation_id'),
47
+ ide_user_email=payload.get('user_email'),
48
+ model=payload.get('model'),
49
+ ide_provider=AIIDEType.CURSOR.value,
50
+ ide_version=payload.get('cursor_version'),
51
+ )
52
+
53
+
54
+ def _get_claude_code_session_context() -> tuple[dict, dict]:
55
+ """Return (mcp_servers, enabled_plugins) for Claude Code.
56
+
57
+ Merges MCP servers from ~/.claude.json (user-configured) with those contributed
58
+ by enabled plugins. Plugin metadata (name, version, description) is included in
59
+ the enabled_plugins dict when resolvable.
60
+ """
61
+ config = load_claude_config()
62
+ mcp_servers = dict(get_mcp_servers(config) or {}) if config else {}
63
+
64
+ settings = load_claude_settings()
65
+ if settings:
66
+ plugin_mcp, enriched_plugins = resolve_plugins(settings)
67
+ mcp_servers.update(plugin_mcp)
68
+ else:
69
+ enriched_plugins = {}
70
+
71
+ return mcp_servers, enriched_plugins
72
+
73
+
74
+ def _get_cursor_session_context() -> tuple[dict, dict]:
75
+ """Return (mcp_servers, enabled_plugins) for Cursor. Cursor has no plugin system."""
76
+ config = load_cursor_config()
77
+ mcp_servers = dict(get_mcp_servers(config) or {}) if config else {}
78
+ return mcp_servers, {}
79
+
80
+
81
+ def _report_session_context(ai_client: 'AISecurityManagerClient', ide: str) -> None:
82
+ """Report IDE session context to the AI security manager. Never raises."""
83
+ try:
84
+ if ide == AIIDEType.CLAUDE_CODE:
85
+ mcp_servers, enabled_plugins = _get_claude_code_session_context()
86
+ elif ide == AIIDEType.CURSOR:
87
+ mcp_servers, enabled_plugins = _get_cursor_session_context()
88
+ else:
89
+ return
90
+
91
+ if not mcp_servers and not enabled_plugins:
92
+ return
93
+ ai_client.report_session_context(mcp_servers=mcp_servers, enabled_plugins=enabled_plugins)
94
+ except Exception as e:
95
+ logger.debug('Failed to report session context', exc_info=e)
96
+
97
+
98
+ def session_start_command(
99
+ ctx: typer.Context,
100
+ ide: Annotated[
101
+ str,
102
+ typer.Option(
103
+ '--ide',
104
+ help='IDE that triggered the session start.',
105
+ hidden=True,
106
+ ),
107
+ ] = AIIDEType.CURSOR.value,
108
+ ) -> None:
109
+ """Handle session start: ensure auth, create conversation, report session context."""
110
+ # Step 1: Ensure authentication
111
+ auth_info = get_authorization_info(ctx)
112
+ if auth_info is None:
113
+ logger.debug('Not authenticated, starting authentication')
114
+ try:
115
+ auth_manager = AuthManager()
116
+ auth_manager.authenticate()
117
+ except Exception as err:
118
+ handle_auth_exception(ctx, err)
119
+ return
120
+ else:
121
+ logger.debug('Already authenticated')
122
+
123
+ # Step 2: Read stdin payload (backward compat: old hooks pipe no stdin)
124
+ if sys.stdin.isatty():
125
+ logger.debug('No stdin payload (TTY), skipping session initialization')
126
+ return
127
+
128
+ stdin_data = sys.stdin.read().strip()
129
+ payload = safe_json_parse(stdin_data)
130
+ if not payload:
131
+ logger.debug('Empty or invalid stdin payload, skipping session initialization')
132
+ return
133
+
134
+ # Step 3: Build session payload and initialize API client
135
+ session_payload = _build_session_payload(payload, ide)
136
+
137
+ try:
138
+ ai_client = get_ai_security_manager_client(ctx)
139
+ except Exception as e:
140
+ logger.debug('Failed to initialize AI security client', exc_info=e)
141
+ return
142
+
143
+ # Step 4: Create conversation
144
+ try:
145
+ ai_client.create_conversation(session_payload)
146
+ except Exception as e:
147
+ logger.debug('Failed to create conversation during session start', exc_info=e)
148
+
149
+ # Step 5: Report session context (MCP servers)
150
+ _report_session_context(ai_client, ide)
@@ -0,0 +1,69 @@
1
+ """Cycode platform API CLI commands.
2
+
3
+ Dynamically builds CLI command groups from the Cycode API v4 OpenAPI spec.
4
+ The spec is fetched lazily — only when the user invokes `cycode platform ...` —
5
+ and cached locally for 24 hours.
6
+ """
7
+
8
+ from typing import Any, Optional
9
+
10
+ import click
11
+
12
+ from cycode.logger import get_logger
13
+
14
+ logger = get_logger('Platform')
15
+
16
+ _PLATFORM_HELP = (
17
+ '[BETA] Access the Cycode platform.\n\n'
18
+ 'Commands are generated dynamically from the Cycode API spec and may change '
19
+ 'between releases. The spec is fetched on first use and cached for 24 hours.'
20
+ )
21
+
22
+
23
+ class PlatformGroup(click.Group):
24
+ """Lazy-loading Click group for `cycode platform` subcommands.
25
+
26
+ The OpenAPI spec is only fetched when the user actually invokes
27
+ `cycode platform ...` (or asks for its help). Unrelated commands like
28
+ `cycode scan` or `cycode status` never trigger a spec fetch.
29
+ """
30
+
31
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
32
+ super().__init__(*args, **kwargs)
33
+ self._loaded: bool = False
34
+
35
+ def _ensure_loaded(self, ctx: Optional[click.Context]) -> None:
36
+ if self._loaded:
37
+ return
38
+ self._loaded = True # set first to avoid re-entrancy on errors
39
+
40
+ client_id = client_secret = None
41
+ if ctx is not None:
42
+ root = ctx.find_root()
43
+ if root.obj:
44
+ client_id = root.obj.get('client_id')
45
+ client_secret = root.obj.get('client_secret')
46
+
47
+ try:
48
+ from cycode.cli.apps.api.api_command import build_api_command_groups
49
+
50
+ for sub_group, name in build_api_command_groups(client_id, client_secret):
51
+ if name not in self.commands:
52
+ self.add_command(sub_group, name)
53
+ except Exception as e:
54
+ logger.debug('Could not load platform commands: %s', e)
55
+ # Surface the error to the user only when they're inside `platform`
56
+ click.echo(f'Error loading Cycode platform commands: {e}', err=True)
57
+
58
+ def list_commands(self, ctx: click.Context) -> list[str]:
59
+ self._ensure_loaded(ctx)
60
+ return super().list_commands(ctx)
61
+
62
+ def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]:
63
+ self._ensure_loaded(ctx)
64
+ return super().get_command(ctx, cmd_name)
65
+
66
+
67
+ def get_platform_group() -> click.Group:
68
+ """Return the top-level `platform` Click group (lazy-loading)."""
69
+ return PlatformGroup(name='platform', help=_PLATFORM_HELP, no_args_is_help=True)
@@ -0,0 +1,271 @@
1
+ """OpenAPI-to-Typer translator: dynamically builds CLI commands from the Cycode API v4 spec."""
2
+
3
+ import json
4
+ import re
5
+ from typing import Any, Optional
6
+
7
+ import click
8
+
9
+ from cycode.cli.apps.api.openapi_spec import OpenAPISpecError, get_openapi_spec, parse_spec_commands
10
+ from cycode.logger import get_logger
11
+
12
+ logger = get_logger('API Command')
13
+
14
+ # Map OpenAPI parameter types to Click types
15
+ _CLICK_TYPE_MAP: dict[str, click.ParamType] = {
16
+ 'string': click.STRING,
17
+ 'integer': click.INT,
18
+ 'number': click.FLOAT,
19
+ 'boolean': click.BOOL,
20
+ }
21
+
22
+
23
+ def _normalize_tag(tag: str) -> str:
24
+ """Normalize an OpenAPI tag to a CLI-friendly command name.
25
+
26
+ 'Scan Statistics' -> 'scan-statistics'
27
+ 'CLI scan statistics' -> 'cli-scan-statistics'
28
+ """
29
+ return re.sub(r'[^a-z0-9]+', '-', tag.lower()).strip('-')
30
+
31
+
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('/')
65
+
66
+ # Remove path parameter segments and empty parts
67
+ parts = [p for p in relative.split('/') if p and not p.startswith('{')]
68
+
69
+ if not parts:
70
+ return 'view' if has_path_params else 'list'
71
+
72
+ # Join remaining segments with hyphens, normalize to kebab-case
73
+ return re.sub(r'[^a-z0-9]+', '-', '-'.join(parts).lower()).strip('-')
74
+
75
+
76
+ def _param_to_option_name(name: str) -> str:
77
+ """Convert an OpenAPI parameter name to a CLI option name.
78
+
79
+ 'page_size' -> '--page-size'
80
+ 'pageSize' -> '--page-size'
81
+ 'filter.status' -> '--filter-status'
82
+ """
83
+ s = re.sub(r'([a-z])([A-Z])', r'\1-\2', name)
84
+ # Replace any non-alphanumeric characters with hyphens
85
+ s = re.sub(r'[^a-z0-9]+', '-', s.lower()).strip('-')
86
+ return f'--{s}'
87
+
88
+
89
+ def _make_api_request(
90
+ endpoint_path: str,
91
+ method: str,
92
+ path_params: dict[str, str],
93
+ query_params: dict[str, Any],
94
+ client_id: Optional[str] = None,
95
+ client_secret: Optional[str] = None,
96
+ ) -> dict:
97
+ """Execute an API request using the CLI's standard auth client."""
98
+ from urllib.parse import quote
99
+
100
+ from cycode.cli.apps.api.openapi_spec import resolve_credentials
101
+ from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient
102
+
103
+ cid, csecret = resolve_credentials(client_id, client_secret)
104
+ client = CycodeTokenBasedClient(cid, csecret)
105
+
106
+ # Substitute path parameters (URL-encoded to prevent path traversal)
107
+ url_path = endpoint_path
108
+ for param_name, param_value in path_params.items():
109
+ url_path = url_path.replace(f'{{{param_name}}}', quote(str(param_value), safe=''))
110
+
111
+ filtered_query = {k: v for k, v in query_params.items() if v is not None}
112
+
113
+ response = client.get(url_path.lstrip('/'), params=filtered_query)
114
+ return response.json()
115
+
116
+
117
+ def build_api_command_groups(
118
+ client_id: Optional[str] = None,
119
+ client_secret: Optional[str] = None,
120
+ ) -> list[tuple[click.Group, str]]:
121
+ """Build Click command groups from the OpenAPI spec.
122
+
123
+ Returns a list of (click_group, command_name) tuples.
124
+ """
125
+ try:
126
+ spec = get_openapi_spec(client_id, client_secret)
127
+ except OpenAPISpecError as e:
128
+ logger.warning('Could not load OpenAPI spec: %s', e)
129
+ return []
130
+
131
+ groups = parse_spec_commands(spec)
132
+ result = []
133
+
134
+ for tag, endpoints in groups.items():
135
+ tag_name = _normalize_tag(tag)
136
+
137
+ group = click.Group(name=tag_name, help=f'[BETA] {tag}')
138
+
139
+ # Compute common prefix from all GET (non-deprecated) endpoint paths in this tag
140
+ get_endpoints = [ep for ep in endpoints if ep['method'] == 'get' and not ep.get('deprecated')]
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
+
147
+ used_names: dict[str, int] = {}
148
+
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)
152
+
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:
156
+ cmd_name = 'view' if has_path_params else 'list'
157
+
158
+ # Handle duplicate names (e.g. deprecated + new endpoint for same resource)
159
+ if cmd_name in used_names:
160
+ used_names[cmd_name] += 1
161
+ cmd_name = f'{cmd_name}-v{used_names[cmd_name]}'
162
+ else:
163
+ used_names[cmd_name] = 1
164
+
165
+ cmd = _build_endpoint_command(cmd_name, endpoint)
166
+ group.add_command(cmd, cmd_name)
167
+
168
+ result.append((group, tag_name))
169
+
170
+ return result
171
+
172
+
173
+ def _build_click_params(endpoint: dict) -> list[click.Parameter]:
174
+ """Build Click parameters from OpenAPI endpoint definition."""
175
+ params: list[click.Parameter] = []
176
+
177
+ # Path parameters -> required arguments
178
+ for p in endpoint['path_params']:
179
+ param_type = _CLICK_TYPE_MAP.get(p.get('schema', {}).get('type', 'string'), click.STRING)
180
+ params.append(
181
+ click.Argument(
182
+ [p['name'].replace('-', '_')],
183
+ type=param_type,
184
+ required=True,
185
+ )
186
+ )
187
+
188
+ # Query parameters -> --option flags
189
+ for p in endpoint['query_params']:
190
+ param_type = _CLICK_TYPE_MAP.get(p.get('schema', {}).get('type', 'string'), click.STRING)
191
+ option_name = _param_to_option_name(p['name'])
192
+ required = p.get('required', False)
193
+ default = p.get('schema', {}).get('default')
194
+
195
+ schema = p.get('schema', {})
196
+ if 'enum' in schema:
197
+ param_type = click.Choice(schema['enum'])
198
+
199
+ params.append(
200
+ click.Option(
201
+ [option_name],
202
+ type=param_type,
203
+ required=required,
204
+ default=default,
205
+ help=p.get('description', ''),
206
+ show_default=default is not None,
207
+ )
208
+ )
209
+
210
+ return params
211
+
212
+
213
+ def _build_endpoint_command(cmd_name: str, endpoint: dict) -> click.Command:
214
+ """Build a Click command for an API endpoint.
215
+
216
+ Path parameters become required CLI arguments.
217
+ Query parameters become --option flags with proper types.
218
+ """
219
+ ep_path = endpoint['path']
220
+ ep_method = endpoint['method']
221
+ ep_path_params = list(endpoint['path_params'])
222
+ ep_query_params = list(endpoint['query_params'])
223
+ ep_description = endpoint['description'] or endpoint['summary']
224
+
225
+ # Build a mapping from Click's normalized kwarg name to original OpenAPI param name
226
+ _path_param_map = {p['name'].replace('-', '_').lower(): p['name'] for p in ep_path_params}
227
+ _query_param_map = {re.sub(r'[^a-z0-9]+', '_', p['name'].lower()).strip('_'): p['name'] for p in ep_query_params}
228
+
229
+ def _callback(**kwargs: Any) -> None:
230
+ ctx = click.get_current_context()
231
+
232
+ # Extract path param values using the mapping
233
+ path_values = {}
234
+ for kwarg_key, original_name in _path_param_map.items():
235
+ if kwarg_key in kwargs and kwargs[kwarg_key] is not None:
236
+ path_values[original_name] = kwargs[kwarg_key]
237
+
238
+ # Extract query param values (skip None)
239
+ query_values = {}
240
+ for kwarg_key, original_name in _query_param_map.items():
241
+ value = kwargs.get(kwarg_key)
242
+ if value is not None:
243
+ query_values[original_name] = value
244
+
245
+ # Get auth from root context (set by app_callback)
246
+ root_ctx = ctx.find_root()
247
+ client_id = root_ctx.obj.get('client_id') if root_ctx.obj else None
248
+ client_secret = root_ctx.obj.get('client_secret') if root_ctx.obj else None
249
+
250
+ try:
251
+ result = _make_api_request(
252
+ ep_path,
253
+ ep_method,
254
+ path_values,
255
+ query_values,
256
+ client_id=client_id,
257
+ client_secret=client_secret,
258
+ )
259
+ except Exception as e:
260
+ click.echo(f'Error: {e}', err=True)
261
+ raise click.Abort from e
262
+
263
+ click.echo(json.dumps(result, indent=2))
264
+
265
+ return click.Command(
266
+ name=cmd_name,
267
+ callback=_callback,
268
+ help=ep_description,
269
+ short_help=endpoint['summary'],
270
+ params=_build_click_params(endpoint),
271
+ )
@@ -0,0 +1,182 @@
1
+ """OpenAPI spec manager: fetch, cache, and parse the Cycode API v4 spec."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY
10
+ from cycode.cli.user_settings.credentials_manager import CredentialsManager
11
+ from cycode.cyclient import config as cyclient_config
12
+ from cycode.logger import get_logger
13
+
14
+ logger = get_logger('OpenAPI Spec')
15
+
16
+ _CACHE_DIR = Path.home() / CYCODE_CONFIGURATION_DIRECTORY
17
+ _CACHE_FILE = _CACHE_DIR / 'openapi-spec.json'
18
+ _CACHE_TTL_SECONDS = int(os.getenv('CYCODE_SPEC_CACHE_TTL', str(24 * 60 * 60))) # 24h default
19
+
20
+ _OPENAPI_SPEC_PATH = '/v4/api-docs/cycode-api-swagger.json'
21
+
22
+
23
+ def get_openapi_spec(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> dict:
24
+ """Get the OpenAPI spec, using cache if fresh, otherwise fetching from API.
25
+
26
+ The spec is only fetched when the user actually invokes `cycode platform ...`.
27
+ Fetch uses the HTTP client's default timeout; on a slow connection the first
28
+ invocation will block accordingly. Once cached, subsequent invocations within
29
+ the TTL are near-instant.
30
+
31
+ Args:
32
+ client_id: Optional client ID override (from CLI flags).
33
+ client_secret: Optional client secret override (from CLI flags).
34
+
35
+ Returns:
36
+ Parsed OpenAPI specification dictionary.
37
+
38
+ Raises:
39
+ OpenAPISpecError: If spec cannot be loaded from cache or API.
40
+ """
41
+ cached = _load_cached_spec()
42
+ if cached is not None:
43
+ return cached
44
+
45
+ return _fetch_and_cache_spec(client_id, client_secret)
46
+
47
+
48
+ def _load_cached_spec() -> Optional[dict]:
49
+ """Load spec from local cache if it exists and is fresh."""
50
+ if not _CACHE_FILE.exists():
51
+ return None
52
+
53
+ try:
54
+ mtime = _CACHE_FILE.stat().st_mtime
55
+ if time.time() - mtime > _CACHE_TTL_SECONDS:
56
+ logger.debug('Cached OpenAPI spec is stale (age > %ds)', _CACHE_TTL_SECONDS)
57
+ return None
58
+
59
+ spec = json.loads(_CACHE_FILE.read_text(encoding='utf-8'))
60
+ logger.debug('Using cached OpenAPI spec from %s', _CACHE_FILE)
61
+ return spec
62
+ except Exception as e:
63
+ logger.warning('Failed to load cached OpenAPI spec: %s', e)
64
+ return None
65
+
66
+
67
+ def resolve_credentials(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> tuple[str, str]:
68
+ """Resolve credentials from args or the CLI's standard credential chain."""
69
+ if not client_id or not client_secret:
70
+ credentials_manager = CredentialsManager()
71
+ cred_id, cred_secret = credentials_manager.get_credentials()
72
+ client_id = client_id or cred_id
73
+ client_secret = client_secret or cred_secret
74
+
75
+ if not client_id or not client_secret:
76
+ raise OpenAPISpecError(
77
+ 'Cycode credentials not found. Run `cycode auth` first, '
78
+ 'or set CYCODE_CLIENT_ID and CYCODE_CLIENT_SECRET environment variables.'
79
+ )
80
+
81
+ return client_id, client_secret
82
+
83
+
84
+ def _fetch_and_cache_spec(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> dict:
85
+ """Fetch OpenAPI spec from API and cache to disk.
86
+
87
+ Uses CycodeTokenBasedClient for auth and retries. The spec is served from the app URL,
88
+ so we create a client with app_url as base instead of the default api_url.
89
+ """
90
+ from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient
91
+
92
+ cid, csecret = resolve_credentials(client_id, client_secret)
93
+
94
+ # The spec is served from app.cycode.com, but token refresh POSTs to api.cycode.com.
95
+ # Ensure the token is fresh BEFORE overriding the base URL so that refresh
96
+ # targets the correct host.
97
+ client = CycodeTokenBasedClient(cid, csecret)
98
+ client.get_access_token()
99
+ client.api_url = cyclient_config.cycode_app_url
100
+
101
+ spec_path = _OPENAPI_SPEC_PATH.lstrip('/')
102
+ logger.info('Fetching OpenAPI spec from %s/%s', cyclient_config.cycode_app_url, spec_path)
103
+
104
+ try:
105
+ response = client.get(spec_path)
106
+ spec = response.json()
107
+ except Exception as e:
108
+ raise OpenAPISpecError(
109
+ f'Failed to fetch OpenAPI spec. Check your authentication and network connectivity. Error: {e}'
110
+ ) from e
111
+
112
+ if not isinstance(spec, dict) or 'paths' not in spec:
113
+ raise OpenAPISpecError('Response does not look like a valid OpenAPI spec (missing "paths" key).')
114
+
115
+ # Override server URL with API URL (supports on-premise installations)
116
+ spec['servers'] = [{'url': cyclient_config.cycode_api_url}]
117
+
118
+ # Cache to disk
119
+ _cache_spec(spec)
120
+
121
+ return spec
122
+
123
+
124
+ def _cache_spec(spec: dict) -> None:
125
+ """Write spec to local cache file atomically (write to temp file, then rename)."""
126
+ try:
127
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
128
+ tmp_file = _CACHE_FILE.with_suffix('.json.tmp')
129
+ tmp_file.write_text(json.dumps(spec), encoding='utf-8')
130
+ tmp_file.replace(_CACHE_FILE) # atomic on POSIX and Windows
131
+ logger.debug('Cached OpenAPI spec to %s', _CACHE_FILE)
132
+ except Exception as e:
133
+ logger.warning('Failed to cache OpenAPI spec: %s', e)
134
+
135
+
136
+ def parse_spec_commands(spec: dict) -> dict[str, list[dict]]:
137
+ """Parse OpenAPI spec into resource groups with their endpoints.
138
+
139
+ Groups endpoints by their first tag, returning a dict of:
140
+ {tag_name: [endpoint_info, ...]}
141
+
142
+ Each endpoint_info contains:
143
+ - path: API path (e.g., '/v4/projects/{projectId}')
144
+ - method: HTTP method (e.g., 'get')
145
+ - summary: Human-readable summary
146
+ - description: Detailed description
147
+ - operation_id: Unique operation ID
148
+ - path_params: List of path parameter definitions
149
+ - query_params: List of query parameter definitions
150
+ """
151
+ groups: dict[str, list[dict]] = {}
152
+
153
+ for path, methods in spec.get('paths', {}).items():
154
+ for method, details in methods.items():
155
+ tags = details.get('tags', ['other'])
156
+ tag = tags[0] if tags else 'other'
157
+
158
+ # Separate path and query parameters
159
+ parameters = details.get('parameters', [])
160
+ path_params = [p for p in parameters if p.get('in') == 'path']
161
+ query_params = [p for p in parameters if p.get('in') == 'query']
162
+
163
+ endpoint_info = {
164
+ 'path': path,
165
+ 'method': method,
166
+ 'summary': details.get('summary', ''),
167
+ 'description': details.get('description', ''),
168
+ 'operation_id': details.get('operationId', ''),
169
+ 'path_params': path_params,
170
+ 'query_params': query_params,
171
+ 'deprecated': details.get('deprecated', False),
172
+ }
173
+
174
+ if tag not in groups:
175
+ groups[tag] = []
176
+ groups[tag].append(endpoint_info)
177
+
178
+ return groups
179
+
180
+
181
+ class OpenAPISpecError(Exception):
182
+ """Raised when the OpenAPI spec cannot be loaded."""
@@ -17,6 +17,7 @@ class AISecurityManagerClient:
17
17
 
18
18
  _CONVERSATIONS_PATH = 'v4/ai-security/interactions/conversations'
19
19
  _EVENTS_PATH = 'v4/ai-security/interactions/events'
20
+ _SESSION_CONTEXT_PATH = 'v4/ai-security/interactions/session-context'
20
21
 
21
22
  def __init__(self, client: CycodeClientBase, service_config: 'AISecurityManagerServiceConfigBase') -> None:
22
23
  self.client = client
@@ -88,3 +89,20 @@ class AISecurityManagerClient:
88
89
  except Exception as e:
89
90
  logger.debug('Failed to create AI hook event', exc_info=e)
90
91
  # Don't fail the hook if tracking fails
92
+
93
+ def report_session_context(
94
+ self,
95
+ mcp_servers: Optional[dict] = None,
96
+ enabled_plugins: Optional[dict] = None,
97
+ ) -> None:
98
+ """Report session context to the backend."""
99
+ body: dict = {
100
+ 'mcp_servers': mcp_servers,
101
+ 'enabled_plugins': enabled_plugins,
102
+ }
103
+
104
+ try:
105
+ self.client.post(self._build_endpoint_path(self._SESSION_CONTEXT_PATH), body=body)
106
+ except Exception as e:
107
+ logger.debug('Failed to report session context', exc_info=e)
108
+ # Don't fail the session if reporting fails
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cycode
3
- Version: 3.13.0
3
+ Version: 3.13.1.dev2
4
4
  Summary: Boost security in your dev lifecycle via SAST, SCA, Secrets & IaC scanning.
5
5
  License-Expression: MIT
6
6
  License-File: LICENCE
@@ -62,7 +62,11 @@ This guide walks you through both installation and usage.
62
62
  2. [Available Options](#available-options)
63
63
  3. [MCP Tools](#mcp-tools)
64
64
  4. [Usage Examples](#usage-examples)
65
- 5. [Scan Command](#scan-command)
65
+ 5. [Platform Command](#platform-command-beta)
66
+ 1. [Discovering Commands](#discovering-commands)
67
+ 2. [Examples](#platform-examples)
68
+ 3. [Notes & Limitations](#platform-notes--limitations)
69
+ 6. [Scan Command](#scan-command)
66
70
  1. [Running a Scan](#running-a-scan)
67
71
  1. [Options](#options)
68
72
  1. [Severity Threshold](#severity-option)
@@ -646,6 +650,64 @@ This information can be helpful when:
646
650
  - Debugging transport-specific issues
647
651
 
648
652
 
653
+ # Platform Command \[BETA\]
654
+
655
+ > [!WARNING]
656
+ > The `platform` command is in **beta**. Commands, arguments, and output formats are generated dynamically from the Cycode API spec and may change between releases without notice. Do not rely on them in production automation yet.
657
+
658
+ The `cycode platform` command exposes the Cycode platform's read APIs as CLI commands. It groups endpoints by resource (e.g. `projects`, `violations`, `workflows`) and turns each endpoint's parameters into typed CLI arguments and `--option` flags.
659
+
660
+ ```bash
661
+ cycode platform projects list --page-size 50
662
+ cycode platform violations count
663
+ cycode platform workflows view <workflow-id>
664
+ ```
665
+
666
+ The OpenAPI spec is fetched from the Cycode API on first use and cached at `~/.cycode/openapi-spec.json` for 24 hours. Unrelated commands (`cycode scan`, `cycode status`, etc.) do not trigger a fetch.
667
+
668
+ > [!NOTE]
669
+ > You must be authenticated (`cycode auth` or `CYCODE_CLIENT_ID` / `CYCODE_CLIENT_SECRET` environment variables) for `cycode platform` to discover and run commands. Other Cycode CLI commands work without authentication.
670
+
671
+ ## Discovering Commands
672
+
673
+ Because commands are generated from the spec, the source of truth for what's available is `--help`:
674
+
675
+ ```bash
676
+ cycode platform --help # list all resource groups
677
+ cycode platform projects --help # list actions on a resource
678
+ cycode platform projects list --help # list options/arguments for an action
679
+ ```
680
+
681
+ ## Platform Examples
682
+
683
+ ```bash
684
+ # List projects with pagination
685
+ cycode platform projects list --page-size 25
686
+
687
+ # View a single project by ID
688
+ cycode platform projects view <project-id>
689
+
690
+ # Count violations across the tenant
691
+ cycode platform violations count
692
+
693
+ # Filter using query parameters (see `--help` for what each endpoint supports)
694
+ cycode platform violations list --severity CRITICAL
695
+ ```
696
+
697
+ All output is JSON by default — pipe it through `jq` for ad-hoc filtering:
698
+
699
+ ```bash
700
+ cycode platform projects list --page-size 100 | jq '.items[].name'
701
+ ```
702
+
703
+ ## Platform Notes & Limitations
704
+
705
+ - **Read-only today.** Only `GET` endpoints are exposed in this beta.
706
+ - **Spec-driven.** Adding a new endpoint to the API surfaces it automatically the next time the cache is refreshed.
707
+ - **No bundled spec.** The first `cycode platform` invocation after install (or after the 24h cache expires) performs a network fetch. On slow connections this first call may take a few seconds; subsequent calls are near-instant until the cache expires.
708
+ - **Override the cache TTL** with `CYCODE_SPEC_CACHE_TTL=<seconds>`.
709
+
710
+
649
711
  # Scan Command
650
712
 
651
713
  ## Running a Scan
@@ -1,31 +1,35 @@
1
- cycode/__init__.py,sha256=q19ilNAJhQDGBJIkm5iFOBnVC4OnwdrARmwwrANqCJg,110
1
+ cycode/__init__.py,sha256=ydaMqViM8vgQ_GN1QQ0obfc8xAMHze1My6jyYEJvgH0,115
2
2
  cycode/__main__.py,sha256=Z3bD5yrA7yPvAChcADQrqCaZd0ChGI1gdiwALwbWJ6U,104
3
3
  cycode/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- cycode/cli/app.py,sha256=bsfXV85RRb1iz19JRC9gkc5Iv30fnEE1cwA8dg552NQ,6482
4
+ cycode/cli/app.py,sha256=7ReEcVkRX9IaQ2I7jAj7Sl9smbtvxiuK8-9bitMEQik,7491
5
5
  cycode/cli/apps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  cycode/cli/apps/activation_manager.py,sha256=Hz9PDJFB-ZmYi4HSG8iYC-fR8j5v25VuUU-l95Otsdk,1678
7
- cycode/cli/apps/ai_guardrails/__init__.py,sha256=R2l1CRRMOY4bAeJkndio81Sc4v0upURGo5s14Hejh-I,1120
7
+ cycode/cli/apps/ai_guardrails/__init__.py,sha256=NsqB1Ca83BIjJMcDSt6suec6Ed0iNnacC0gBqkuuTtI,1367
8
8
  cycode/cli/apps/ai_guardrails/command_utils.py,sha256=itWoARiiqC-kCJuppBxBwKDjCSnci2m0EG95GQPy3r4,1924
9
- cycode/cli/apps/ai_guardrails/consts.py,sha256=3B39MQ4p_tX7xm6KeigMJRDF8yLPONQOhn2BENK4fVs,4442
10
- cycode/cli/apps/ai_guardrails/ensure_auth_command.py,sha256=SwgafehweFCPt7QtbprsoUjYD8pvnaOQH407rWbG-aI,738
9
+ cycode/cli/apps/ai_guardrails/consts.py,sha256=XmsFIESadcnImtLx5ISQjCJIJFk6kCg2ZXYIMF4l6DY,4491
11
10
  cycode/cli/apps/ai_guardrails/hooks_manager.py,sha256=37IcEMCK60pQ8lnuy8GThlq9oeNOfETVp_xYGeJ9EpM,9428
12
11
  cycode/cli/apps/ai_guardrails/install_command.py,sha256=qlklts1Uj6j3urK6jwAWJY-L_DgVaZWuk7vZcpoKPAQ,4571
13
12
  cycode/cli/apps/ai_guardrails/scan/__init__.py,sha256=qJc82XiQGiAuc1sYY8Ij_A-qXpxgLPuayQq8xWlouMA,48
14
- cycode/cli/apps/ai_guardrails/scan/claude_config.py,sha256=JCPpSHUroH6tF2URoXCsf_AszqrSsSDcpM8ST4uJqHQ,1262
13
+ cycode/cli/apps/ai_guardrails/scan/claude_config.py,sha256=2hVuPHfT-9_kgf5yCNgN522IcporEZvJEyYTLaaae2c,5195
15
14
  cycode/cli/apps/ai_guardrails/scan/consts.py,sha256=drAslw6vW3kxmbCs2qPCUbUPR7PJouT2lsXtu5sD-lQ,1094
16
- cycode/cli/apps/ai_guardrails/scan/handlers.py,sha256=e3UlQ6TbFFFAFEAgMUhL5i7bV1mSrmFJuLb9O0FLX2Y,15702
17
- cycode/cli/apps/ai_guardrails/scan/payload.py,sha256=wUk9PEtVrym0SaChiML8_7KGa0NmpRNIrcojKA3msjU,10409
15
+ cycode/cli/apps/ai_guardrails/scan/cursor_config.py,sha256=D4bQsTu6MFzJFhygk0QCyopy5gJMkJm0oRrWNt2PC0g,1087
16
+ cycode/cli/apps/ai_guardrails/scan/handlers.py,sha256=7zxGQrePRJcs6kkpKnbYq59wM2ADKz70Ve_ZB5ZNRQE,15573
17
+ cycode/cli/apps/ai_guardrails/scan/payload.py,sha256=TXDz7S_0WiGLYzUwsF-48MtR8nfft33g4_dEsfAdisI,10424
18
18
  cycode/cli/apps/ai_guardrails/scan/policy.py,sha256=39s8hnxgjny1l6XAO59wsRcAlpW-LG00GUnO0PfqvuY,2566
19
19
  cycode/cli/apps/ai_guardrails/scan/response_builders.py,sha256=tVFJCnGdqSmyileg-idypOihygct7F6T4KHXYlX8y_c,4653
20
20
  cycode/cli/apps/ai_guardrails/scan/scan_command.py,sha256=_2fa6vz8ZmJlvCYrYNoWfX9fWrrpzcNCwL1UD-JxqLM,5618
21
21
  cycode/cli/apps/ai_guardrails/scan/types.py,sha256=H25MKJhAXmp7Mz1YeCIRmAY1Zg5GSpgBq8G1TEI9PFk,1868
22
22
  cycode/cli/apps/ai_guardrails/scan/utils.py,sha256=KVfX-NrcM-QW4quLtoNqfmz4GF0FlDs-TkqUOu1hAWM,2057
23
+ cycode/cli/apps/ai_guardrails/session_start_command.py,sha256=NzYrCTmxSQ3fZpKceUL5arnUKnoLzZG3HzDY4CmbL_w,5437
23
24
  cycode/cli/apps/ai_guardrails/status_command.py,sha256=UerHtjIGi6sY4RXGR06Es6jQFQAEWTx2Dvhk784WQIM,3539
24
25
  cycode/cli/apps/ai_guardrails/uninstall_command.py,sha256=0qhXNC4PQPqrtt5JmexcM4W6i-VyvObB3DQT_DINM1Q,2969
25
26
  cycode/cli/apps/ai_remediation/__init__.py,sha256=8vYthY9RQeJqEni3AIF5sryz8n-XJQ6VNqG4aEFBAdY,553
26
27
  cycode/cli/apps/ai_remediation/ai_remediation_command.py,sha256=u1EdebaKCEmzv9fXmnIN0xDSLcCmGyjueYKvYfLOj_8,1549
27
28
  cycode/cli/apps/ai_remediation/apply_fix.py,sha256=9zgqiqF9HBQXi7Oz9ZIiANIAuKAMTji1PlNncCEOf5Q,817
28
29
  cycode/cli/apps/ai_remediation/print_remediation.py,sha256=nEVkR7gnGIryGEo0NOKzrmqsh4CjLr2QfVt9elsrzGY,590
30
+ cycode/cli/apps/api/__init__.py,sha256=1Re_qLdTwSt9H4OR-Cl5jxBvhNI4nEhPBzJ3-V1AMCw,2523
31
+ cycode/cli/apps/api/api_command.py,sha256=iZKOegd0-NQ8dOwxuQUFdem39iXd9nBGQr2FD-s4nyw,9539
32
+ cycode/cli/apps/api/openapi_spec.py,sha256=_Le9FN5JxL5XElEnBpKtdFLGZH2iSNVjXgQblxwFEXE,6877
29
33
  cycode/cli/apps/auth/__init__.py,sha256=rjf_rEBS1aS6rzY4Qh75BzOOX9SEHPdJMah-1FJM4DY,447
30
34
  cycode/cli/apps/auth/auth_command.py,sha256=lI5lXuyGD9_OOr-kdzVxu_3gDZasOHJ0mgMXSIt8-cs,1448
31
35
  cycode/cli/apps/auth/auth_common.py,sha256=bfQXqfv5bcYmc7njWOnG1VGzRU-C7spBv48gxHROCGU,2420
@@ -178,7 +182,7 @@ cycode/cli/utils/version_checker.py,sha256=0f5PaTk02ZkDxzBqZOeMV9mU_CWcx6HKW80jU
178
182
  cycode/cli/utils/yaml_utils.py,sha256=R-tqzl0C-zoa42rS7nfWeHu3GJ0jpbQUyyqYYU2hleM,1818
179
183
  cycode/config.py,sha256=jHORGZQcAXkAGSf2XreC-RQoc8sdNWja69QKtPWTbWo,1044
180
184
  cycode/cyclient/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
181
- cycode/cyclient/ai_security_manager_client.py,sha256=AruZ-k98oc15jNswE-uTIGzifKPK2mNjJSjRqIih95U,3493
185
+ cycode/cyclient/ai_security_manager_client.py,sha256=8fjupsrwrPHKUHqcM5DRwhoji2MmsfxOGiO3hX_S33o,4164
182
186
  cycode/cyclient/ai_security_manager_service_config.py,sha256=83pQzgOb93JW6E-dznJkI4c0NEXmQRlx9YZKMmjVwp8,808
183
187
  cycode/cyclient/auth_client.py,sha256=TwbmZ358Ancf-Q-IZolvfljZ8691_6botsqd0R0PLPk,2105
184
188
  cycode/cyclient/base_token_auth_client.py,sha256=3JIrSz0-ywVTIfxIs2zs5aGcE-x5GW3AgPHm9qA4ZDE,3857
@@ -199,8 +203,8 @@ cycode/cyclient/report_client.py,sha256=Scq30NeJPzgXv0hPLO1U05AdE9i_2iu6cIrSKpEJ
199
203
  cycode/cyclient/scan_client.py,sha256=6TK5FQkfrvV7PHqRnUzEn1PBNd2oPYVamvIixcUfe3c,16755
200
204
  cycode/cyclient/scan_config_base.py,sha256=mXsPZGYCtp85rv5GIige40yQZXuRcEKUW-VQJ0vgFzk,1201
201
205
  cycode/logger.py,sha256=EfZGRK6VC5rE_LAjIcRrHFiQCueylCDXoG6bvGkrIME,2111
202
- cycode-3.13.0.dist-info/METADATA,sha256=zP1a13NFYNYLJnyY2r5aSQkyd5fqJJAYwl8g2QU3H6E,84345
203
- cycode-3.13.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
204
- cycode-3.13.0.dist-info/entry_points.txt,sha256=iDcVJM8ByLElVgvBgtYxDjw1kT7O8Mo0LcWZIT5L3Ig,45
205
- cycode-3.13.0.dist-info/licenses/LICENCE,sha256=2Wx4N6mD_4xB7-E3hPkZ3MPhpJy__k_I8MaCSO-PDRo,1068
206
- cycode-3.13.0.dist-info/RECORD,,
206
+ cycode-3.13.1.dev2.dist-info/METADATA,sha256=FW_uYfD3CfcqIEUZk-FkxYBYAe6dTlzjyqFuwO2jgSY,87082
207
+ cycode-3.13.1.dev2.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
208
+ cycode-3.13.1.dev2.dist-info/entry_points.txt,sha256=iDcVJM8ByLElVgvBgtYxDjw1kT7O8Mo0LcWZIT5L3Ig,45
209
+ cycode-3.13.1.dev2.dist-info/licenses/LICENCE,sha256=2Wx4N6mD_4xB7-E3hPkZ3MPhpJy__k_I8MaCSO-PDRo,1068
210
+ cycode-3.13.1.dev2.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- import typer
2
-
3
- from cycode.cli.apps.auth.auth_common import get_authorization_info
4
- from cycode.cli.apps.auth.auth_manager import AuthManager
5
- from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception
6
- from cycode.cli.logger import logger
7
-
8
-
9
- def ensure_auth_command(ctx: typer.Context) -> None:
10
- """Ensure the user is authenticated, triggering authentication if needed."""
11
- auth_info = get_authorization_info(ctx)
12
- if auth_info is not None:
13
- logger.debug('Already authenticated')
14
- return
15
-
16
- logger.debug('Not authenticated, starting authentication')
17
- try:
18
- auth_manager = AuthManager()
19
- auth_manager.authenticate()
20
- except Exception as err:
21
- handle_auth_exception(ctx, err)