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.
Files changed (130) hide show
  1. xg/__init__.py +3 -0
  2. xg/__main__.py +3 -0
  3. xg/adaptive/__init__.py +27 -0
  4. xg/adaptive/calibrate.py +191 -0
  5. xg/adaptive/feedback.py +170 -0
  6. xg/adaptive/learned_rules.py +284 -0
  7. xg/adaptive/signals.py +125 -0
  8. xg/adaptive/store.py +164 -0
  9. xg/agent/__init__.py +0 -0
  10. xg/agent/plan.py +631 -0
  11. xg/agent/react.py +268 -0
  12. xg/agent/team.py +1793 -0
  13. xg/assets/router.lgb +0 -0
  14. xg/assets/router_semantics.json +21278 -0
  15. xg/assets/router_semantics.onnx +0 -0
  16. xg/cli/__init__.py +0 -0
  17. xg/cli/app.py +1372 -0
  18. xg/cli/commands.py +921 -0
  19. xg/cli/completion.py +740 -0
  20. xg/cli/help.py +185 -0
  21. xg/cli/train.py +200 -0
  22. xg/config/__init__.py +0 -0
  23. xg/config/env_writer.py +121 -0
  24. xg/config/manager.py +446 -0
  25. xg/config/mcp.py +229 -0
  26. xg/config/provider_service.py +267 -0
  27. xg/config/providers.py +46 -0
  28. xg/config/settings.py +258 -0
  29. xg/config/skills.py +100 -0
  30. xg/config/smart_router_service.py +121 -0
  31. xg/config/web.py +140 -0
  32. xg/input_history/__init__.py +7 -0
  33. xg/input_history/models.py +28 -0
  34. xg/input_history/persistence.py +126 -0
  35. xg/input_history/policy.py +39 -0
  36. xg/input_history/prompt_toolkit.py +36 -0
  37. xg/input_history/store.py +118 -0
  38. xg/llm/__init__.py +0 -0
  39. xg/llm/client.py +49 -0
  40. xg/llm/factory.py +40 -0
  41. xg/llm/openai_compat.py +275 -0
  42. xg/llm/types.py +98 -0
  43. xg/mcp/__init__.py +4 -0
  44. xg/mcp/http.py +192 -0
  45. xg/mcp/manager.py +726 -0
  46. xg/mcp/models.py +86 -0
  47. xg/mcp/protocol.py +62 -0
  48. xg/mcp/resources.py +72 -0
  49. xg/mcp/schema.py +137 -0
  50. xg/mcp/stdio.py +210 -0
  51. xg/mcp/transport.py +66 -0
  52. xg/memory/__init__.py +15 -0
  53. xg/memory/context.py +327 -0
  54. xg/memory/manager.py +111 -0
  55. xg/memory/models.py +41 -0
  56. xg/memory/project.py +187 -0
  57. xg/memory/store.py +144 -0
  58. xg/router/__init__.py +124 -0
  59. xg/router/features.py +66 -0
  60. xg/router/keywords.py +50 -0
  61. xg/router/ml_router.py +178 -0
  62. xg/router/model_tiers.py +73 -0
  63. xg/router/postprocess.py +167 -0
  64. xg/router/rule_router.py +77 -0
  65. xg/router/semantic.py +138 -0
  66. xg/safety/__init__.py +0 -0
  67. xg/safety/audit.py +96 -0
  68. xg/safety/guards.py +106 -0
  69. xg/safety/hitl.py +73 -0
  70. xg/skill/__init__.py +9 -0
  71. xg/skill/errors.py +45 -0
  72. xg/skill/loader.py +42 -0
  73. xg/skill/models.py +57 -0
  74. xg/skill/parser.py +93 -0
  75. xg/skill/policy.py +40 -0
  76. xg/skill/prompt.py +45 -0
  77. xg/skill/registry.py +169 -0
  78. xg/tool/__init__.py +0 -0
  79. xg/tool/builtin.py +356 -0
  80. xg/tool/registry.py +228 -0
  81. xg/tui/__init__.py +34 -0
  82. xg/tui/app.py +612 -0
  83. xg/tui/controller.py +1296 -0
  84. xg/tui/diagrams/__init__.py +22 -0
  85. xg/tui/diagrams/layout.py +110 -0
  86. xg/tui/diagrams/markdown.py +39 -0
  87. xg/tui/diagrams/model.py +36 -0
  88. xg/tui/diagrams/parser.py +119 -0
  89. xg/tui/diagrams/renderer.py +551 -0
  90. xg/tui/i18n.py +169 -0
  91. xg/tui/messages.py +45 -0
  92. xg/tui/plan_renderables.py +147 -0
  93. xg/tui/reducer.py +1029 -0
  94. xg/tui/renderables.py +252 -0
  95. xg/tui/state.py +240 -0
  96. xg/tui/theme.tcss +208 -0
  97. xg/tui/widgets/__init__.py +1 -0
  98. xg/tui/widgets/action_card.py +94 -0
  99. xg/tui/widgets/agent_group_card.py +39 -0
  100. xg/tui/widgets/approval_modal.py +59 -0
  101. xg/tui/widgets/collapsible_card.py +40 -0
  102. xg/tui/widgets/command_suggestions.py +128 -0
  103. xg/tui/widgets/composer.py +151 -0
  104. xg/tui/widgets/config_panel.py +198 -0
  105. xg/tui/widgets/confirm_modal.py +31 -0
  106. xg/tui/widgets/footer.py +9 -0
  107. xg/tui/widgets/header.py +118 -0
  108. xg/tui/widgets/inspector.py +378 -0
  109. xg/tui/widgets/plan_modal.py +53 -0
  110. xg/tui/widgets/provider_form.py +141 -0
  111. xg/tui/widgets/queue_status.py +30 -0
  112. xg/tui/widgets/smart_router_form.py +95 -0
  113. xg/tui/widgets/transcript.py +354 -0
  114. xg/tui/workers.py +14 -0
  115. xg/web/__init__.py +21 -0
  116. xg/web/errors.py +57 -0
  117. xg/web/extract.py +127 -0
  118. xg/web/fetch.py +106 -0
  119. xg/web/markdown.py +77 -0
  120. xg/web/models.py +91 -0
  121. xg/web/providers.py +79 -0
  122. xg/web/search.py +118 -0
  123. xg/web/searxng.py +27 -0
  124. xg/web/serpapi.py +29 -0
  125. xg/web/url_policy.py +110 -0
  126. xg/web/zhipu.py +27 -0
  127. xg_cli-1.0.dist-info/METADATA +284 -0
  128. xg_cli-1.0.dist-info/RECORD +130 -0
  129. xg_cli-1.0.dist-info/WHEEL +4 -0
  130. 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
+ ]