cloudsense-customer-compass 0.1.0__tar.gz
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.
- cloudsense_customer_compass-0.1.0/.gitignore +12 -0
- cloudsense_customer_compass-0.1.0/PKG-INFO +15 -0
- cloudsense_customer_compass-0.1.0/README.md +3 -0
- cloudsense_customer_compass-0.1.0/pyproject.toml +26 -0
- cloudsense_customer_compass-0.1.0/src/cloudsense_customer_compass/__init__.py +3 -0
- cloudsense_customer_compass-0.1.0/src/cloudsense_customer_compass/__main__.py +5 -0
- cloudsense_customer_compass-0.1.0/src/cloudsense_customer_compass/server.py +96 -0
- cloudsense_customer_compass-0.1.0/src/cloudsense_customer_compass/tools/__init__.py +5 -0
- cloudsense_customer_compass-0.1.0/src/cloudsense_customer_compass/tools/connect_lma.py +118 -0
- cloudsense_customer_compass-0.1.0/src/cloudsense_customer_compass/utils/__init__.py +0 -0
- cloudsense_customer_compass-0.1.0/src/cloudsense_customer_compass/utils/safety.py +106 -0
- cloudsense_customer_compass-0.1.0/src/cloudsense_customer_compass/utils/sf_cli.py +156 -0
- cloudsense_customer_compass-0.1.0/src/cloudsense_customer_compass/utils/state.py +61 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cloudsense-customer-compass
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for CloudSense customer upgrade assessment, analysis, and impact verification.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: mcp>=1.0.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# CloudSense Customer Compass
|
|
14
|
+
|
|
15
|
+
MCP server for CloudSense customer upgrade assessment, analysis, and impact verification.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cloudsense-customer-compass"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for CloudSense customer upgrade assessment, analysis, and impact verification."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"mcp>=1.0.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
cloudsense-customer-compass = "cloudsense_customer_compass.server:main"
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
asyncio_mode = "auto"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/cloudsense_customer_compass"]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""CloudSense Customer Compass MCP server.
|
|
2
|
+
|
|
3
|
+
Run with: python -m cloudsense_customer_compass.server
|
|
4
|
+
Or via CLI: cloudsense-customer-compass
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from anyio import BrokenResourceError, ClosedResourceError
|
|
12
|
+
from mcp.server import Server
|
|
13
|
+
from mcp.server.stdio import stdio_server
|
|
14
|
+
import mcp.types as types
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .tools import connect_lma
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
server = Server("cloudsense-customer-compass")
|
|
22
|
+
|
|
23
|
+
TOOLS = {
|
|
24
|
+
"connect_lma": connect_lma,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
SERVER_INSTRUCTIONS = (
|
|
28
|
+
"You are connected to the CloudSense Customer Compass MCP server.\n\n"
|
|
29
|
+
"SAFETY: This server is STRICTLY READ-ONLY against customer Salesforce orgs. "
|
|
30
|
+
"NEVER deploy, push, insert, update, delete, or modify anything in the org.\n\n"
|
|
31
|
+
"WORKFLOW:\n"
|
|
32
|
+
"1. connect_lma -- Connect to the License Management Portal (FIRST step)\n"
|
|
33
|
+
"2. (more tools coming soon)\n\n"
|
|
34
|
+
"ORG RESOLUTION: If the user mentions an org, pass it as org_alias. "
|
|
35
|
+
"If not, omit — the tool uses defaults or state.\n"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@server.list_tools()
|
|
40
|
+
async def list_tools() -> list[types.Tool]:
|
|
41
|
+
return [
|
|
42
|
+
types.Tool(
|
|
43
|
+
name=mod.TOOL_DEFINITION["name"],
|
|
44
|
+
description=mod.TOOL_DEFINITION["description"],
|
|
45
|
+
inputSchema=mod.TOOL_DEFINITION["inputSchema"],
|
|
46
|
+
)
|
|
47
|
+
for mod in TOOLS.values()
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@server.call_tool()
|
|
52
|
+
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
53
|
+
tool_module = TOOLS.get(name)
|
|
54
|
+
if tool_module is None:
|
|
55
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
56
|
+
result = await tool_module.run(arguments)
|
|
57
|
+
return [types.TextContent(type="text", text=result)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_disconnect(exc: BaseException) -> bool:
|
|
61
|
+
if isinstance(exc, (BrokenResourceError, ClosedResourceError)):
|
|
62
|
+
return True
|
|
63
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
64
|
+
return all(_is_disconnect(e) for e in exc.exceptions)
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _run_server():
|
|
69
|
+
logger.info("CloudSense Customer Compass v%s starting...", __version__)
|
|
70
|
+
try:
|
|
71
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
72
|
+
init_options = server.create_initialization_options()
|
|
73
|
+
init_options.instructions = SERVER_INSTRUCTIONS
|
|
74
|
+
await server.run(read_stream, write_stream, init_options)
|
|
75
|
+
except BaseExceptionGroup as eg:
|
|
76
|
+
if _is_disconnect(eg):
|
|
77
|
+
logger.info("Client disconnected — shutting down.")
|
|
78
|
+
else:
|
|
79
|
+
raise
|
|
80
|
+
except (BrokenResourceError, ClosedResourceError):
|
|
81
|
+
logger.info("Client disconnected — shutting down.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main():
|
|
85
|
+
logging.basicConfig(level=logging.INFO)
|
|
86
|
+
try:
|
|
87
|
+
asyncio.run(_run_server())
|
|
88
|
+
except KeyboardInterrupt:
|
|
89
|
+
pass
|
|
90
|
+
except (BrokenPipeError, EOFError):
|
|
91
|
+
logger.info("Pipe closed — exiting.")
|
|
92
|
+
sys.exit(0)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
main()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""connect_lma -- Authorize the CloudSense License Management Portal.
|
|
2
|
+
|
|
3
|
+
Entry point for the workflow. Checks if the LMA org is already
|
|
4
|
+
authorized, opens browser login if not, and saves connection info
|
|
5
|
+
to assessment.json.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..utils import sf_cli, state
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
DEFAULT_ALIAS = "cs-lma"
|
|
16
|
+
DEFAULT_LOGIN_URL = "https://login.salesforce.com"
|
|
17
|
+
|
|
18
|
+
TOOL_DEFINITION = {
|
|
19
|
+
"name": "connect_lma",
|
|
20
|
+
"description": (
|
|
21
|
+
"Authorize the CloudSense License Management Portal. "
|
|
22
|
+
"This is the FIRST tool to call for any customer inquiry. "
|
|
23
|
+
"Checks if the LMA org is already authorized, opens browser "
|
|
24
|
+
"login if not, and saves connection info to state."
|
|
25
|
+
),
|
|
26
|
+
"inputSchema": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {
|
|
29
|
+
"org_alias": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": (
|
|
32
|
+
"Alias for the LMA org. Defaults to 'cs-lma'. "
|
|
33
|
+
"If already authorized under this alias, login is skipped."
|
|
34
|
+
),
|
|
35
|
+
},
|
|
36
|
+
"instance_url": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": (
|
|
39
|
+
"Salesforce login URL. Defaults to "
|
|
40
|
+
"'https://login.salesforce.com'. Override for custom domains."
|
|
41
|
+
),
|
|
42
|
+
},
|
|
43
|
+
"working_directory": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "Workspace directory. The AI passes this automatically.",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
"required": [],
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _validate_org(alias: str) -> dict[str, Any] | None:
|
|
54
|
+
"""Check if an org is authorized and accessible."""
|
|
55
|
+
try:
|
|
56
|
+
data = sf_cli.display_org(alias)
|
|
57
|
+
return data.get("result", {})
|
|
58
|
+
except sf_cli.SfCliError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def run(arguments: dict[str, Any]) -> str:
|
|
63
|
+
"""Execute the connect_lma tool."""
|
|
64
|
+
state.set_working_dir(arguments.get("working_directory"))
|
|
65
|
+
|
|
66
|
+
sf_check = sf_cli.check_sf_installed()
|
|
67
|
+
if not sf_check["installed"]:
|
|
68
|
+
return (
|
|
69
|
+
"## BLOCKER: Salesforce CLI not found\n\n"
|
|
70
|
+
"Install from: https://developer.salesforce.com/tools/salesforcecli\n"
|
|
71
|
+
"Then call connect_lma again."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
alias = arguments.get("org_alias") or DEFAULT_ALIAS
|
|
75
|
+
instance_url = arguments.get("instance_url")
|
|
76
|
+
|
|
77
|
+
org_info = _validate_org(alias)
|
|
78
|
+
|
|
79
|
+
if not org_info:
|
|
80
|
+
login_url = instance_url or DEFAULT_LOGIN_URL
|
|
81
|
+
try:
|
|
82
|
+
sf_cli.run_sf(
|
|
83
|
+
f"org login web --alias {alias} --instance-url {login_url}",
|
|
84
|
+
timeout=300, json_output=False,
|
|
85
|
+
)
|
|
86
|
+
except sf_cli.SfCliError as e:
|
|
87
|
+
return (
|
|
88
|
+
f"## BLOCKER: Login failed\n\n"
|
|
89
|
+
f"Error: {e}\n\n"
|
|
90
|
+
f"Run manually if browser didn't open:\n"
|
|
91
|
+
f"```\nsf org login web --alias {alias} --instance-url {login_url}\n```"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
org_info = _validate_org(alias)
|
|
95
|
+
if not org_info:
|
|
96
|
+
return (
|
|
97
|
+
"## BLOCKER: Login succeeded but validation failed\n\n"
|
|
98
|
+
"Call connect_lma again to retry."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
username = org_info.get("username", "unknown")
|
|
102
|
+
org_id = org_info.get("id", "unknown")
|
|
103
|
+
instance = org_info.get("instanceUrl", "unknown")
|
|
104
|
+
|
|
105
|
+
state.update(
|
|
106
|
+
lma_org_alias=alias,
|
|
107
|
+
lma_org_username=username,
|
|
108
|
+
lma_org_id=org_id,
|
|
109
|
+
lma_instance_url=instance,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
f"## LMA Connected\n\n"
|
|
114
|
+
f"- **Alias:** {alias}\n"
|
|
115
|
+
f"- **Username:** {username}\n"
|
|
116
|
+
f"- **Org ID:** {org_id}\n"
|
|
117
|
+
f"- **Instance:** {instance}\n"
|
|
118
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Safety policy enforcement for Salesforce CLI commands.
|
|
2
|
+
|
|
3
|
+
Every sf CLI command MUST pass through validate_sf_command() before execution.
|
|
4
|
+
If a command is not on the whitelist, it is blocked.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
ALLOWED_SF_SUBCOMMANDS: set[str] = {
|
|
11
|
+
"org login web",
|
|
12
|
+
"org list",
|
|
13
|
+
"org display",
|
|
14
|
+
"project generate",
|
|
15
|
+
"project retrieve start",
|
|
16
|
+
"org list metadata",
|
|
17
|
+
"package installed list",
|
|
18
|
+
"sobject list",
|
|
19
|
+
"sobject describe",
|
|
20
|
+
"data query",
|
|
21
|
+
"data search",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
LMA_ALLOWED_OBJECTS: set[str] = {
|
|
25
|
+
"ACCOUNT",
|
|
26
|
+
"SFLMA__LICENSE__C",
|
|
27
|
+
"SFLMA__PACKAGE__C",
|
|
28
|
+
"SFLMA__PACKAGE_VERSION__C",
|
|
29
|
+
"SUBSCRIBERPACKAGE",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
BLOCKED_KEYWORDS: set[str] = {
|
|
33
|
+
"deploy",
|
|
34
|
+
"push",
|
|
35
|
+
"delete",
|
|
36
|
+
"insert",
|
|
37
|
+
"update",
|
|
38
|
+
"upsert",
|
|
39
|
+
"undelete",
|
|
40
|
+
"destroy",
|
|
41
|
+
"execute",
|
|
42
|
+
"apex run",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SafetyViolationError(Exception):
|
|
47
|
+
"""Raised when a command violates the read-only safety policy."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_sf_command(command: str) -> None:
|
|
51
|
+
"""Validate that an sf CLI command is on the whitelist."""
|
|
52
|
+
normalized = " ".join(command.strip().split())
|
|
53
|
+
|
|
54
|
+
for blocked in BLOCKED_KEYWORDS:
|
|
55
|
+
if blocked in normalized.lower():
|
|
56
|
+
raise SafetyViolationError(
|
|
57
|
+
f"BLOCKED: Command contains '{blocked}' which is prohibited. "
|
|
58
|
+
f"Command: {command}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if not any(allowed in normalized for allowed in ALLOWED_SF_SUBCOMMANDS):
|
|
62
|
+
raise SafetyViolationError(
|
|
63
|
+
f"BLOCKED: Command not on the whitelist. "
|
|
64
|
+
f"Allowed: {sorted(ALLOWED_SF_SUBCOMMANDS)}. "
|
|
65
|
+
f"Command: {command}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_soql_query(
|
|
70
|
+
query: str,
|
|
71
|
+
*,
|
|
72
|
+
target_org: str | None = None,
|
|
73
|
+
lma_org: str | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Validate that a SOQL query is read-only and safe."""
|
|
76
|
+
normalized = query.strip().upper()
|
|
77
|
+
|
|
78
|
+
if not normalized.startswith("SELECT"):
|
|
79
|
+
raise SafetyViolationError(
|
|
80
|
+
f"BLOCKED: Only SELECT queries are permitted. Query: {query}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
for keyword in ("INSERT", "UPDATE", "DELETE", "UPSERT", "MERGE"):
|
|
84
|
+
if re.search(rf"\b{keyword}\b", normalized):
|
|
85
|
+
raise SafetyViolationError(
|
|
86
|
+
f"BLOCKED: DML keyword '{keyword}' found. Query: {query}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
is_lma_context = (
|
|
90
|
+
target_org is not None
|
|
91
|
+
and lma_org is not None
|
|
92
|
+
and target_org == lma_org
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
business_objects = [
|
|
96
|
+
"ACCOUNT", "CONTACT", "LEAD", "OPPORTUNITY", "CASE",
|
|
97
|
+
"CONTRACT", "TASK", "EVENT", "CAMPAIGN",
|
|
98
|
+
]
|
|
99
|
+
for obj in business_objects:
|
|
100
|
+
if re.search(rf"\bFROM\s+{obj}\b", normalized):
|
|
101
|
+
if is_lma_context and obj in LMA_ALLOWED_OBJECTS:
|
|
102
|
+
continue
|
|
103
|
+
raise SafetyViolationError(
|
|
104
|
+
f"BLOCKED: Querying business object '{obj}' is not permitted. "
|
|
105
|
+
f"Query: {query}"
|
|
106
|
+
)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Safe wrapper around Salesforce CLI (sf).
|
|
2
|
+
|
|
3
|
+
Every command goes through the safety validator before execution.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import subprocess
|
|
9
|
+
import shutil
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .safety import validate_sf_command, validate_soql_query
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SfCliError(Exception):
|
|
19
|
+
"""Raised when an sf CLI command fails."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, command: str = "", exit_code: int = -1, stderr: str = ""):
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.command = command
|
|
24
|
+
self.exit_code = exit_code
|
|
25
|
+
self.stderr = stderr
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_sf_installed() -> dict[str, Any]:
|
|
29
|
+
"""Check if Salesforce CLI is installed and return version info."""
|
|
30
|
+
sf_path = shutil.which("sf")
|
|
31
|
+
if not sf_path:
|
|
32
|
+
return {"installed": False, "version": None, "path": None}
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
result = subprocess.run(
|
|
36
|
+
["sf", "version", "--json"],
|
|
37
|
+
capture_output=True, text=True, timeout=15,
|
|
38
|
+
)
|
|
39
|
+
if result.returncode == 0:
|
|
40
|
+
try:
|
|
41
|
+
data = json.loads(result.stdout)
|
|
42
|
+
version = data.get("cliVersion", result.stdout.strip())
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
version = result.stdout.strip()
|
|
45
|
+
return {"installed": True, "version": version, "path": sf_path}
|
|
46
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
return {"installed": True, "version": "unknown", "path": sf_path}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_sf(
|
|
53
|
+
command: str | list[str],
|
|
54
|
+
*,
|
|
55
|
+
cwd: Path | str | None = None,
|
|
56
|
+
timeout: int = 120,
|
|
57
|
+
json_output: bool = True,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""Run an sf CLI command safely.
|
|
60
|
+
|
|
61
|
+
The command string should NOT include 'sf' prefix.
|
|
62
|
+
"""
|
|
63
|
+
if isinstance(command, list):
|
|
64
|
+
full_command = "sf " + " ".join(command)
|
|
65
|
+
args = ["sf"] + command
|
|
66
|
+
else:
|
|
67
|
+
full_command = f"sf {command}"
|
|
68
|
+
args = ["sf"] + command.split()
|
|
69
|
+
|
|
70
|
+
validate_sf_command(full_command)
|
|
71
|
+
|
|
72
|
+
if json_output and "--json" not in args:
|
|
73
|
+
args.append("--json")
|
|
74
|
+
|
|
75
|
+
logger.info("Running: %s (cwd=%s)", " ".join(args), cwd or ".")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
result = subprocess.run(
|
|
79
|
+
args, capture_output=True, text=True,
|
|
80
|
+
cwd=str(cwd) if cwd else None, timeout=timeout,
|
|
81
|
+
)
|
|
82
|
+
except subprocess.TimeoutExpired:
|
|
83
|
+
raise SfCliError(
|
|
84
|
+
f"Command timed out after {timeout}s: {full_command}",
|
|
85
|
+
command=full_command, exit_code=-1, stderr="TimeoutExpired",
|
|
86
|
+
)
|
|
87
|
+
except FileNotFoundError:
|
|
88
|
+
raise SfCliError(
|
|
89
|
+
"Salesforce CLI (sf) not found on PATH.",
|
|
90
|
+
command=full_command, exit_code=-1, stderr="FileNotFoundError",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if result.returncode != 0:
|
|
94
|
+
stderr = result.stderr.strip() or result.stdout.strip()
|
|
95
|
+
raise SfCliError(
|
|
96
|
+
f"sf command failed (exit {result.returncode}): {stderr}",
|
|
97
|
+
command=full_command, exit_code=result.returncode, stderr=stderr,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if json_output:
|
|
101
|
+
try:
|
|
102
|
+
return json.loads(result.stdout)
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
return {"stdout": result.stdout.strip()}
|
|
105
|
+
|
|
106
|
+
return {"stdout": result.stdout.strip()}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def list_orgs(cwd: Path | str | None = None) -> list[dict[str, Any]]:
|
|
110
|
+
"""List all authorized Salesforce orgs."""
|
|
111
|
+
data = run_sf("org list", cwd=cwd)
|
|
112
|
+
result = data.get("result", {})
|
|
113
|
+
orgs = []
|
|
114
|
+
for category in ("nonScratchOrgs", "scratchOrgs"):
|
|
115
|
+
orgs.extend(result.get(category, []))
|
|
116
|
+
return orgs
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def display_org(alias: str, cwd: Path | str | None = None) -> dict[str, Any]:
|
|
120
|
+
"""Get detailed info about a specific org."""
|
|
121
|
+
return run_sf(f"org display --target-org {alias}", cwd=cwd)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def lma_query(
|
|
125
|
+
query: str,
|
|
126
|
+
lma_org: str,
|
|
127
|
+
cwd: Path | str | None = None,
|
|
128
|
+
timeout: int = 120,
|
|
129
|
+
) -> list[dict[str, Any]]:
|
|
130
|
+
"""Run a read-only SOQL query against the LMA org."""
|
|
131
|
+
validate_soql_query(query, target_org=lma_org, lma_org=lma_org)
|
|
132
|
+
data = run_sf(
|
|
133
|
+
["data", "query", "--query", query, "--target-org", lma_org],
|
|
134
|
+
cwd=cwd, timeout=timeout,
|
|
135
|
+
)
|
|
136
|
+
result = data.get("result", {})
|
|
137
|
+
records = result.get("records") or []
|
|
138
|
+
return records if isinstance(records, list) else []
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def sosl_search(
|
|
142
|
+
search_term: str,
|
|
143
|
+
returning: str,
|
|
144
|
+
target_org: str,
|
|
145
|
+
cwd: Path | str | None = None,
|
|
146
|
+
timeout: int = 60,
|
|
147
|
+
) -> list[dict[str, Any]]:
|
|
148
|
+
"""Run a SOSL search via sf data search."""
|
|
149
|
+
sosl_query = f"FIND {{{search_term}}} IN NAME FIELDS RETURNING {returning}"
|
|
150
|
+
data = run_sf(
|
|
151
|
+
["data", "search", "--query", sosl_query, "--target-org", target_org],
|
|
152
|
+
cwd=cwd, timeout=timeout,
|
|
153
|
+
)
|
|
154
|
+
result = data.get("result", {})
|
|
155
|
+
records = result.get("searchRecords") or []
|
|
156
|
+
return records if isinstance(records, list) else []
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Lean assessment state management.
|
|
2
|
+
|
|
3
|
+
Manages assessment.json — a minimal state file tracking workflow status
|
|
4
|
+
and connection info only. No data duplication; customer data lives in
|
|
5
|
+
customer/ directory files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
STATE_FILENAME = "assessment.json"
|
|
17
|
+
|
|
18
|
+
_working_dir: Path | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def set_working_dir(path: str | Path | None) -> None:
|
|
22
|
+
"""Set the working directory from a tool argument."""
|
|
23
|
+
global _working_dir
|
|
24
|
+
if path:
|
|
25
|
+
_working_dir = Path(path)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_working_dir() -> Path:
|
|
29
|
+
"""Return the working directory (explicit override or cwd)."""
|
|
30
|
+
return _working_dir or Path.cwd()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _state_path() -> Path:
|
|
34
|
+
return get_working_dir() / STATE_FILENAME
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load() -> dict[str, Any]:
|
|
38
|
+
"""Load the state file. Returns empty dict if it doesn't exist."""
|
|
39
|
+
path = _state_path()
|
|
40
|
+
if path.exists():
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(path.read_text())
|
|
43
|
+
except (json.JSONDecodeError, OSError):
|
|
44
|
+
logger.warning("Could not read state file at %s", path)
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save(data: dict[str, Any]) -> None:
|
|
49
|
+
"""Write the state file."""
|
|
50
|
+
path = _state_path()
|
|
51
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
path.write_text(json.dumps(data, indent=2, default=str))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def update(**fields: Any) -> dict[str, Any]:
|
|
56
|
+
"""Merge fields into the existing state and save."""
|
|
57
|
+
current = load()
|
|
58
|
+
current.update(fields)
|
|
59
|
+
current["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
60
|
+
save(current)
|
|
61
|
+
return current
|