cited-cli 0.3.2__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.
- cited_cli/__init__.py +1 -0
- cited_cli/__main__.py +3 -0
- cited_cli/api/__init__.py +0 -0
- cited_cli/api/client.py +111 -0
- cited_cli/api/endpoints.py +88 -0
- cited_cli/app.py +199 -0
- cited_cli/auth/__init__.py +0 -0
- cited_cli/auth/oauth_server.py +123 -0
- cited_cli/auth/store.py +83 -0
- cited_cli/commands/__init__.py +0 -0
- cited_cli/commands/agent.py +129 -0
- cited_cli/commands/analytics.py +83 -0
- cited_cli/commands/audit.py +177 -0
- cited_cli/commands/auth.py +353 -0
- cited_cli/commands/business.py +217 -0
- cited_cli/commands/config_cmd.py +111 -0
- cited_cli/commands/hq.py +125 -0
- cited_cli/commands/job.py +106 -0
- cited_cli/commands/mcp_cmd.py +30 -0
- cited_cli/commands/named_audit.py +244 -0
- cited_cli/commands/recommend.py +211 -0
- cited_cli/commands/solution.py +154 -0
- cited_cli/config/__init__.py +0 -0
- cited_cli/config/constants.py +48 -0
- cited_cli/config/manager.py +69 -0
- cited_cli/mcp/__init__.py +1 -0
- cited_cli/mcp/context.py +12 -0
- cited_cli/mcp/server.py +54 -0
- cited_cli/mcp/tools/__init__.py +1 -0
- cited_cli/mcp/tools/_helpers.py +33 -0
- cited_cli/mcp/tools/audit.py +129 -0
- cited_cli/mcp/tools/auth.py +23 -0
- cited_cli/mcp/tools/business.py +96 -0
- cited_cli/mcp/tools/job.py +57 -0
- cited_cli/mcp/tools/recommend.py +96 -0
- cited_cli/mcp/tools/solution.py +66 -0
- cited_cli/output/__init__.py +0 -0
- cited_cli/output/formatter.py +61 -0
- cited_cli/output/progress.py +56 -0
- cited_cli/output/tables.py +49 -0
- cited_cli/utils/__init__.py +0 -0
- cited_cli/utils/errors.py +31 -0
- cited_cli/utils/interactive.py +83 -0
- cited_cli-0.3.2.dist-info/METADATA +515 -0
- cited_cli-0.3.2.dist-info/RECORD +48 -0
- cited_cli-0.3.2.dist-info/WHEEL +4 -0
- cited_cli-0.3.2.dist-info/entry_points.txt +2 -0
- cited_cli-0.3.2.dist-info/licenses/LICENSE +4 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import Context
|
|
6
|
+
|
|
7
|
+
from cited_cli.api import endpoints
|
|
8
|
+
from cited_cli.mcp.context import CitedContext
|
|
9
|
+
from cited_cli.mcp.server import mcp
|
|
10
|
+
from cited_cli.mcp.tools._helpers import _api_error_response, _auth_check, _get_ctx
|
|
11
|
+
from cited_cli.utils.errors import CitedAPIError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@mcp.tool()
|
|
15
|
+
async def list_audit_templates(ctx: Context[Any, CitedContext, Any]) -> Any:
|
|
16
|
+
"""List all audit templates (named audits) for the user."""
|
|
17
|
+
cited_ctx = _get_ctx(ctx)
|
|
18
|
+
if err := _auth_check(cited_ctx):
|
|
19
|
+
return err
|
|
20
|
+
try:
|
|
21
|
+
return cited_ctx.client.get(endpoints.NAMED_AUDITS)
|
|
22
|
+
except CitedAPIError as e:
|
|
23
|
+
return _api_error_response(e)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@mcp.tool()
|
|
27
|
+
async def get_audit_template(ctx: Context[Any, CitedContext, Any], named_audit_id: str) -> Any:
|
|
28
|
+
"""Get a specific audit template by ID."""
|
|
29
|
+
cited_ctx = _get_ctx(ctx)
|
|
30
|
+
if err := _auth_check(cited_ctx):
|
|
31
|
+
return err
|
|
32
|
+
try:
|
|
33
|
+
return cited_ctx.client.get(
|
|
34
|
+
endpoints.NAMED_AUDIT.format(named_audit_id=named_audit_id)
|
|
35
|
+
)
|
|
36
|
+
except CitedAPIError as e:
|
|
37
|
+
return _api_error_response(e)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@mcp.tool()
|
|
41
|
+
async def create_audit_template(
|
|
42
|
+
ctx: Context[Any, CitedContext, Any],
|
|
43
|
+
name: str,
|
|
44
|
+
business_id: str,
|
|
45
|
+
description: str | None = None,
|
|
46
|
+
questions: list[str] | None = None,
|
|
47
|
+
) -> Any:
|
|
48
|
+
"""Create a new audit template.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
ctx: MCP context
|
|
52
|
+
name: Template name
|
|
53
|
+
business_id: Business ID to associate with
|
|
54
|
+
description: Optional template description
|
|
55
|
+
questions: Optional list of audit questions
|
|
56
|
+
"""
|
|
57
|
+
cited_ctx = _get_ctx(ctx)
|
|
58
|
+
if err := _auth_check(cited_ctx):
|
|
59
|
+
return err
|
|
60
|
+
payload: dict[str, Any] = {"name": name, "business_id": business_id}
|
|
61
|
+
if description is not None:
|
|
62
|
+
payload["description"] = description
|
|
63
|
+
if questions is not None:
|
|
64
|
+
payload["questions"] = questions
|
|
65
|
+
try:
|
|
66
|
+
return cited_ctx.client.post(endpoints.NAMED_AUDITS, json=payload)
|
|
67
|
+
except CitedAPIError as e:
|
|
68
|
+
return _api_error_response(e)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@mcp.tool()
|
|
72
|
+
async def start_audit(
|
|
73
|
+
ctx: Context[Any, CitedContext, Any],
|
|
74
|
+
named_audit_id: str,
|
|
75
|
+
business_id: str | None = None,
|
|
76
|
+
) -> Any:
|
|
77
|
+
"""Start an audit using a template. Returns a job_id to poll for status.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
ctx: MCP context
|
|
81
|
+
named_audit_id: The audit template ID to run
|
|
82
|
+
business_id: Optional business ID override
|
|
83
|
+
"""
|
|
84
|
+
cited_ctx = _get_ctx(ctx)
|
|
85
|
+
if err := _auth_check(cited_ctx):
|
|
86
|
+
return err
|
|
87
|
+
payload: dict[str, Any] = {"named_audit_id": named_audit_id}
|
|
88
|
+
if business_id is not None:
|
|
89
|
+
payload["business_id"] = business_id
|
|
90
|
+
try:
|
|
91
|
+
return cited_ctx.client.post(endpoints.AUDIT_START, json=payload)
|
|
92
|
+
except CitedAPIError as e:
|
|
93
|
+
return _api_error_response(e)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@mcp.tool()
|
|
97
|
+
async def get_audit_status(ctx: Context[Any, CitedContext, Any], job_id: str) -> Any:
|
|
98
|
+
"""Check the status of a running audit job."""
|
|
99
|
+
cited_ctx = _get_ctx(ctx)
|
|
100
|
+
if err := _auth_check(cited_ctx):
|
|
101
|
+
return err
|
|
102
|
+
try:
|
|
103
|
+
return cited_ctx.client.get(endpoints.AUDIT_STATUS.format(job_id=job_id))
|
|
104
|
+
except CitedAPIError as e:
|
|
105
|
+
return _api_error_response(e)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@mcp.tool()
|
|
109
|
+
async def get_audit_result(ctx: Context[Any, CitedContext, Any], job_id: str) -> Any:
|
|
110
|
+
"""Get the results of a completed audit job."""
|
|
111
|
+
cited_ctx = _get_ctx(ctx)
|
|
112
|
+
if err := _auth_check(cited_ctx):
|
|
113
|
+
return err
|
|
114
|
+
try:
|
|
115
|
+
return cited_ctx.client.get(endpoints.AUDIT_RESULT.format(job_id=job_id))
|
|
116
|
+
except CitedAPIError as e:
|
|
117
|
+
return _api_error_response(e)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@mcp.tool()
|
|
121
|
+
async def list_audits(ctx: Context[Any, CitedContext, Any]) -> Any:
|
|
122
|
+
"""List audit history."""
|
|
123
|
+
cited_ctx = _get_ctx(ctx)
|
|
124
|
+
if err := _auth_check(cited_ctx):
|
|
125
|
+
return err
|
|
126
|
+
try:
|
|
127
|
+
return cited_ctx.client.get(endpoints.AUDIT_HISTORY)
|
|
128
|
+
except CitedAPIError as e:
|
|
129
|
+
return _api_error_response(e)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import Context
|
|
6
|
+
|
|
7
|
+
from cited_cli.api import endpoints
|
|
8
|
+
from cited_cli.mcp.context import CitedContext
|
|
9
|
+
from cited_cli.mcp.server import mcp
|
|
10
|
+
from cited_cli.mcp.tools._helpers import _api_error_response, _auth_check, _get_ctx
|
|
11
|
+
from cited_cli.utils.errors import CitedAPIError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@mcp.tool()
|
|
15
|
+
async def check_auth_status(ctx: Context[Any, CitedContext, Any]) -> Any:
|
|
16
|
+
"""Check if the user is authenticated and return their account info."""
|
|
17
|
+
cited_ctx = _get_ctx(ctx)
|
|
18
|
+
if err := _auth_check(cited_ctx):
|
|
19
|
+
return err
|
|
20
|
+
try:
|
|
21
|
+
return cited_ctx.client.get(endpoints.ME)
|
|
22
|
+
except CitedAPIError as e:
|
|
23
|
+
return _api_error_response(e)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import Context
|
|
6
|
+
|
|
7
|
+
from cited_cli.api import endpoints
|
|
8
|
+
from cited_cli.mcp.context import CitedContext
|
|
9
|
+
from cited_cli.mcp.server import mcp
|
|
10
|
+
from cited_cli.mcp.tools._helpers import _api_error_response, _auth_check, _get_ctx
|
|
11
|
+
from cited_cli.utils.errors import CitedAPIError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@mcp.tool()
|
|
15
|
+
async def list_businesses(ctx: Context[Any, CitedContext, Any]) -> Any:
|
|
16
|
+
"""List all businesses for the authenticated user."""
|
|
17
|
+
cited_ctx = _get_ctx(ctx)
|
|
18
|
+
if err := _auth_check(cited_ctx):
|
|
19
|
+
return err
|
|
20
|
+
try:
|
|
21
|
+
return cited_ctx.client.get(endpoints.BUSINESSES)
|
|
22
|
+
except CitedAPIError as e:
|
|
23
|
+
return _api_error_response(e)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@mcp.tool()
|
|
27
|
+
async def get_business(ctx: Context[Any, CitedContext, Any], business_id: str) -> Any:
|
|
28
|
+
"""Get details for a specific business by ID."""
|
|
29
|
+
cited_ctx = _get_ctx(ctx)
|
|
30
|
+
if err := _auth_check(cited_ctx):
|
|
31
|
+
return err
|
|
32
|
+
try:
|
|
33
|
+
return cited_ctx.client.get(endpoints.BUSINESS.format(business_id=business_id))
|
|
34
|
+
except CitedAPIError as e:
|
|
35
|
+
return _api_error_response(e)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@mcp.tool()
|
|
39
|
+
async def create_business(
|
|
40
|
+
ctx: Context[Any, CitedContext, Any],
|
|
41
|
+
name: str,
|
|
42
|
+
website: str,
|
|
43
|
+
description: str,
|
|
44
|
+
industry: str = "technology",
|
|
45
|
+
) -> Any:
|
|
46
|
+
"""Create a new business.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
ctx: MCP context
|
|
50
|
+
name: Business name
|
|
51
|
+
website: Business website (must be a publicly DNS-resolvable domain)
|
|
52
|
+
description: Business description (minimum ~50 characters)
|
|
53
|
+
industry: One of: automotive, beauty, consulting, education, entertainment,
|
|
54
|
+
finance, fitness, government, healthcare, home_services, hospitality,
|
|
55
|
+
legal, manufacturing, non_profit, real_estate, restaurant, retail,
|
|
56
|
+
technology, other
|
|
57
|
+
"""
|
|
58
|
+
cited_ctx = _get_ctx(ctx)
|
|
59
|
+
if err := _auth_check(cited_ctx):
|
|
60
|
+
return err
|
|
61
|
+
try:
|
|
62
|
+
return cited_ctx.client.post(
|
|
63
|
+
endpoints.BUSINESSES,
|
|
64
|
+
json={
|
|
65
|
+
"name": name,
|
|
66
|
+
"website": website,
|
|
67
|
+
"description": description,
|
|
68
|
+
"industry": industry,
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
except CitedAPIError as e:
|
|
72
|
+
return _api_error_response(e)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@mcp.tool()
|
|
76
|
+
async def crawl_business(ctx: Context[Any, CitedContext, Any], business_id: str) -> Any:
|
|
77
|
+
"""Start a crawl job for a business. Returns a job_id you can poll with get_job_status."""
|
|
78
|
+
cited_ctx = _get_ctx(ctx)
|
|
79
|
+
if err := _auth_check(cited_ctx):
|
|
80
|
+
return err
|
|
81
|
+
try:
|
|
82
|
+
return cited_ctx.client.post(endpoints.CRAWL_START.format(business_id=business_id))
|
|
83
|
+
except CitedAPIError as e:
|
|
84
|
+
return _api_error_response(e)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@mcp.tool()
|
|
88
|
+
async def get_health_scores(ctx: Context[Any, CitedContext, Any], business_id: str) -> Any:
|
|
89
|
+
"""Get GEO health scores for a business."""
|
|
90
|
+
cited_ctx = _get_ctx(ctx)
|
|
91
|
+
if err := _auth_check(cited_ctx):
|
|
92
|
+
return err
|
|
93
|
+
try:
|
|
94
|
+
return cited_ctx.client.get(endpoints.HEALTH_SCORES.format(business_id=business_id))
|
|
95
|
+
except CitedAPIError as e:
|
|
96
|
+
return _api_error_response(e)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import Context
|
|
6
|
+
|
|
7
|
+
from cited_cli.api import endpoints
|
|
8
|
+
from cited_cli.mcp.context import CitedContext
|
|
9
|
+
from cited_cli.mcp.server import mcp
|
|
10
|
+
from cited_cli.mcp.tools._helpers import _api_error_response, _auth_check, _get_ctx
|
|
11
|
+
from cited_cli.utils.errors import CitedAPIError
|
|
12
|
+
|
|
13
|
+
_STATUS_ENDPOINTS = {
|
|
14
|
+
"audit": endpoints.AUDIT_STATUS,
|
|
15
|
+
"recommendation": endpoints.RECOMMEND_STATUS,
|
|
16
|
+
"solution": endpoints.SOLUTION_STATUS,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@mcp.tool()
|
|
21
|
+
async def get_job_status(
|
|
22
|
+
ctx: Context[Any, CitedContext, Any],
|
|
23
|
+
job_id: str,
|
|
24
|
+
job_type: str | None = None,
|
|
25
|
+
) -> Any:
|
|
26
|
+
"""Get the status of any job by ID.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
ctx: MCP context
|
|
30
|
+
job_id: The job ID to check
|
|
31
|
+
job_type: Optional job type hint: audit, recommendation, or solution.
|
|
32
|
+
If not provided, will probe each type until one responds.
|
|
33
|
+
"""
|
|
34
|
+
cited_ctx = _get_ctx(ctx)
|
|
35
|
+
if err := _auth_check(cited_ctx):
|
|
36
|
+
return err
|
|
37
|
+
|
|
38
|
+
if job_type and job_type in _STATUS_ENDPOINTS:
|
|
39
|
+
try:
|
|
40
|
+
result = cited_ctx.client.get(
|
|
41
|
+
_STATUS_ENDPOINTS[job_type].format(job_id=job_id)
|
|
42
|
+
)
|
|
43
|
+
result["job_type"] = job_type
|
|
44
|
+
return result
|
|
45
|
+
except CitedAPIError as e:
|
|
46
|
+
return _api_error_response(e)
|
|
47
|
+
|
|
48
|
+
# Probe each type
|
|
49
|
+
for jtype, endpoint_tpl in _STATUS_ENDPOINTS.items():
|
|
50
|
+
try:
|
|
51
|
+
result = cited_ctx.client.get(endpoint_tpl.format(job_id=job_id))
|
|
52
|
+
result["job_type"] = jtype
|
|
53
|
+
return result
|
|
54
|
+
except CitedAPIError:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
return {"error": True, "message": f"No job found with ID: {job_id}"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import Context
|
|
6
|
+
|
|
7
|
+
from cited_cli.api import endpoints
|
|
8
|
+
from cited_cli.mcp.context import CitedContext
|
|
9
|
+
from cited_cli.mcp.server import mcp
|
|
10
|
+
from cited_cli.mcp.tools._helpers import _api_error_response, _auth_check, _get_ctx
|
|
11
|
+
from cited_cli.utils.errors import CitedAPIError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@mcp.tool()
|
|
15
|
+
async def start_recommendation(ctx: Context[Any, CitedContext, Any], audit_job_id: str) -> Any:
|
|
16
|
+
"""Start a recommendation job based on a completed audit.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
ctx: MCP context
|
|
20
|
+
audit_job_id: The completed audit job ID to generate recommendations for
|
|
21
|
+
"""
|
|
22
|
+
cited_ctx = _get_ctx(ctx)
|
|
23
|
+
if err := _auth_check(cited_ctx):
|
|
24
|
+
return err
|
|
25
|
+
try:
|
|
26
|
+
return cited_ctx.client.post(
|
|
27
|
+
endpoints.RECOMMEND_START,
|
|
28
|
+
json={"audit_job_id": audit_job_id},
|
|
29
|
+
)
|
|
30
|
+
except CitedAPIError as e:
|
|
31
|
+
return _api_error_response(e)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@mcp.tool()
|
|
35
|
+
async def get_recommendation_status(ctx: Context[Any, CitedContext, Any], job_id: str) -> Any:
|
|
36
|
+
"""Check the status of a recommendation job."""
|
|
37
|
+
cited_ctx = _get_ctx(ctx)
|
|
38
|
+
if err := _auth_check(cited_ctx):
|
|
39
|
+
return err
|
|
40
|
+
try:
|
|
41
|
+
return cited_ctx.client.get(endpoints.RECOMMEND_STATUS.format(job_id=job_id))
|
|
42
|
+
except CitedAPIError as e:
|
|
43
|
+
return _api_error_response(e)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@mcp.tool()
|
|
47
|
+
async def get_recommendation_result(ctx: Context[Any, CitedContext, Any], job_id: str) -> Any:
|
|
48
|
+
"""Get the full results of a completed recommendation job."""
|
|
49
|
+
cited_ctx = _get_ctx(ctx)
|
|
50
|
+
if err := _auth_check(cited_ctx):
|
|
51
|
+
return err
|
|
52
|
+
try:
|
|
53
|
+
return cited_ctx.client.get(endpoints.RECOMMEND_RESULT.format(job_id=job_id))
|
|
54
|
+
except CitedAPIError as e:
|
|
55
|
+
return _api_error_response(e)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@mcp.tool()
|
|
59
|
+
async def get_recommendation_insights(ctx: Context[Any, CitedContext, Any], job_id: str) -> Any:
|
|
60
|
+
"""Get actionable insights from a recommendation, with source_type and source_id for solutions.
|
|
61
|
+
|
|
62
|
+
Returns question_insights, head_to_head_comparisons, strengthening_tips, and
|
|
63
|
+
priority_actions, each annotated with the source_type and source_id needed to
|
|
64
|
+
start a solution via start_solution.
|
|
65
|
+
"""
|
|
66
|
+
cited_ctx = _get_ctx(ctx)
|
|
67
|
+
if err := _auth_check(cited_ctx):
|
|
68
|
+
return err
|
|
69
|
+
try:
|
|
70
|
+
result = cited_ctx.client.get(endpoints.RECOMMEND_RESULT.format(job_id=job_id))
|
|
71
|
+
except CitedAPIError as e:
|
|
72
|
+
return _api_error_response(e)
|
|
73
|
+
|
|
74
|
+
insights: dict[str, Any] = {}
|
|
75
|
+
|
|
76
|
+
for item in result.get("question_insights", []):
|
|
77
|
+
item["source_type"] = "question_insight"
|
|
78
|
+
item["source_id"] = item.get("question_id", "")
|
|
79
|
+
insights["question_insights"] = result.get("question_insights", [])
|
|
80
|
+
|
|
81
|
+
for item in result.get("head_to_head_comparisons", []):
|
|
82
|
+
item["source_type"] = "head_to_head"
|
|
83
|
+
item["source_id"] = item.get("competitor_domain", "")
|
|
84
|
+
insights["head_to_head_comparisons"] = result.get("head_to_head_comparisons", [])
|
|
85
|
+
|
|
86
|
+
for item in result.get("strengthening_tips", []):
|
|
87
|
+
item["source_type"] = "strengthening_tip"
|
|
88
|
+
item["source_id"] = item.get("category", "")
|
|
89
|
+
insights["strengthening_tips"] = result.get("strengthening_tips", [])
|
|
90
|
+
|
|
91
|
+
for item in result.get("priority_actions", []):
|
|
92
|
+
item["source_type"] = "priority_action"
|
|
93
|
+
item["source_id"] = item.get("id", item.get("category", ""))
|
|
94
|
+
insights["priority_actions"] = result.get("priority_actions", [])
|
|
95
|
+
|
|
96
|
+
return insights
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import Context
|
|
6
|
+
|
|
7
|
+
from cited_cli.api import endpoints
|
|
8
|
+
from cited_cli.mcp.context import CitedContext
|
|
9
|
+
from cited_cli.mcp.server import mcp
|
|
10
|
+
from cited_cli.mcp.tools._helpers import _api_error_response, _auth_check, _get_ctx
|
|
11
|
+
from cited_cli.utils.errors import CitedAPIError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@mcp.tool()
|
|
15
|
+
async def start_solution(
|
|
16
|
+
ctx: Context[Any, CitedContext, Any],
|
|
17
|
+
recommendation_job_id: str,
|
|
18
|
+
source_type: str,
|
|
19
|
+
source_id: str,
|
|
20
|
+
) -> Any:
|
|
21
|
+
"""Start generating a solution for a specific recommendation insight.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
ctx: MCP context
|
|
25
|
+
recommendation_job_id: The recommendation job ID
|
|
26
|
+
source_type: One of: question_insight, head_to_head, strengthening_tip, priority_action
|
|
27
|
+
source_id: The source identifier (question_id, competitor_domain, category, etc.)
|
|
28
|
+
"""
|
|
29
|
+
cited_ctx = _get_ctx(ctx)
|
|
30
|
+
if err := _auth_check(cited_ctx):
|
|
31
|
+
return err
|
|
32
|
+
try:
|
|
33
|
+
return cited_ctx.client.post(
|
|
34
|
+
endpoints.SOLUTION_REQUEST,
|
|
35
|
+
json={
|
|
36
|
+
"recommendation_job_id": recommendation_job_id,
|
|
37
|
+
"source_type": source_type,
|
|
38
|
+
"source_id": source_id,
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
except CitedAPIError as e:
|
|
42
|
+
return _api_error_response(e)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp.tool()
|
|
46
|
+
async def get_solution_status(ctx: Context[Any, CitedContext, Any], job_id: str) -> Any:
|
|
47
|
+
"""Check the status of a solution generation job."""
|
|
48
|
+
cited_ctx = _get_ctx(ctx)
|
|
49
|
+
if err := _auth_check(cited_ctx):
|
|
50
|
+
return err
|
|
51
|
+
try:
|
|
52
|
+
return cited_ctx.client.get(endpoints.SOLUTION_STATUS.format(job_id=job_id))
|
|
53
|
+
except CitedAPIError as e:
|
|
54
|
+
return _api_error_response(e)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@mcp.tool()
|
|
58
|
+
async def get_solution_result(ctx: Context[Any, CitedContext, Any], job_id: str) -> Any:
|
|
59
|
+
"""Get the results of a completed solution generation job."""
|
|
60
|
+
cited_ctx = _get_ctx(ctx)
|
|
61
|
+
if err := _auth_check(cited_ctx):
|
|
62
|
+
return err
|
|
63
|
+
try:
|
|
64
|
+
return cited_ctx.client.get(endpoints.SOLUTION_RESULT.format(job_id=job_id))
|
|
65
|
+
except CitedAPIError as e:
|
|
66
|
+
return _api_error_response(e)
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class OutputContext:
|
|
14
|
+
json_mode: bool = False
|
|
15
|
+
quiet: bool = False
|
|
16
|
+
no_color: bool = False
|
|
17
|
+
_console: Console | None = field(default=None, repr=False)
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def console(self) -> Console:
|
|
21
|
+
if self._console is None:
|
|
22
|
+
self._console = Console(
|
|
23
|
+
no_color=self.no_color,
|
|
24
|
+
quiet=self.quiet and not self.json_mode,
|
|
25
|
+
)
|
|
26
|
+
return self._console
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def print_result(
|
|
30
|
+
data: Any,
|
|
31
|
+
ctx: OutputContext,
|
|
32
|
+
human_formatter: Callable[[Any, Console], None] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
if ctx.json_mode:
|
|
35
|
+
sys.stdout.write(json.dumps(data, indent=2, default=str) + "\n")
|
|
36
|
+
elif human_formatter:
|
|
37
|
+
human_formatter(data, ctx.console)
|
|
38
|
+
else:
|
|
39
|
+
ctx.console.print_json(json.dumps(data, default=str))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def print_success(message: str, ctx: OutputContext) -> None:
|
|
43
|
+
if ctx.json_mode:
|
|
44
|
+
sys.stdout.write(json.dumps({"status": "ok", "message": message}) + "\n")
|
|
45
|
+
elif not ctx.quiet:
|
|
46
|
+
ctx.console.print(f"[green]✓[/green] {message}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def print_error(message: str, ctx: OutputContext, status_code: int | None = None) -> None:
|
|
50
|
+
if ctx.json_mode:
|
|
51
|
+
err: dict[str, Any] = {"error": True, "message": message}
|
|
52
|
+
if status_code:
|
|
53
|
+
err["status_code"] = status_code
|
|
54
|
+
sys.stderr.write(json.dumps(err) + "\n")
|
|
55
|
+
else:
|
|
56
|
+
Console(stderr=True).print(f"[red]Error:[/red] {message}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def print_warning(message: str, ctx: OutputContext) -> None:
|
|
60
|
+
if not ctx.json_mode and not ctx.quiet:
|
|
61
|
+
ctx.console.print(f"[yellow]Warning:[/yellow] {message}")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.live import Live
|
|
7
|
+
from rich.spinner import Spinner
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from cited_cli.utils.errors import CitedAPIError
|
|
11
|
+
from cited_core.api.client import CitedClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def watch_job(
|
|
15
|
+
client: CitedClient,
|
|
16
|
+
status_path: str,
|
|
17
|
+
console: Console | None = None,
|
|
18
|
+
poll_interval: float = 2.0,
|
|
19
|
+
) -> dict[str, object]:
|
|
20
|
+
"""Poll a job status endpoint until completion. Returns the final status response."""
|
|
21
|
+
c = console or Console()
|
|
22
|
+
|
|
23
|
+
with Live(Spinner("dots", text="Starting..."), console=c, refresh_per_second=4) as live:
|
|
24
|
+
while True:
|
|
25
|
+
try:
|
|
26
|
+
data = client.get(status_path)
|
|
27
|
+
except CitedAPIError:
|
|
28
|
+
raise
|
|
29
|
+
|
|
30
|
+
status = data.get("status", "unknown")
|
|
31
|
+
progress = data.get("progress")
|
|
32
|
+
message = data.get("message", "")
|
|
33
|
+
|
|
34
|
+
if progress is not None:
|
|
35
|
+
pct = (
|
|
36
|
+
int(progress * 100)
|
|
37
|
+
if isinstance(progress, float) and progress <= 1
|
|
38
|
+
else int(progress)
|
|
39
|
+
)
|
|
40
|
+
bar_w = 20
|
|
41
|
+
filled = int((pct / 100) * bar_w)
|
|
42
|
+
bar = f"{'█' * filled}{'░' * (bar_w - filled)}"
|
|
43
|
+
text = Text.from_markup(
|
|
44
|
+
f"[cyan]{bar}[/cyan] {pct}% — {status} {message}"
|
|
45
|
+
)
|
|
46
|
+
else:
|
|
47
|
+
text = Text.from_markup(f"[cyan]⟳[/cyan] {status} {message}")
|
|
48
|
+
|
|
49
|
+
live.update(Spinner("dots", text=text))
|
|
50
|
+
|
|
51
|
+
if status in ("completed", "complete", "done", "failed", "error", "cancelled"):
|
|
52
|
+
break
|
|
53
|
+
|
|
54
|
+
time.sleep(poll_interval)
|
|
55
|
+
|
|
56
|
+
return data # type: ignore[no-any-return]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_table(
|
|
11
|
+
title: str,
|
|
12
|
+
columns: list[str],
|
|
13
|
+
rows: list[list[Any]],
|
|
14
|
+
console: Console | None = None,
|
|
15
|
+
) -> None:
|
|
16
|
+
c = console or Console()
|
|
17
|
+
table = Table(title=title, show_header=True, header_style="bold cyan")
|
|
18
|
+
for col in columns:
|
|
19
|
+
table.add_column(col)
|
|
20
|
+
for row in rows:
|
|
21
|
+
table.add_row(*[str(v) for v in row])
|
|
22
|
+
c.print(table)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render_kv(
|
|
26
|
+
title: str,
|
|
27
|
+
items: dict[str, Any],
|
|
28
|
+
console: Console | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
c = console or Console()
|
|
31
|
+
lines = []
|
|
32
|
+
for key, value in items.items():
|
|
33
|
+
lines.append(f"[bold]{key}:[/bold] {value}")
|
|
34
|
+
panel = Panel("\n".join(lines), title=title, border_style="cyan")
|
|
35
|
+
c.print(panel)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render_bar(
|
|
39
|
+
label: str,
|
|
40
|
+
value: int | float,
|
|
41
|
+
max_value: int | float = 100,
|
|
42
|
+
console: Console | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
c = console or Console()
|
|
45
|
+
bar_width = 20
|
|
46
|
+
filled = int((value / max_value) * bar_width) if max_value > 0 else 0
|
|
47
|
+
empty = bar_width - filled
|
|
48
|
+
bar = f"[green]{'█' * filled}[/green][dim]{'░' * empty}[/dim]"
|
|
49
|
+
c.print(f" {label:<25} {bar} {value:.0f}%")
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from cited_core.errors import CitedAPIError as CitedAPIError # noqa: F401
|
|
8
|
+
from cited_core.errors import ExitCode as ExitCode # noqa: F401
|
|
9
|
+
from cited_core.errors import exit_code_for_status as exit_code_for_status # noqa: F401
|
|
10
|
+
|
|
11
|
+
err_console = Console(stderr=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def handle_api_error(error: CitedAPIError, json_mode: bool = False) -> None:
|
|
15
|
+
code = exit_code_for_status(error.status_code)
|
|
16
|
+
if json_mode:
|
|
17
|
+
import json
|
|
18
|
+
|
|
19
|
+
err_console.print(
|
|
20
|
+
json.dumps(
|
|
21
|
+
{
|
|
22
|
+
"error": True,
|
|
23
|
+
"status_code": error.status_code,
|
|
24
|
+
"message": error.message,
|
|
25
|
+
"error_code": error.error_code,
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
else:
|
|
30
|
+
err_console.print(f"[red]Error[/red] ({error.status_code}): {error.message}")
|
|
31
|
+
sys.exit(code)
|