xg-cli 1.0__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.
- xg/__init__.py +3 -0
- xg/__main__.py +3 -0
- xg/adaptive/__init__.py +27 -0
- xg/adaptive/calibrate.py +191 -0
- xg/adaptive/feedback.py +170 -0
- xg/adaptive/learned_rules.py +284 -0
- xg/adaptive/signals.py +125 -0
- xg/adaptive/store.py +164 -0
- xg/agent/__init__.py +0 -0
- xg/agent/plan.py +631 -0
- xg/agent/react.py +268 -0
- xg/agent/team.py +1793 -0
- xg/assets/router.lgb +0 -0
- xg/assets/router_semantics.json +21278 -0
- xg/assets/router_semantics.onnx +0 -0
- xg/cli/__init__.py +0 -0
- xg/cli/app.py +1372 -0
- xg/cli/commands.py +921 -0
- xg/cli/completion.py +740 -0
- xg/cli/help.py +185 -0
- xg/cli/train.py +200 -0
- xg/config/__init__.py +0 -0
- xg/config/env_writer.py +121 -0
- xg/config/manager.py +446 -0
- xg/config/mcp.py +229 -0
- xg/config/provider_service.py +267 -0
- xg/config/providers.py +46 -0
- xg/config/settings.py +258 -0
- xg/config/skills.py +100 -0
- xg/config/smart_router_service.py +121 -0
- xg/config/web.py +140 -0
- xg/input_history/__init__.py +7 -0
- xg/input_history/models.py +28 -0
- xg/input_history/persistence.py +126 -0
- xg/input_history/policy.py +39 -0
- xg/input_history/prompt_toolkit.py +36 -0
- xg/input_history/store.py +118 -0
- xg/llm/__init__.py +0 -0
- xg/llm/client.py +49 -0
- xg/llm/factory.py +40 -0
- xg/llm/openai_compat.py +275 -0
- xg/llm/types.py +98 -0
- xg/mcp/__init__.py +4 -0
- xg/mcp/http.py +192 -0
- xg/mcp/manager.py +726 -0
- xg/mcp/models.py +86 -0
- xg/mcp/protocol.py +62 -0
- xg/mcp/resources.py +72 -0
- xg/mcp/schema.py +137 -0
- xg/mcp/stdio.py +210 -0
- xg/mcp/transport.py +66 -0
- xg/memory/__init__.py +15 -0
- xg/memory/context.py +327 -0
- xg/memory/manager.py +111 -0
- xg/memory/models.py +41 -0
- xg/memory/project.py +187 -0
- xg/memory/store.py +144 -0
- xg/router/__init__.py +124 -0
- xg/router/features.py +66 -0
- xg/router/keywords.py +50 -0
- xg/router/ml_router.py +178 -0
- xg/router/model_tiers.py +73 -0
- xg/router/postprocess.py +167 -0
- xg/router/rule_router.py +77 -0
- xg/router/semantic.py +138 -0
- xg/safety/__init__.py +0 -0
- xg/safety/audit.py +96 -0
- xg/safety/guards.py +106 -0
- xg/safety/hitl.py +73 -0
- xg/skill/__init__.py +9 -0
- xg/skill/errors.py +45 -0
- xg/skill/loader.py +42 -0
- xg/skill/models.py +57 -0
- xg/skill/parser.py +93 -0
- xg/skill/policy.py +40 -0
- xg/skill/prompt.py +45 -0
- xg/skill/registry.py +169 -0
- xg/tool/__init__.py +0 -0
- xg/tool/builtin.py +356 -0
- xg/tool/registry.py +228 -0
- xg/tui/__init__.py +34 -0
- xg/tui/app.py +612 -0
- xg/tui/controller.py +1296 -0
- xg/tui/diagrams/__init__.py +22 -0
- xg/tui/diagrams/layout.py +110 -0
- xg/tui/diagrams/markdown.py +39 -0
- xg/tui/diagrams/model.py +36 -0
- xg/tui/diagrams/parser.py +119 -0
- xg/tui/diagrams/renderer.py +551 -0
- xg/tui/i18n.py +169 -0
- xg/tui/messages.py +45 -0
- xg/tui/plan_renderables.py +147 -0
- xg/tui/reducer.py +1029 -0
- xg/tui/renderables.py +252 -0
- xg/tui/state.py +240 -0
- xg/tui/theme.tcss +208 -0
- xg/tui/widgets/__init__.py +1 -0
- xg/tui/widgets/action_card.py +94 -0
- xg/tui/widgets/agent_group_card.py +39 -0
- xg/tui/widgets/approval_modal.py +59 -0
- xg/tui/widgets/collapsible_card.py +40 -0
- xg/tui/widgets/command_suggestions.py +128 -0
- xg/tui/widgets/composer.py +151 -0
- xg/tui/widgets/config_panel.py +198 -0
- xg/tui/widgets/confirm_modal.py +31 -0
- xg/tui/widgets/footer.py +9 -0
- xg/tui/widgets/header.py +118 -0
- xg/tui/widgets/inspector.py +378 -0
- xg/tui/widgets/plan_modal.py +53 -0
- xg/tui/widgets/provider_form.py +141 -0
- xg/tui/widgets/queue_status.py +30 -0
- xg/tui/widgets/smart_router_form.py +95 -0
- xg/tui/widgets/transcript.py +354 -0
- xg/tui/workers.py +14 -0
- xg/web/__init__.py +21 -0
- xg/web/errors.py +57 -0
- xg/web/extract.py +127 -0
- xg/web/fetch.py +106 -0
- xg/web/markdown.py +77 -0
- xg/web/models.py +91 -0
- xg/web/providers.py +79 -0
- xg/web/search.py +118 -0
- xg/web/searxng.py +27 -0
- xg/web/serpapi.py +29 -0
- xg/web/url_policy.py +110 -0
- xg/web/zhipu.py +27 -0
- xg_cli-1.0.dist-info/METADATA +284 -0
- xg_cli-1.0.dist-info/RECORD +130 -0
- xg_cli-1.0.dist-info/WHEEL +4 -0
- xg_cli-1.0.dist-info/entry_points.txt +2 -0
xg/cli/completion.py
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
"""Pure command-completion parsing and token replacement for XG.
|
|
2
|
+
|
|
3
|
+
This is the P0 slice of ``enhancement-v2/07``: it moves the parsing and
|
|
4
|
+
text-replacement logic out of the Textual ``Composer`` widget so it can be
|
|
5
|
+
unit-tested without launching a UI. User-visible behaviour is intentionally
|
|
6
|
+
unchanged in this phase.
|
|
7
|
+
|
|
8
|
+
Later phases (P1-P3) layer subcommand, option and dynamic-value completion on
|
|
9
|
+
top of the data model introduced here.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import fnmatch
|
|
15
|
+
import posixpath
|
|
16
|
+
from collections.abc import Callable, Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
from xg.cli.commands import (
|
|
22
|
+
SLASH_COMMANDS,
|
|
23
|
+
SlashCommandSpec,
|
|
24
|
+
SlashSubcommandSpec,
|
|
25
|
+
filter_slash_commands,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
CompletionKind = Literal["command", "subcommand", "argument", "option", "value", "path"]
|
|
29
|
+
|
|
30
|
+
CompletionValueKind = Literal["text", "enum", "provider", "model", "task", "path", "scope"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Token:
|
|
35
|
+
"""One whitespace-separated token with absolute offsets in the input."""
|
|
36
|
+
|
|
37
|
+
text: str
|
|
38
|
+
start: int
|
|
39
|
+
end: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class CompletionCandidate:
|
|
44
|
+
"""A candidate: what text to insert, where, and how to present it.
|
|
45
|
+
|
|
46
|
+
A candidate only describes text insertion. It never carries tool calls,
|
|
47
|
+
file writes, command execution or any other side effect.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
label: str
|
|
51
|
+
insert_text: str
|
|
52
|
+
detail: str = ""
|
|
53
|
+
kind: CompletionKind = "value"
|
|
54
|
+
sort_key: tuple[int, str] = (0, "")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class CompletionContext:
|
|
59
|
+
"""Parsed view of a composer line, independent of any UI widget."""
|
|
60
|
+
|
|
61
|
+
raw: str
|
|
62
|
+
cursor_position: int
|
|
63
|
+
is_command: bool = False
|
|
64
|
+
command: str = ""
|
|
65
|
+
command_token_start: int = 0
|
|
66
|
+
command_token_end: int = 0
|
|
67
|
+
tokens: tuple[Token, ...] = ()
|
|
68
|
+
current_token: str = ""
|
|
69
|
+
current_token_start: int = 0
|
|
70
|
+
current_token_end: int = 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tokenize(raw: str) -> list[Token]:
|
|
74
|
+
"""Split on whitespace, keeping quoted segments together.
|
|
75
|
+
|
|
76
|
+
Defensive by design: an unterminated quote never raises; the remaining
|
|
77
|
+
text is returned as one trailing token.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
tokens: list[Token] = []
|
|
81
|
+
index = 0
|
|
82
|
+
length = len(raw)
|
|
83
|
+
while index < length:
|
|
84
|
+
while index < length and raw[index].isspace():
|
|
85
|
+
index += 1
|
|
86
|
+
if index >= length:
|
|
87
|
+
break
|
|
88
|
+
start = index
|
|
89
|
+
quote: str | None = None
|
|
90
|
+
while index < length:
|
|
91
|
+
char = raw[index]
|
|
92
|
+
if quote is not None:
|
|
93
|
+
index += 1
|
|
94
|
+
if char == quote:
|
|
95
|
+
quote = None
|
|
96
|
+
continue
|
|
97
|
+
if char in ("'", '"'):
|
|
98
|
+
quote = char
|
|
99
|
+
index += 1
|
|
100
|
+
continue
|
|
101
|
+
if char.isspace():
|
|
102
|
+
break
|
|
103
|
+
index += 1
|
|
104
|
+
tokens.append(Token(raw[start:index], start, index))
|
|
105
|
+
return tokens
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _clamp(value: int, length: int) -> int:
|
|
109
|
+
return max(0, min(length, value))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _current_token(tokens: tuple[Token, ...], cursor: int) -> Token | None:
|
|
113
|
+
"""Return the token under the cursor.
|
|
114
|
+
|
|
115
|
+
The cursor sitting exactly on a token's end boundary belongs to that
|
|
116
|
+
token so that completing right after a word still targets it.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
chosen: Token | None = None
|
|
120
|
+
for token in tokens:
|
|
121
|
+
if token.start <= cursor <= token.end:
|
|
122
|
+
chosen = token
|
|
123
|
+
return chosen
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse_completion_line(raw: str, cursor_position: int | None = None) -> CompletionContext:
|
|
127
|
+
"""Parse a composer line into a non-throwing CompletionContext.
|
|
128
|
+
|
|
129
|
+
A line is a command context when its first token starts with ``/``.
|
|
130
|
+
Partial or malformed input never raises; it simply yields an incomplete
|
|
131
|
+
context that callers can ignore.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
cursor = _clamp(len(raw) if cursor_position is None else cursor_position, len(raw))
|
|
135
|
+
tokens = tuple(tokenize(raw))
|
|
136
|
+
is_command = False
|
|
137
|
+
command = ""
|
|
138
|
+
command_start = 0
|
|
139
|
+
command_end = 0
|
|
140
|
+
if tokens and tokens[0].text.startswith("/"):
|
|
141
|
+
is_command = True
|
|
142
|
+
command = tokens[0].text.lower()
|
|
143
|
+
command_start = tokens[0].start
|
|
144
|
+
command_end = tokens[0].end
|
|
145
|
+
|
|
146
|
+
active = _current_token(tokens, cursor)
|
|
147
|
+
return CompletionContext(
|
|
148
|
+
raw=raw,
|
|
149
|
+
cursor_position=cursor,
|
|
150
|
+
is_command=is_command,
|
|
151
|
+
command=command,
|
|
152
|
+
command_token_start=command_start,
|
|
153
|
+
command_token_end=command_end,
|
|
154
|
+
tokens=tokens,
|
|
155
|
+
current_token=active.text if active else "",
|
|
156
|
+
current_token_start=active.start if active else cursor,
|
|
157
|
+
current_token_end=active.end if active else cursor,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def replace_span(raw: str, start: int, end: int, replacement: str) -> tuple[str, int]:
|
|
162
|
+
"""Return ``(new_text, new_cursor)`` with ``raw[start:end]`` replaced.
|
|
163
|
+
|
|
164
|
+
The returned cursor points to the end of the inserted text.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
new = raw[:start] + replacement + raw[end:]
|
|
168
|
+
return new, start + len(replacement)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def complete_command_token(raw: str, command: str) -> tuple[str, int]:
|
|
172
|
+
"""Replace the leading slash command token with ``command``.
|
|
173
|
+
|
|
174
|
+
Reproduces the exact behaviour the App/Composer used to implement
|
|
175
|
+
inline: leading whitespace and any trailing text after the command are
|
|
176
|
+
preserved, and the final cursor moves to the end of the resulting line.
|
|
177
|
+
Non-command input is returned unchanged.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
ctx = parse_completion_line(raw)
|
|
181
|
+
if not ctx.is_command:
|
|
182
|
+
return raw, len(raw)
|
|
183
|
+
new_text, _ = replace_span(
|
|
184
|
+
raw, ctx.command_token_start, ctx.command_token_end, command
|
|
185
|
+
)
|
|
186
|
+
return new_text, len(new_text)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def sort_candidates(
|
|
190
|
+
candidates: list[CompletionCandidate], *, exact_match: str | None = None
|
|
191
|
+
) -> list[CompletionCandidate]:
|
|
192
|
+
"""Stable sort by sort_key then label; exact matches float to the front.
|
|
193
|
+
|
|
194
|
+
``exact_match`` is the lowercase text that, when it equals a candidate's
|
|
195
|
+
insert label, ranks that candidate first (mirrors the exact-alias-first
|
|
196
|
+
ordering of top-level command filtering).
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
if exact_match is not None:
|
|
200
|
+
exact_match = exact_match.lower()
|
|
201
|
+
return sorted(
|
|
202
|
+
candidates,
|
|
203
|
+
key=lambda cand: (
|
|
204
|
+
0
|
|
205
|
+
if exact_match is not None and cand.insert_text.lower() == exact_match
|
|
206
|
+
else 1,
|
|
207
|
+
cand.sort_key[0],
|
|
208
|
+
cand.sort_key[1] or cand.label,
|
|
209
|
+
cand.label,
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# P1: layered command / subcommand / static-option completion
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _command_spec_or_none(command: str) -> SlashCommandSpec | None:
|
|
220
|
+
"""Return the resolved command spec (name or alias) or None."""
|
|
221
|
+
normalized = command.lower()
|
|
222
|
+
for spec in SLASH_COMMANDS:
|
|
223
|
+
if spec.name.lower() == normalized or any(
|
|
224
|
+
alias.lower() == normalized for alias in spec.aliases
|
|
225
|
+
):
|
|
226
|
+
return spec
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _subcommand_or_none(spec: SlashCommandSpec, token: str) -> SlashSubcommandSpec | None:
|
|
231
|
+
normalized = token.lower()
|
|
232
|
+
for sub in spec.subcommands:
|
|
233
|
+
if sub.name.lower() == normalized:
|
|
234
|
+
return sub
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def completion_candidates(raw: str, cursor_position: int | None = None) -> list[CompletionCandidate]:
|
|
239
|
+
"""Return layered completion candidates for a command line.
|
|
240
|
+
|
|
241
|
+
Top-level commands, subcommands and statically-declared options are
|
|
242
|
+
offered at the correct token, keeping existing top-level behaviour
|
|
243
|
+
compatible. Dynamic values (providers/models/MCP/skills/...) are a later
|
|
244
|
+
phase and intentionally not resolved here.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
if not isinstance(raw, str) or not raw.lstrip().startswith("/"):
|
|
248
|
+
return []
|
|
249
|
+
|
|
250
|
+
ctx = parse_completion_line(raw, cursor_position)
|
|
251
|
+
if not ctx.is_command or not ctx.tokens:
|
|
252
|
+
return []
|
|
253
|
+
|
|
254
|
+
tokens = ctx.tokens
|
|
255
|
+
command_token = ctx.command
|
|
256
|
+
index_of_current = None
|
|
257
|
+
for index, token in enumerate(tokens):
|
|
258
|
+
if token.start <= ctx.cursor_position <= token.end:
|
|
259
|
+
index_of_current = index
|
|
260
|
+
break
|
|
261
|
+
# Cursor sitting on the boundary after the command belongs to the next token.
|
|
262
|
+
if index_of_current is None and ctx.cursor_position >= tokens[0].end:
|
|
263
|
+
index_of_current = len(tokens) # treated as a fresh argument slot
|
|
264
|
+
|
|
265
|
+
spec = _command_spec_or_none(command_token)
|
|
266
|
+
is_command_exact = spec is not None
|
|
267
|
+
|
|
268
|
+
# --- Layer 1: still typing the command token (or nothing after it) ---
|
|
269
|
+
if not is_command_exact or index_of_current in (0, None):
|
|
270
|
+
prefix = tokens[0].text if tokens else raw.strip()
|
|
271
|
+
if is_command_exact:
|
|
272
|
+
# Typing an exact command as its own full token; show subcommands.
|
|
273
|
+
pass
|
|
274
|
+
else:
|
|
275
|
+
specs = filter_slash_commands(prefix)
|
|
276
|
+
return [
|
|
277
|
+
CompletionCandidate(
|
|
278
|
+
spec.name,
|
|
279
|
+
spec.name,
|
|
280
|
+
detail=spec.description,
|
|
281
|
+
kind="command",
|
|
282
|
+
)
|
|
283
|
+
for spec in specs
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
# --- Layer 2: resolve the current token against subcommands / options ---
|
|
287
|
+
if index_of_current is None:
|
|
288
|
+
return []
|
|
289
|
+
|
|
290
|
+
if index_of_current == 0:
|
|
291
|
+
# Exact command typed with no arguments yet -> offer subcommands/options.
|
|
292
|
+
if spec is None:
|
|
293
|
+
return []
|
|
294
|
+
if spec.subcommands:
|
|
295
|
+
return [
|
|
296
|
+
CompletionCandidate(sub.name, sub.name, detail=sub.description, kind="subcommand")
|
|
297
|
+
for sub in spec.subcommands
|
|
298
|
+
]
|
|
299
|
+
return [
|
|
300
|
+
CompletionCandidate(option, option, detail="选项参数", kind="option")
|
|
301
|
+
for option in spec.options
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
if index_of_current == 1:
|
|
305
|
+
# Second token: a subcommand is expected ('' when the slot is blank).
|
|
306
|
+
token = tokens[1].text if len(tokens) > 1 else ""
|
|
307
|
+
if spec is not None and spec.subcommands:
|
|
308
|
+
subs = [sub for sub in spec.subcommands if sub.name.lower().startswith(token.lower())]
|
|
309
|
+
if not subs:
|
|
310
|
+
subs = list(spec.subcommands)
|
|
311
|
+
return [
|
|
312
|
+
CompletionCandidate(sub.name, sub.name, detail=sub.description, kind="subcommand")
|
|
313
|
+
for sub in subs
|
|
314
|
+
]
|
|
315
|
+
if spec is not None and spec.options:
|
|
316
|
+
options = [o for o in spec.options if o.lower().startswith(token.lower())] or list(spec.options)
|
|
317
|
+
return [
|
|
318
|
+
CompletionCandidate(o, o + " ", detail="选项参数", kind="option") for o in options
|
|
319
|
+
]
|
|
320
|
+
return []
|
|
321
|
+
|
|
322
|
+
# --- index_of_current >= 2: arguments / option values ---
|
|
323
|
+
# Detect whether we are typing the *value* of a preceding option (the
|
|
324
|
+
# previous token itself is a declared option name). In that case the
|
|
325
|
+
# static layer must not repeat the option list; dynamic rules own value
|
|
326
|
+
# completion (e.g. ``--write-scope <path>``). Options already typed are
|
|
327
|
+
# also excluded so you never see ``--write-scope`` twice.
|
|
328
|
+
prev_declares_value = False
|
|
329
|
+
if index_of_current >= 1 and index_of_current - 1 < len(tokens):
|
|
330
|
+
prev_text = tokens[index_of_current - 1].text
|
|
331
|
+
declared_options: set[str] = set()
|
|
332
|
+
if spec is not None:
|
|
333
|
+
declared_options.update(spec.options or ())
|
|
334
|
+
if spec.subcommands and len(tokens) >= 2:
|
|
335
|
+
sub_spec = _subcommand_or_none(spec, tokens[1].text)
|
|
336
|
+
if sub_spec is not None:
|
|
337
|
+
declared_options.update(sub_spec.options or ())
|
|
338
|
+
prev_declares_value = prev_text in declared_options
|
|
339
|
+
|
|
340
|
+
# Collect already-written option names so we don't offer them twice. They
|
|
341
|
+
# are the non-current tokens whose text is in declared_options.
|
|
342
|
+
consumed_options: set[str] = set()
|
|
343
|
+
if spec is not None:
|
|
344
|
+
for idx, token in enumerate(tokens):
|
|
345
|
+
if idx == index_of_current:
|
|
346
|
+
continue
|
|
347
|
+
if token.text in declared_options:
|
|
348
|
+
consumed_options.add(token.text)
|
|
349
|
+
|
|
350
|
+
if spec is not None and spec.subcommands and len(tokens) >= 2:
|
|
351
|
+
sub = _subcommand_or_none(spec, tokens[1].text)
|
|
352
|
+
if sub is not None and sub.options and not prev_declares_value:
|
|
353
|
+
current = ctx.current_token if index_of_current < len(tokens) else ""
|
|
354
|
+
options = [o for o in sub.options if o not in consumed_options]
|
|
355
|
+
if current and current.startswith("-"):
|
|
356
|
+
matches = [o for o in options if o.lower().startswith(current.lower())]
|
|
357
|
+
options = matches or options
|
|
358
|
+
return [
|
|
359
|
+
CompletionCandidate(o, o + " ", detail="选项参数", kind="option") for o in options
|
|
360
|
+
]
|
|
361
|
+
if sub is None and tokens[1].text and spec.options and not prev_declares_value:
|
|
362
|
+
options = [o for o in spec.options if o not in consumed_options]
|
|
363
|
+
return [
|
|
364
|
+
CompletionCandidate(o, o + " ", detail="选项参数", kind="option")
|
|
365
|
+
for o in options
|
|
366
|
+
]
|
|
367
|
+
if spec is not None and spec.options and not prev_declares_value:
|
|
368
|
+
options = [o for o in spec.options if o not in consumed_options]
|
|
369
|
+
return [
|
|
370
|
+
CompletionCandidate(o, o + " ", detail="选项参数", kind="option")
|
|
371
|
+
for o in options
|
|
372
|
+
]
|
|
373
|
+
return []
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def apply_completion(
|
|
377
|
+
raw: str, cursor_position: int | None, candidate: CompletionCandidate
|
|
378
|
+
) -> tuple[str, int]:
|
|
379
|
+
"""Apply a candidate to replace the current token; return (value, cursor).
|
|
380
|
+
|
|
381
|
+
Trailing text after the current token is preserved. The cursor is placed
|
|
382
|
+
at the end of the inserted text (mirrors complete_command_token).
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
ctx = parse_completion_line(raw, cursor_position)
|
|
386
|
+
if not ctx.is_command:
|
|
387
|
+
return raw, len(raw)
|
|
388
|
+
start = ctx.current_token_start
|
|
389
|
+
end = ctx.current_token_end
|
|
390
|
+
new_text, _ = replace_span(raw, start, end, candidate.insert_text)
|
|
391
|
+
# Keep the cursor on the inserted token rather than forcing it to the end
|
|
392
|
+
# of the whole line, so any later tokens are preserved (P3 §4.4).
|
|
393
|
+
return new_text, start + len(candidate.insert_text)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ---------------------------------------------------------------------------
|
|
397
|
+
# P2: dynamic local value completion
|
|
398
|
+
#
|
|
399
|
+
# Layered static candidates (commands/subcommands/options) are independent of
|
|
400
|
+
# runtime data. P2 adds local, side-effect-free dynamic values (providers,
|
|
401
|
+
# models, MCP servers, skills, memory ids, team tasks). The engine only knows
|
|
402
|
+
# about a *kind* plus a provider name; the registry resolves names to data,
|
|
403
|
+
# so the engine stays U.I.- and agent-independent and never triggers network
|
|
404
|
+
# I/O or tool execution.
|
|
405
|
+
# ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@dataclass(frozen=True)
|
|
409
|
+
class CompletionArgumentSpec:
|
|
410
|
+
"""Describes one positional argument slot that takes a dynamic value.
|
|
411
|
+
|
|
412
|
+
``kind`` drives presentation; ``value_provider`` names a registered,
|
|
413
|
+
user-agnostic provider (never a user-constructed callable).
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
name: str
|
|
417
|
+
kind: CompletionValueKind = "text"
|
|
418
|
+
value_provider: str = ""
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@dataclass(frozen=True)
|
|
422
|
+
class DynamicArgumentRules:
|
|
423
|
+
"""Declare which dynamic value kinds each subcommand/option consumes.
|
|
424
|
+
|
|
425
|
+
``positionals`` maps a likely subcommand token to the value spec for its
|
|
426
|
+
first argument; ``option_values`` maps ``--option`` to a value spec.
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
positionals: dict[str, CompletionArgumentSpec] = field(default_factory=dict)
|
|
430
|
+
option_values: dict[str, CompletionArgumentSpec] = field(default_factory=dict)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
__DYNAMIC_RULES: dict[str, DynamicArgumentRules] = {
|
|
434
|
+
"/model": DynamicArgumentRules(
|
|
435
|
+
positionals={
|
|
436
|
+
"list": CompletionArgumentSpec("provider", "provider", "provider"),
|
|
437
|
+
"provider": CompletionArgumentSpec("provider", "provider", "provider"),
|
|
438
|
+
"model": CompletionArgumentSpec("model", "model", "model"),
|
|
439
|
+
"": CompletionArgumentSpec("provider", "provider", "provider"),
|
|
440
|
+
}
|
|
441
|
+
),
|
|
442
|
+
"/mcp": DynamicArgumentRules(
|
|
443
|
+
positionals={
|
|
444
|
+
"status": CompletionArgumentSpec("server", "enum"),
|
|
445
|
+
"resources": CompletionArgumentSpec("server", "enum"),
|
|
446
|
+
"restart": CompletionArgumentSpec("server", "enum", "mcp"),
|
|
447
|
+
"logs": CompletionArgumentSpec("server", "enum", "mcp"),
|
|
448
|
+
"enable": CompletionArgumentSpec("server", "enum", "mcp"),
|
|
449
|
+
"disable": CompletionArgumentSpec("server", "enum", "mcp"),
|
|
450
|
+
}
|
|
451
|
+
),
|
|
452
|
+
"/skill": DynamicArgumentRules(
|
|
453
|
+
positionals={
|
|
454
|
+
"load": CompletionArgumentSpec("skill", "enum", "skill"),
|
|
455
|
+
"enable": CompletionArgumentSpec("skill", "enum", "skill"),
|
|
456
|
+
"disable": CompletionArgumentSpec("skill", "enum", "skill"),
|
|
457
|
+
}
|
|
458
|
+
),
|
|
459
|
+
"/memory": DynamicArgumentRules(
|
|
460
|
+
positionals={"delete": CompletionArgumentSpec("id", "text", "memory_id")}
|
|
461
|
+
),
|
|
462
|
+
"/web": DynamicArgumentRules(
|
|
463
|
+
positionals={"search": CompletionArgumentSpec("query", "text")}
|
|
464
|
+
),
|
|
465
|
+
"/team": DynamicArgumentRules(
|
|
466
|
+
option_values={
|
|
467
|
+
"--write-scope": CompletionArgumentSpec("scope", "scope", "team_scope")
|
|
468
|
+
}
|
|
469
|
+
),
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def dynamic_argument_rules(command: str) -> DynamicArgumentRules:
|
|
474
|
+
return __DYNAMIC_RULES.get(command.lower(), DynamicArgumentRules())
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
ProviderGetCandidates = Callable[[CompletionContext], Sequence[CompletionCandidate]]
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class CompletionProviderRegistry:
|
|
481
|
+
"""Resolve provider names to candidate generators.
|
|
482
|
+
|
|
483
|
+
Providers are registered by plain string names. Resolving never raises:
|
|
484
|
+
an unknown provider or a failing provider yields an empty sequence so the
|
|
485
|
+
user can always keep typing manually.
|
|
486
|
+
"""
|
|
487
|
+
|
|
488
|
+
def __init__(self) -> None:
|
|
489
|
+
self._providers: dict[str, ProviderGetCandidates] = {}
|
|
490
|
+
|
|
491
|
+
def register(self, name: str, fn: ProviderGetCandidates) -> None:
|
|
492
|
+
self._providers[name] = fn
|
|
493
|
+
|
|
494
|
+
def candidate(self, ctx: CompletionContext, provider_name: str) -> list[CompletionCandidate]:
|
|
495
|
+
fn = self._providers.get(provider_name)
|
|
496
|
+
if fn is None:
|
|
497
|
+
return []
|
|
498
|
+
try:
|
|
499
|
+
result = fn(ctx)
|
|
500
|
+
except Exception:
|
|
501
|
+
return []
|
|
502
|
+
if result is None:
|
|
503
|
+
return []
|
|
504
|
+
return list(result)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def dynamic_candidates(
|
|
508
|
+
raw: str, cursor_position: int | None, registry: CompletionProviderRegistry, *, limit: int = 20
|
|
509
|
+
) -> list[CompletionCandidate]:
|
|
510
|
+
"""Return capped dynamic candidates for the current argument slot.
|
|
511
|
+
|
|
512
|
+
Only arguments whose declared provider resolves to non-empty values are
|
|
513
|
+
offered. Results are capped to ``limit`` so a huge local store never
|
|
514
|
+
floods the suggestion list. Returns [] for non-command lines.
|
|
515
|
+
"""
|
|
516
|
+
|
|
517
|
+
if not isinstance(raw, str) or not raw.lstrip().startswith("/"):
|
|
518
|
+
return []
|
|
519
|
+
ctx = parse_completion_line(raw, cursor_position)
|
|
520
|
+
if not ctx.is_command or not ctx.tokens:
|
|
521
|
+
return []
|
|
522
|
+
|
|
523
|
+
rules = dynamic_argument_rules(ctx.command)
|
|
524
|
+
spec = _dynamic_slot_spec(ctx, rules)
|
|
525
|
+
if spec is None or not spec.value_provider:
|
|
526
|
+
return []
|
|
527
|
+
candidates = registry.candidate(ctx, spec.value_provider)
|
|
528
|
+
return candidates[:limit]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _dynamic_slot_spec(
|
|
532
|
+
ctx: CompletionContext, rules: DynamicArgumentRules
|
|
533
|
+
) -> CompletionArgumentSpec | None:
|
|
534
|
+
tokens = ctx.tokens
|
|
535
|
+
if len(tokens) < 2:
|
|
536
|
+
return None
|
|
537
|
+
|
|
538
|
+
# Determine the index of the token under the cursor. Boundary at the very
|
|
539
|
+
# end counts as a fresh argument slot after the command.
|
|
540
|
+
current_index = None
|
|
541
|
+
for index, token in enumerate(tokens):
|
|
542
|
+
if token.start <= ctx.cursor_position <= token.end:
|
|
543
|
+
current_index = index
|
|
544
|
+
break
|
|
545
|
+
if current_index is None and ctx.cursor_position >= tokens[0].end:
|
|
546
|
+
current_index = len(tokens)
|
|
547
|
+
if current_index is None:
|
|
548
|
+
current_index = 0
|
|
549
|
+
|
|
550
|
+
# Option value being typed: an option token appears at or before the
|
|
551
|
+
# cursor and declares a dynamic value. e.g. `/team resume t4 --write-scope xg/`
|
|
552
|
+
for idx, token in enumerate(tokens):
|
|
553
|
+
if idx >= current_index or token.text.startswith("--") is False:
|
|
554
|
+
continue
|
|
555
|
+
spec = rules.option_values.get(token.text)
|
|
556
|
+
if spec is not None:
|
|
557
|
+
return spec
|
|
558
|
+
|
|
559
|
+
# Argument slot: the second token may name a declared subcommand whose
|
|
560
|
+
# first value is dynamic. `/mcp restart lo` -> value at index 2.
|
|
561
|
+
# Otherwise the first argument itself is dynamic. `/model dee` -> index 1.
|
|
562
|
+
first_arg = tokens[1].text.lower()
|
|
563
|
+
pos_spec = rules.positionals.get(first_arg)
|
|
564
|
+
if pos_spec is not None:
|
|
565
|
+
return pos_spec if current_index >= 2 else None
|
|
566
|
+
default_spec = rules.positionals.get("")
|
|
567
|
+
if default_spec is not None and current_index >= 1:
|
|
568
|
+
return default_spec
|
|
569
|
+
return None
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# ---------------------------------------------------------------------------
|
|
573
|
+
# P3: workspace path completion
|
|
574
|
+
#
|
|
575
|
+
# Path candidates are workspace-relative, never escape the project root, and
|
|
576
|
+
# never carry execution intent. `allow_patterns` (read/write claim patterns)
|
|
577
|
+
# constrain which prefixes are offered purely as a hint; the runtime repair
|
|
578
|
+
# scope check still decides real write access.
|
|
579
|
+
# ---------------------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
DEFAULT_PATH_DENY_PATTERNS: tuple[str, ...] = (
|
|
582
|
+
".env",
|
|
583
|
+
".env.*",
|
|
584
|
+
"**/.env",
|
|
585
|
+
"**/.env.*",
|
|
586
|
+
"**/*.pem",
|
|
587
|
+
"**/*.key",
|
|
588
|
+
"**/*secret*",
|
|
589
|
+
"**/*credential*",
|
|
590
|
+
"**/*password*",
|
|
591
|
+
".git",
|
|
592
|
+
"**/.git*",
|
|
593
|
+
".xg/memory.db",
|
|
594
|
+
".xg/audit.log",
|
|
595
|
+
)
|
|
596
|
+
DEFAULT_PATH_LIMIT = 30
|
|
597
|
+
DEFAULT_PATH_MAX_DEPTH = 8
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def normalize_path_value(value: str) -> str | None:
|
|
601
|
+
"""Normalize a user path token to a workspace-relative posix string.
|
|
602
|
+
|
|
603
|
+
Returns ``""`` for an effectively-empty value. Returns ``None`` when the
|
|
604
|
+
value would escape the workspace (``..`` traversal). Leading slashes are
|
|
605
|
+
treated as workspace-relative (slash-command convention). Quotes are
|
|
606
|
+
stripped; ``.``/``./`` become ``""``.
|
|
607
|
+
"""
|
|
608
|
+
cleaned = (value or "").strip().strip("\"'")
|
|
609
|
+
if cleaned in ("", ".", "./", ".\\"):
|
|
610
|
+
return ""
|
|
611
|
+
cleaned = cleaned.lstrip("/").lstrip("\\")
|
|
612
|
+
if ":" in cleaned: # reject windows drive / URL-ish values in path slots
|
|
613
|
+
return None
|
|
614
|
+
parts = [part for part in cleaned.replace("\\", "/").split("/") if part not in ("", ".")]
|
|
615
|
+
if ".." in parts:
|
|
616
|
+
return None
|
|
617
|
+
return "/".join(parts)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _path_claim_match(value: str, pattern: str) -> bool:
|
|
621
|
+
"""Mirror Team's ``_claim_matches`` so scope hints reuse the same matching."""
|
|
622
|
+
if fnmatch.fnmatch(value, pattern) or (
|
|
623
|
+
pattern.startswith("**/") and fnmatch.fnmatch(value, pattern[3:])
|
|
624
|
+
):
|
|
625
|
+
return True
|
|
626
|
+
prefix = pattern.rstrip("/*").rstrip("/")
|
|
627
|
+
if prefix and (value == prefix or value.startswith(prefix + "/")):
|
|
628
|
+
return True
|
|
629
|
+
return fnmatch.fnmatch(value, pattern.rstrip("/") + "/**")
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _path_is_denied(name: str, rel: str, deny_patterns: Sequence[str]) -> bool:
|
|
633
|
+
return any(
|
|
634
|
+
pattern == name
|
|
635
|
+
or pattern == rel
|
|
636
|
+
or (pattern.rstrip("/") and (rel.startswith(pattern.rstrip("/") + "/") or rel == pattern.rstrip("/")))
|
|
637
|
+
or fnmatch.fnmatch(name, pattern)
|
|
638
|
+
or fnmatch.fnmatch(rel, pattern)
|
|
639
|
+
for pattern in deny_patterns
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def enumerate_workspace_names(
|
|
644
|
+
root: Path,
|
|
645
|
+
rel: str,
|
|
646
|
+
*,
|
|
647
|
+
allow_patterns: Sequence[str] = (),
|
|
648
|
+
deny_patterns: Sequence[str] = DEFAULT_PATH_DENY_PATTERNS,
|
|
649
|
+
limit: int = DEFAULT_PATH_LIMIT,
|
|
650
|
+
max_depth: int = DEFAULT_PATH_MAX_DEPTH,
|
|
651
|
+
) -> list[str]:
|
|
652
|
+
"""List workspace-relative names the current path prefix could expand to.
|
|
653
|
+
|
|
654
|
+
The prefix ``rel`` is workspace-relative. The directory being listed is
|
|
655
|
+
resolved and re-checked against the workspace root so symlinks or
|
|
656
|
+
nonexistent paths cannot move results outside the project. Results are
|
|
657
|
+
depth- and count-limited. ``allow_patterns`` (if any) only *hint* which
|
|
658
|
+
prefixes are offered; it never authorises anything.
|
|
659
|
+
"""
|
|
660
|
+
rel = rel or ""
|
|
661
|
+
if rel.count("/") >= max_depth:
|
|
662
|
+
return []
|
|
663
|
+
search_rel, _, name_prefix = rel.rpartition("/")
|
|
664
|
+
if rel.endswith("/"):
|
|
665
|
+
search_rel = rel[:-1]
|
|
666
|
+
name_prefix = ""
|
|
667
|
+
root = root.resolve()
|
|
668
|
+
try:
|
|
669
|
+
search_dir = (root / search_rel).resolve() if search_rel else root
|
|
670
|
+
except OSError:
|
|
671
|
+
return []
|
|
672
|
+
try:
|
|
673
|
+
search_dir.relative_to(root)
|
|
674
|
+
except ValueError:
|
|
675
|
+
return []
|
|
676
|
+
if not search_dir.is_dir():
|
|
677
|
+
return []
|
|
678
|
+
try:
|
|
679
|
+
entries = sorted(search_dir.iterdir(), key=lambda entry: entry.name)
|
|
680
|
+
except OSError:
|
|
681
|
+
return []
|
|
682
|
+
results: list[str] = []
|
|
683
|
+
for entry in entries:
|
|
684
|
+
name = entry.name
|
|
685
|
+
if name.startswith(".") and not (name_prefix.startswith(name)):
|
|
686
|
+
continue
|
|
687
|
+
if name_prefix and not name.startswith(name_prefix):
|
|
688
|
+
continue
|
|
689
|
+
entry_rel = f"{search_rel}/{name}" if search_rel else name
|
|
690
|
+
if entry.is_dir():
|
|
691
|
+
entry_rel += "/"
|
|
692
|
+
if _path_is_denied(name, entry_rel.rstrip("/"), deny_patterns):
|
|
693
|
+
continue
|
|
694
|
+
if allow_patterns and not any(
|
|
695
|
+
_path_claim_match(entry_rel.rstrip("/"), pattern) for pattern in allow_patterns
|
|
696
|
+
):
|
|
697
|
+
continue
|
|
698
|
+
results.append(entry_rel)
|
|
699
|
+
if len(results) >= limit:
|
|
700
|
+
break
|
|
701
|
+
return results
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def path_completion_candidates(
|
|
705
|
+
raw: str,
|
|
706
|
+
cursor_position: int | None,
|
|
707
|
+
workspace_root: Path,
|
|
708
|
+
*,
|
|
709
|
+
allow_patterns: Sequence[str] = (),
|
|
710
|
+
deny_patterns: Sequence[str] = DEFAULT_PATH_DENY_PATTERNS,
|
|
711
|
+
limit: int = DEFAULT_PATH_LIMIT,
|
|
712
|
+
) -> list[CompletionCandidate]:
|
|
713
|
+
"""Return workspace-relative path candidates for the current token."""
|
|
714
|
+
if not (isinstance(raw, str) and raw.lstrip().startswith("/")):
|
|
715
|
+
return []
|
|
716
|
+
ctx = parse_completion_line(raw, cursor_position)
|
|
717
|
+
if not ctx.is_command or not ctx.tokens:
|
|
718
|
+
return []
|
|
719
|
+
raw_token = (ctx.current_token or "").strip().strip("\"'")
|
|
720
|
+
rel = normalize_path_value(ctx.current_token)
|
|
721
|
+
if rel is None:
|
|
722
|
+
return []
|
|
723
|
+
# Preserve the user's intent to list a directory's children: if they typed
|
|
724
|
+
# a trailing slash (``xg/auth/`` vs ``xg/auth``), enumeration must descend
|
|
725
|
+
# into that directory instead of treating the name as a prefix filter on
|
|
726
|
+
# the parent listing. normalize_path_value strips the trailing slash so we
|
|
727
|
+
# re-attach it purely as a listing hint (it never affects claim matching).
|
|
728
|
+
had_trailing_sep = rel and (raw_token.endswith("/") or raw_token.endswith("\\"))
|
|
729
|
+
if had_trailing_sep:
|
|
730
|
+
rel += "/"
|
|
731
|
+
names = enumerate_workspace_names(
|
|
732
|
+
workspace_root,
|
|
733
|
+
rel,
|
|
734
|
+
allow_patterns=allow_patterns,
|
|
735
|
+
deny_patterns=deny_patterns,
|
|
736
|
+
limit=limit,
|
|
737
|
+
)
|
|
738
|
+
return [
|
|
739
|
+
CompletionCandidate(name, name, detail="路径", kind="path") for name in names
|
|
740
|
+
]
|