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,211 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from cited_cli.output.formatter import OutputContext, print_error, print_result
|
|
9
|
+
from cited_cli.output.tables import render_kv, render_table
|
|
10
|
+
from cited_cli.utils.errors import CitedAPIError, ExitCode, handle_api_error
|
|
11
|
+
from cited_cli.utils.interactive import prompt_if_missing
|
|
12
|
+
from cited_core.api import endpoints
|
|
13
|
+
from cited_core.api.client import LONG_TIMEOUT, CitedClient
|
|
14
|
+
from cited_core.auth.store import TokenStore
|
|
15
|
+
from cited_core.config.manager import ConfigManager
|
|
16
|
+
|
|
17
|
+
recommend_app = typer.Typer(name="recommend", help="Generate and manage recommendations.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_client(ctx: typer.Context) -> tuple[OutputContext, CitedClient]:
|
|
21
|
+
obj = ctx.obj or {}
|
|
22
|
+
out: OutputContext = obj.get("output", OutputContext())
|
|
23
|
+
cfg: ConfigManager = obj.get("config", ConfigManager())
|
|
24
|
+
profile = obj.get("profile", "default")
|
|
25
|
+
env = cfg.get_environment(profile, obj.get("env_override"))
|
|
26
|
+
api_url = cfg.get_api_url(profile, obj.get("env_override"))
|
|
27
|
+
|
|
28
|
+
store = TokenStore()
|
|
29
|
+
token = store.get_token(env)
|
|
30
|
+
if not token:
|
|
31
|
+
print_error(f"Not logged in to {env}. Run: cited login", out)
|
|
32
|
+
raise typer.Exit(ExitCode.AUTH_ERROR)
|
|
33
|
+
|
|
34
|
+
return out, CitedClient(base_url=api_url, token=token)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@recommend_app.command("start")
|
|
38
|
+
def recommend_start(
|
|
39
|
+
ctx: typer.Context,
|
|
40
|
+
audit_job_id: Annotated[
|
|
41
|
+
str | None,
|
|
42
|
+
typer.Argument(help="Audit job ID to generate recommendations from"),
|
|
43
|
+
] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Generate recommendations from an audit."""
|
|
46
|
+
out, client = _get_client(ctx)
|
|
47
|
+
audit_job_id = prompt_if_missing(audit_job_id, "AUDIT_JOB_ID", "Audit job ID", out)
|
|
48
|
+
try:
|
|
49
|
+
data = client.post(
|
|
50
|
+
endpoints.RECOMMEND_START,
|
|
51
|
+
json={"audit_job_id": audit_job_id},
|
|
52
|
+
timeout=LONG_TIMEOUT,
|
|
53
|
+
)
|
|
54
|
+
job_id = data.get("job_id", data.get("id", ""))
|
|
55
|
+
print_result(
|
|
56
|
+
data,
|
|
57
|
+
out,
|
|
58
|
+
human_formatter=lambda d, c: render_kv("Recommendations Started", d, c),
|
|
59
|
+
)
|
|
60
|
+
if not out.json_mode and job_id:
|
|
61
|
+
out.console.print(f"\nTrack progress: [bold]cited job watch {job_id}[/bold]")
|
|
62
|
+
except CitedAPIError as e:
|
|
63
|
+
handle_api_error(e, out.json_mode)
|
|
64
|
+
finally:
|
|
65
|
+
client.close()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@recommend_app.command("status")
|
|
69
|
+
def recommend_status(
|
|
70
|
+
ctx: typer.Context,
|
|
71
|
+
job_id: Annotated[str, typer.Argument(help="Recommendation job ID")],
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Check recommendation job status."""
|
|
74
|
+
out, client = _get_client(ctx)
|
|
75
|
+
try:
|
|
76
|
+
path = endpoints.RECOMMEND_STATUS.format(job_id=job_id)
|
|
77
|
+
data = client.get(path)
|
|
78
|
+
print_result(
|
|
79
|
+
data,
|
|
80
|
+
out,
|
|
81
|
+
human_formatter=lambda d, c: render_kv("Recommendation Status", d, c),
|
|
82
|
+
)
|
|
83
|
+
except CitedAPIError as e:
|
|
84
|
+
handle_api_error(e, out.json_mode)
|
|
85
|
+
finally:
|
|
86
|
+
client.close()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@recommend_app.command("result")
|
|
90
|
+
def recommend_result(
|
|
91
|
+
ctx: typer.Context,
|
|
92
|
+
job_id: Annotated[str, typer.Argument(help="Recommendation job ID")],
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Get recommendation results."""
|
|
95
|
+
out, client = _get_client(ctx)
|
|
96
|
+
try:
|
|
97
|
+
path = endpoints.RECOMMEND_RESULT.format(job_id=job_id)
|
|
98
|
+
data = client.get(path)
|
|
99
|
+
print_result(data, out)
|
|
100
|
+
except CitedAPIError as e:
|
|
101
|
+
handle_api_error(e, out.json_mode)
|
|
102
|
+
finally:
|
|
103
|
+
client.close()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@recommend_app.command("insights")
|
|
107
|
+
def recommend_insights(
|
|
108
|
+
ctx: typer.Context,
|
|
109
|
+
job_id: Annotated[str, typer.Argument(help="Recommendation job ID")],
|
|
110
|
+
) -> None:
|
|
111
|
+
"""List solution-ready insights from a recommendation job."""
|
|
112
|
+
out, client = _get_client(ctx)
|
|
113
|
+
try:
|
|
114
|
+
path = endpoints.RECOMMEND_RESULT.format(job_id=job_id)
|
|
115
|
+
data = client.get(path)
|
|
116
|
+
|
|
117
|
+
def _human(d: object, console: Console) -> None:
|
|
118
|
+
if not isinstance(d, dict):
|
|
119
|
+
console.print(d)
|
|
120
|
+
return
|
|
121
|
+
data: dict[str, Any] = d
|
|
122
|
+
rows: list[list[object]] = []
|
|
123
|
+
|
|
124
|
+
# question_insights: id=question_id, desc=question_text
|
|
125
|
+
for item in data.get("question_insights", []):
|
|
126
|
+
if not isinstance(item, dict):
|
|
127
|
+
continue
|
|
128
|
+
source_id = item.get("question_id", item.get("id", ""))
|
|
129
|
+
desc = str(item.get("question_text", item.get("question", "")))
|
|
130
|
+
rows.append([str(len(rows) + 1), "question_insight", source_id, desc[:60]])
|
|
131
|
+
|
|
132
|
+
# head_to_head_comparisons: id=competitor_domain
|
|
133
|
+
for item in data.get("head_to_head_comparisons", data.get("head_to_head", [])):
|
|
134
|
+
if not isinstance(item, dict):
|
|
135
|
+
continue
|
|
136
|
+
source_id = item.get("competitor_domain", item.get("id", ""))
|
|
137
|
+
desc = str(item.get("competitor_domain", item.get("description", "")))
|
|
138
|
+
rows.append([str(len(rows) + 1), "head_to_head", source_id, desc[:60]])
|
|
139
|
+
|
|
140
|
+
# strengthening_tips: id=category (no uuid), desc=title
|
|
141
|
+
for item in data.get("strengthening_tips", []):
|
|
142
|
+
if not isinstance(item, dict):
|
|
143
|
+
continue
|
|
144
|
+
source_id = item.get("category", item.get("id", item.get("source_id", "")))
|
|
145
|
+
desc = str(item.get("title", item.get("description", "")))
|
|
146
|
+
rows.append([str(len(rows) + 1), "strengthening_tip", source_id, desc[:60]])
|
|
147
|
+
|
|
148
|
+
# priority_actions: id=id or category
|
|
149
|
+
for item in data.get("priority_actions", []):
|
|
150
|
+
if not isinstance(item, dict):
|
|
151
|
+
continue
|
|
152
|
+
source_id = item.get("id", item.get("category", item.get("source_id", "")))
|
|
153
|
+
desc = str(item.get("title", item.get("description", item.get("action", ""))))
|
|
154
|
+
rows.append([str(len(rows) + 1), "priority_action", source_id, desc[:60]])
|
|
155
|
+
|
|
156
|
+
render_table(
|
|
157
|
+
"Available Insights",
|
|
158
|
+
["#", "Type", "Source ID", "Description"],
|
|
159
|
+
rows,
|
|
160
|
+
console,
|
|
161
|
+
)
|
|
162
|
+
if rows:
|
|
163
|
+
console.print(
|
|
164
|
+
"\nRun a solution: [bold]cited solution start "
|
|
165
|
+
f"{job_id} --type <type> --source <source_id>[/bold]"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
print_result(data, out, human_formatter=_human)
|
|
169
|
+
except CitedAPIError as e:
|
|
170
|
+
handle_api_error(e, out.json_mode)
|
|
171
|
+
finally:
|
|
172
|
+
client.close()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@recommend_app.command("list")
|
|
176
|
+
def recommend_list(
|
|
177
|
+
ctx: typer.Context,
|
|
178
|
+
audit_job_id: Annotated[
|
|
179
|
+
str | None,
|
|
180
|
+
typer.Option("--audit", "-a", help="Filter by audit job ID"),
|
|
181
|
+
] = None,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""List recommendation history."""
|
|
184
|
+
out, client = _get_client(ctx)
|
|
185
|
+
try:
|
|
186
|
+
if audit_job_id:
|
|
187
|
+
path = endpoints.RECOMMEND_HISTORY.format(audit_job_id=audit_job_id)
|
|
188
|
+
data = client.get(path)
|
|
189
|
+
else:
|
|
190
|
+
data = client.get("/recommendations/history/bulk")
|
|
191
|
+
recs = (
|
|
192
|
+
data if isinstance(data, list)
|
|
193
|
+
else data.get("recommendations", data.get("items", []))
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def _human(d: object, console: Console) -> None:
|
|
197
|
+
items = d if isinstance(d, list) else []
|
|
198
|
+
rows = []
|
|
199
|
+
for r in items:
|
|
200
|
+
rows.append([
|
|
201
|
+
r.get("job_id", r.get("id", ""))[:8],
|
|
202
|
+
r.get("status", ""),
|
|
203
|
+
r.get("created_at", "")[:19] if r.get("created_at") else "",
|
|
204
|
+
])
|
|
205
|
+
render_table("Recommendations", ["Job ID", "Status", "Created"], rows, console)
|
|
206
|
+
|
|
207
|
+
print_result(recs, out, human_formatter=_human)
|
|
208
|
+
except CitedAPIError as e:
|
|
209
|
+
handle_api_error(e, out.json_mode)
|
|
210
|
+
finally:
|
|
211
|
+
client.close()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from cited_cli.output.formatter import OutputContext, print_error, print_result
|
|
9
|
+
from cited_cli.output.tables import render_kv, render_table
|
|
10
|
+
from cited_cli.utils.errors import CitedAPIError, ExitCode, handle_api_error
|
|
11
|
+
from cited_cli.utils.interactive import prompt_choice, prompt_if_missing
|
|
12
|
+
from cited_core.api import endpoints
|
|
13
|
+
from cited_core.api.client import LONG_TIMEOUT, CitedClient
|
|
14
|
+
from cited_core.auth.store import TokenStore
|
|
15
|
+
from cited_core.config.constants import VALID_SOURCE_TYPES
|
|
16
|
+
from cited_core.config.manager import ConfigManager
|
|
17
|
+
|
|
18
|
+
solution_app = typer.Typer(name="solution", help="Generate and manage content solutions.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_client(ctx: typer.Context) -> tuple[OutputContext, CitedClient, str]:
|
|
22
|
+
obj = ctx.obj or {}
|
|
23
|
+
out: OutputContext = obj.get("output", OutputContext())
|
|
24
|
+
cfg: ConfigManager = obj.get("config", ConfigManager())
|
|
25
|
+
profile = obj.get("profile", "default")
|
|
26
|
+
env = cfg.get_environment(profile, obj.get("env_override"))
|
|
27
|
+
api_url = cfg.get_api_url(profile, obj.get("env_override"))
|
|
28
|
+
|
|
29
|
+
store = TokenStore()
|
|
30
|
+
token = store.get_token(env)
|
|
31
|
+
if not token:
|
|
32
|
+
print_error(f"Not logged in to {env}. Run: cited login", out)
|
|
33
|
+
raise typer.Exit(ExitCode.AUTH_ERROR)
|
|
34
|
+
|
|
35
|
+
return out, CitedClient(base_url=api_url, token=token), env
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@solution_app.command("start")
|
|
39
|
+
def solution_start(
|
|
40
|
+
ctx: typer.Context,
|
|
41
|
+
recommendation_job_id: Annotated[
|
|
42
|
+
str | None,
|
|
43
|
+
typer.Argument(help="Recommendation job ID"),
|
|
44
|
+
] = None,
|
|
45
|
+
source_type: Annotated[
|
|
46
|
+
str | None,
|
|
47
|
+
typer.Option(
|
|
48
|
+
"--type", "-t",
|
|
49
|
+
help="Source type: question_insight, head_to_head, strengthening_tip, priority_action",
|
|
50
|
+
),
|
|
51
|
+
] = None,
|
|
52
|
+
source_id: Annotated[
|
|
53
|
+
str | None,
|
|
54
|
+
typer.Option("--source", "-s", help="Source ID from 'cited recommend insights'"),
|
|
55
|
+
] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Generate a content solution from a recommendation insight."""
|
|
58
|
+
out, client, env = _get_client(ctx)
|
|
59
|
+
recommendation_job_id = prompt_if_missing(
|
|
60
|
+
recommendation_job_id, "RECOMMENDATION_JOB_ID", "Recommendation job ID", out
|
|
61
|
+
)
|
|
62
|
+
source_type = prompt_choice(
|
|
63
|
+
source_type, "--type", "Select source type:", VALID_SOURCE_TYPES, out
|
|
64
|
+
)
|
|
65
|
+
source_id = prompt_if_missing(source_id, "--source", "Source ID", out)
|
|
66
|
+
try:
|
|
67
|
+
data = client.post(
|
|
68
|
+
endpoints.SOLUTION_REQUEST,
|
|
69
|
+
json={
|
|
70
|
+
"recommendation_job_id": recommendation_job_id,
|
|
71
|
+
"source_type": source_type,
|
|
72
|
+
"source_id": source_id,
|
|
73
|
+
},
|
|
74
|
+
timeout=LONG_TIMEOUT,
|
|
75
|
+
)
|
|
76
|
+
job_id = data.get("job_id", data.get("id", ""))
|
|
77
|
+
print_result(data, out, human_formatter=lambda d, c: render_kv("Solution Started", d, c))
|
|
78
|
+
if not out.json_mode and job_id:
|
|
79
|
+
out.console.print(f"\nTrack progress: [bold]cited job watch {job_id}[/bold]")
|
|
80
|
+
web_base = f"https://{env}.youcited.com" if env != "prod" else "https://youcited.com"
|
|
81
|
+
out.console.print(f"View artifacts: [bold]{web_base}/solutions/{job_id}[/bold]")
|
|
82
|
+
except CitedAPIError as e:
|
|
83
|
+
handle_api_error(e, out.json_mode)
|
|
84
|
+
finally:
|
|
85
|
+
client.close()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@solution_app.command("status")
|
|
89
|
+
def solution_status(
|
|
90
|
+
ctx: typer.Context,
|
|
91
|
+
job_id: Annotated[str, typer.Argument(help="Solution job ID")],
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Check solution job status."""
|
|
94
|
+
out, client, _ = _get_client(ctx)
|
|
95
|
+
try:
|
|
96
|
+
path = endpoints.SOLUTION_STATUS.format(job_id=job_id)
|
|
97
|
+
data = client.get(path)
|
|
98
|
+
print_result(data, out, human_formatter=lambda d, c: render_kv("Solution Status", d, c))
|
|
99
|
+
except CitedAPIError as e:
|
|
100
|
+
handle_api_error(e, out.json_mode)
|
|
101
|
+
finally:
|
|
102
|
+
client.close()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@solution_app.command("result")
|
|
106
|
+
def solution_result(
|
|
107
|
+
ctx: typer.Context,
|
|
108
|
+
job_id: Annotated[str, typer.Argument(help="Solution job ID")],
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Get solution results."""
|
|
111
|
+
out, client, _ = _get_client(ctx)
|
|
112
|
+
try:
|
|
113
|
+
path = endpoints.SOLUTION_RESULT.format(job_id=job_id)
|
|
114
|
+
data = client.get(path)
|
|
115
|
+
print_result(data, out)
|
|
116
|
+
except CitedAPIError as e:
|
|
117
|
+
handle_api_error(e, out.json_mode)
|
|
118
|
+
finally:
|
|
119
|
+
client.close()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@solution_app.command("list")
|
|
123
|
+
def solution_list(
|
|
124
|
+
ctx: typer.Context,
|
|
125
|
+
business_id: Annotated[
|
|
126
|
+
str | None,
|
|
127
|
+
typer.Option("--business", "-b", help="Filter by business ID"),
|
|
128
|
+
] = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""List solution history."""
|
|
131
|
+
out, client, _ = _get_client(ctx)
|
|
132
|
+
try:
|
|
133
|
+
params = {}
|
|
134
|
+
if business_id:
|
|
135
|
+
params["business_id"] = business_id
|
|
136
|
+
data = client.get(endpoints.SOLUTION_HISTORY, params=params or None)
|
|
137
|
+
solutions = data if isinstance(data, list) else data.get("solutions", data.get("items", []))
|
|
138
|
+
|
|
139
|
+
def _human(d: object, console: Console) -> None:
|
|
140
|
+
items = d if isinstance(d, list) else []
|
|
141
|
+
rows = []
|
|
142
|
+
for s in items:
|
|
143
|
+
rows.append([
|
|
144
|
+
s.get("job_id", s.get("id", ""))[:8],
|
|
145
|
+
s.get("status", ""),
|
|
146
|
+
s.get("created_at", "")[:19] if s.get("created_at") else "",
|
|
147
|
+
])
|
|
148
|
+
render_table("Solutions", ["Job ID", "Status", "Created"], rows, console)
|
|
149
|
+
|
|
150
|
+
print_result(solutions, out, human_formatter=_human)
|
|
151
|
+
except CitedAPIError as e:
|
|
152
|
+
handle_api_error(e, out.json_mode)
|
|
153
|
+
finally:
|
|
154
|
+
client.close()
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
ENVIRONMENTS: dict[str, str] = {
|
|
4
|
+
"prod": "https://api.youcited.com",
|
|
5
|
+
"dev": "https://dev.youcited.com",
|
|
6
|
+
"local": "http://localhost:8000",
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
FRONTEND_URLS: dict[str, str] = {
|
|
10
|
+
"prod": "https://app.youcited.com",
|
|
11
|
+
"dev": "https://dev.youcited.com",
|
|
12
|
+
"local": "http://localhost:3000",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
DEFAULT_ENV = "prod"
|
|
16
|
+
CONFIG_DIR = Path.home() / ".cited"
|
|
17
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
18
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
19
|
+
KEYRING_SERVICE = "cited-cli"
|
|
20
|
+
|
|
21
|
+
VALID_INDUSTRIES = [
|
|
22
|
+
"automotive",
|
|
23
|
+
"beauty",
|
|
24
|
+
"consulting",
|
|
25
|
+
"education",
|
|
26
|
+
"entertainment",
|
|
27
|
+
"finance",
|
|
28
|
+
"fitness",
|
|
29
|
+
"government",
|
|
30
|
+
"healthcare",
|
|
31
|
+
"home_services",
|
|
32
|
+
"hospitality",
|
|
33
|
+
"legal",
|
|
34
|
+
"manufacturing",
|
|
35
|
+
"non_profit",
|
|
36
|
+
"real_estate",
|
|
37
|
+
"restaurant",
|
|
38
|
+
"retail",
|
|
39
|
+
"technology",
|
|
40
|
+
"other",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
VALID_SOURCE_TYPES = [
|
|
44
|
+
"question_insight",
|
|
45
|
+
"head_to_head",
|
|
46
|
+
"strengthening_tip",
|
|
47
|
+
"priority_action",
|
|
48
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import tomli_w
|
|
7
|
+
|
|
8
|
+
from cited_cli.config.constants import CONFIG_DIR, CONFIG_FILE, DEFAULT_ENV, ENVIRONMENTS
|
|
9
|
+
|
|
10
|
+
VALID_KEYS = {
|
|
11
|
+
"environment",
|
|
12
|
+
"default_business_id",
|
|
13
|
+
"agent_api_key",
|
|
14
|
+
"output",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
VALID_OUTPUT_VALUES = {"json", "text"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigManager:
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._ensure_config_dir()
|
|
23
|
+
self._data: dict[str, Any] = self._load()
|
|
24
|
+
|
|
25
|
+
def _ensure_config_dir(self) -> None:
|
|
26
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
def _load(self) -> dict[str, Any]:
|
|
29
|
+
if not CONFIG_FILE.exists():
|
|
30
|
+
return {"default": {}}
|
|
31
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
32
|
+
return tomllib.load(f)
|
|
33
|
+
|
|
34
|
+
def _save(self) -> None:
|
|
35
|
+
with open(CONFIG_FILE, "wb") as f:
|
|
36
|
+
tomli_w.dump(self._data, f)
|
|
37
|
+
|
|
38
|
+
def get(self, key: str, profile: str = "default") -> Any:
|
|
39
|
+
section = self._data.get(profile, self._data.get("default", {}))
|
|
40
|
+
return section.get(key)
|
|
41
|
+
|
|
42
|
+
def set(self, key: str, value: str, profile: str = "default") -> None:
|
|
43
|
+
if profile not in self._data:
|
|
44
|
+
self._data[profile] = {}
|
|
45
|
+
self._data[profile][key] = value
|
|
46
|
+
self._save()
|
|
47
|
+
|
|
48
|
+
def delete(self, key: str, profile: str = "default") -> bool:
|
|
49
|
+
section = self._data.get(profile, {})
|
|
50
|
+
if key in section:
|
|
51
|
+
del section[key]
|
|
52
|
+
self._save()
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
def get_all(self, profile: str = "default") -> dict[str, Any]:
|
|
57
|
+
return dict(self._data.get(profile, {}))
|
|
58
|
+
|
|
59
|
+
def get_environment(self, profile: str = "default", override: str | None = None) -> str:
|
|
60
|
+
if override:
|
|
61
|
+
return override
|
|
62
|
+
return self.get("environment", profile) or DEFAULT_ENV
|
|
63
|
+
|
|
64
|
+
def get_api_url(self, profile: str = "default", env_override: str | None = None) -> str:
|
|
65
|
+
env = self.get_environment(profile, env_override)
|
|
66
|
+
return ENVIRONMENTS.get(env, ENVIRONMENTS[DEFAULT_ENV])
|
|
67
|
+
|
|
68
|
+
def get_profiles(self) -> list[str]:
|
|
69
|
+
return [k for k in self._data if k != "default" and isinstance(self._data[k], dict)]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
cited_cli/mcp/context.py
ADDED
cited_cli/mcp/server.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
|
|
7
|
+
from mcp.server.fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from cited_cli.api.client import CitedClient
|
|
10
|
+
from cited_cli.auth.store import TokenStore
|
|
11
|
+
from cited_cli.config.manager import ConfigManager
|
|
12
|
+
from cited_cli.mcp.context import CitedContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@asynccontextmanager
|
|
16
|
+
async def cited_lifespan(server: FastMCP) -> AsyncIterator[CitedContext]:
|
|
17
|
+
"""Set up CitedClient for the MCP server lifetime."""
|
|
18
|
+
config = ConfigManager()
|
|
19
|
+
env_override = os.environ.get("CITED_ENV")
|
|
20
|
+
env = config.get_environment(override=env_override)
|
|
21
|
+
api_url = config.get_api_url(env_override=env_override)
|
|
22
|
+
|
|
23
|
+
token = TokenStore().get_token(env)
|
|
24
|
+
agent_api_key = os.environ.get("CITED_AGENT_API_KEY") or config.get("agent_api_key")
|
|
25
|
+
|
|
26
|
+
client = CitedClient(
|
|
27
|
+
base_url=api_url,
|
|
28
|
+
token=token,
|
|
29
|
+
agent_api_key=agent_api_key,
|
|
30
|
+
)
|
|
31
|
+
try:
|
|
32
|
+
yield CitedContext(client=client, env=env, api_url=api_url)
|
|
33
|
+
finally:
|
|
34
|
+
client.close()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
mcp = FastMCP(
|
|
38
|
+
"cited",
|
|
39
|
+
instructions="Cited GEO platform — audit, optimize, and monitor AI search presence",
|
|
40
|
+
lifespan=cited_lifespan,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Import tool modules to trigger @mcp.tool() registrations
|
|
44
|
+
import cited_cli.mcp.tools.auth # noqa: I001, E402, F401
|
|
45
|
+
import cited_cli.mcp.tools.audit # noqa: E402, F401
|
|
46
|
+
import cited_cli.mcp.tools.business # noqa: E402, F401
|
|
47
|
+
import cited_cli.mcp.tools.job # noqa: E402, F401
|
|
48
|
+
import cited_cli.mcp.tools.recommend # noqa: E402, F401
|
|
49
|
+
import cited_cli.mcp.tools.solution # noqa: E402, F401
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_server() -> None:
|
|
53
|
+
"""Start the MCP server on stdio transport."""
|
|
54
|
+
mcp.run(transport="stdio")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import Context
|
|
6
|
+
|
|
7
|
+
from cited_cli.mcp.context import CitedContext
|
|
8
|
+
from cited_cli.utils.errors import CitedAPIError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_ctx(ctx: Context[Any, CitedContext, Any]) -> CitedContext:
|
|
12
|
+
"""Extract CitedContext from MCP lifespan context."""
|
|
13
|
+
lc: CitedContext = ctx.request_context.lifespan_context
|
|
14
|
+
return lc
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _auth_check(cited_ctx: CitedContext) -> dict[str, Any] | None:
|
|
18
|
+
"""Return error dict if not authenticated, else None."""
|
|
19
|
+
if cited_ctx.client.token is None:
|
|
20
|
+
return {
|
|
21
|
+
"error": True,
|
|
22
|
+
"message": "Not authenticated. Run 'cited login' first.",
|
|
23
|
+
}
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _api_error_response(e: CitedAPIError) -> dict[str, Any]:
|
|
28
|
+
"""Convert CitedAPIError to a structured error dict."""
|
|
29
|
+
return {
|
|
30
|
+
"error": True,
|
|
31
|
+
"status_code": e.status_code,
|
|
32
|
+
"message": e.message,
|
|
33
|
+
}
|