completion-ai 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ dist/
6
+ build/
7
+ .DS_Store
8
+
9
+ # generated completion artifacts (not source)
10
+ _*
11
+ !compgen_ai/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yangwb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: completion-ai
3
+ Version: 0.1.0
4
+ Summary: LLM-powered zsh completion scaffolding for arbitrary CLIs
5
+ Project-URL: Homepage, https://github.com/Yangeyu/completion-ai
6
+ Project-URL: Repository, https://github.com/Yangeyu/completion-ai
7
+ Project-URL: Issues, https://github.com/Yangeyu/completion-ai/issues
8
+ Author-email: yangwb <binyang617@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,completion,dashscope,llm,qwen,zsh
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Software Development
23
+ Classifier: Topic :: System :: Shells
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: httpx[socks]>=0.27.0
27
+ Requires-Dist: openai>=1.40.0
28
+ Description-Content-Type: text/markdown
29
+
30
+ # completion-ai
31
+
32
+ LLM-powered zsh completion scaffolding for arbitrary CLIs. Point it at any
33
+ command on your `PATH` and it will:
34
+
35
+ 1. Recursively run `<cmd> --help` (and discovered subcommand `--help`s).
36
+ Subcommand discovery is done by the LLM, not regex — so `install [options]`,
37
+ `plugin|plugins`, and other oddly-formatted entries are picked up.
38
+ 2. Ask Qwen (via DashScope OpenAI-compatible API) to extract a structured
39
+ schema of flags, subcommands, and positional arguments.
40
+ 3. Render a `#compdef` zsh completion script using a deterministic template
41
+ (the LLM never writes shell directly).
42
+ 4. Run `zsh -n` against the result and warn on syntax errors.
43
+
44
+ The output is meant to be **reviewed by a human** before installation — it's
45
+ a scaffold, not a runtime completion engine.
46
+
47
+ ## Install
48
+
49
+ Recommended (global CLI, isolated venv managed by uv):
50
+
51
+ ```sh
52
+ uv tool install -e .
53
+ ```
54
+
55
+ Other options:
56
+
57
+ ```sh
58
+ uv pip install -e . # into the current uv venv
59
+ pip install -e . # into the active python (conda, system, ...)
60
+ ```
61
+
62
+ Requires `DASHSCOPE_API_KEY` in the environment.
63
+
64
+ ## Usage
65
+
66
+ ```sh
67
+ completion-ai claude # writes ./_claude
68
+ completion-ai claude --install # install straight into oh-my-zsh
69
+ completion-ai claude -o ~/.zsh/completions/_claude
70
+ completion-ai docker --depth 3 -v # crawl deeper, verbose
71
+ completion-ai gh --dump-schema gh.json # also save intermediate schema
72
+ ```
73
+
74
+ ### Options
75
+
76
+ | Flag | Default | Notes |
77
+ |---|---|---|
78
+ | `-o, --output PATH` | `./_<cmd>` | Where to write the completion script |
79
+ | `-d, --depth N` | `2` | How deep to crawl subcommand help |
80
+ | `--model ID` | `qwen-plus` | Override via `COMPLETION_AI_MODEL` too |
81
+ | `--dump-schema PATH` | — | Also save the JSON the LLM produced |
82
+ | `--no-syntax-check` | off | Skip `zsh -n` validation |
83
+ | `--install` | off | Install into `$ZSH/custom/plugins/completion-ai/` |
84
+ | `-v, --verbose` | off | Progress logs to stderr |
85
+
86
+ ### Environment
87
+
88
+ - `DASHSCOPE_API_KEY` — required
89
+ - `COMPLETION_AI_MODEL` — defaults to `qwen-plus`
90
+ - `COMPLETION_AI_BASE_URL` — defaults to DashScope OpenAI-compatible endpoint
91
+ - `ZSH` — oh-my-zsh root, used by `--install` (defaults to `~/.oh-my-zsh`)
92
+
93
+ ## Installing the generated completion
94
+
95
+ ### Option A — oh-my-zsh users (recommended)
96
+
97
+ ```sh
98
+ completion-ai claude --install
99
+ ```
100
+
101
+ This creates `$ZSH/custom/plugins/completion-ai/_claude` plus a
102
+ plugin stub. **First time only**, add the plugin to `~/.zshrc`:
103
+
104
+ ```zsh
105
+ plugins=(git ... completion-ai)
106
+ ```
107
+
108
+ Then refresh:
109
+
110
+ ```sh
111
+ rm -f ~/.zcompdump* && exec zsh
112
+ ```
113
+
114
+ Subsequent `completion-ai <cmd> --install` calls drop new completions into the
115
+ same plugin directory — no zshrc edits needed.
116
+
117
+ ### Option B — plain zsh
118
+
119
+ ```sh
120
+ completion-ai claude -o ~/.zsh/completions/_claude
121
+
122
+ # in ~/.zshrc (one-time):
123
+ fpath=(~/.zsh/completions $fpath)
124
+ autoload -U compinit && compinit
125
+ ```
126
+
127
+ ## How it works
128
+
129
+ ```
130
+ [crawler] run `<cmd> --help`
131
+
132
+ [llm] discover subcommand names from help text (1 call per node)
133
+
134
+ [crawler] recursively run discovered subcommands' --help
135
+
136
+ [llm] extract structured JSON schema from all help texts (1 call)
137
+
138
+ [renderer] JSON → #compdef zsh template (deterministic, not LLM)
139
+
140
+ [validator] zsh -n syntax check
141
+ ```
142
+
143
+ For `depth=2`, that's **2 LLM calls** total (1 discover + 1 extract).
144
+
145
+ ## Limitations
146
+
147
+ - Static only: dynamic completions (e.g. `git checkout <branch>`) are not
148
+ generated — the script may suggest a hint type (`branch`, `host`, ...) but
149
+ won't query live state.
150
+ - The LLM may miss hidden flags or mislabel value hints. Always diff against
151
+ `<cmd> --help` before trusting.
152
+ - Only zsh for now.
153
+
154
+ ## Development
155
+
156
+ ```sh
157
+ git clone <repo> && cd completion-ai
158
+ uv venv && source .venv/bin/activate
159
+ uv pip install -e .
160
+ ```
161
+
162
+ ## License
163
+
164
+ MIT
@@ -0,0 +1,135 @@
1
+ # completion-ai
2
+
3
+ LLM-powered zsh completion scaffolding for arbitrary CLIs. Point it at any
4
+ command on your `PATH` and it will:
5
+
6
+ 1. Recursively run `<cmd> --help` (and discovered subcommand `--help`s).
7
+ Subcommand discovery is done by the LLM, not regex — so `install [options]`,
8
+ `plugin|plugins`, and other oddly-formatted entries are picked up.
9
+ 2. Ask Qwen (via DashScope OpenAI-compatible API) to extract a structured
10
+ schema of flags, subcommands, and positional arguments.
11
+ 3. Render a `#compdef` zsh completion script using a deterministic template
12
+ (the LLM never writes shell directly).
13
+ 4. Run `zsh -n` against the result and warn on syntax errors.
14
+
15
+ The output is meant to be **reviewed by a human** before installation — it's
16
+ a scaffold, not a runtime completion engine.
17
+
18
+ ## Install
19
+
20
+ Recommended (global CLI, isolated venv managed by uv):
21
+
22
+ ```sh
23
+ uv tool install -e .
24
+ ```
25
+
26
+ Other options:
27
+
28
+ ```sh
29
+ uv pip install -e . # into the current uv venv
30
+ pip install -e . # into the active python (conda, system, ...)
31
+ ```
32
+
33
+ Requires `DASHSCOPE_API_KEY` in the environment.
34
+
35
+ ## Usage
36
+
37
+ ```sh
38
+ completion-ai claude # writes ./_claude
39
+ completion-ai claude --install # install straight into oh-my-zsh
40
+ completion-ai claude -o ~/.zsh/completions/_claude
41
+ completion-ai docker --depth 3 -v # crawl deeper, verbose
42
+ completion-ai gh --dump-schema gh.json # also save intermediate schema
43
+ ```
44
+
45
+ ### Options
46
+
47
+ | Flag | Default | Notes |
48
+ |---|---|---|
49
+ | `-o, --output PATH` | `./_<cmd>` | Where to write the completion script |
50
+ | `-d, --depth N` | `2` | How deep to crawl subcommand help |
51
+ | `--model ID` | `qwen-plus` | Override via `COMPLETION_AI_MODEL` too |
52
+ | `--dump-schema PATH` | — | Also save the JSON the LLM produced |
53
+ | `--no-syntax-check` | off | Skip `zsh -n` validation |
54
+ | `--install` | off | Install into `$ZSH/custom/plugins/completion-ai/` |
55
+ | `-v, --verbose` | off | Progress logs to stderr |
56
+
57
+ ### Environment
58
+
59
+ - `DASHSCOPE_API_KEY` — required
60
+ - `COMPLETION_AI_MODEL` — defaults to `qwen-plus`
61
+ - `COMPLETION_AI_BASE_URL` — defaults to DashScope OpenAI-compatible endpoint
62
+ - `ZSH` — oh-my-zsh root, used by `--install` (defaults to `~/.oh-my-zsh`)
63
+
64
+ ## Installing the generated completion
65
+
66
+ ### Option A — oh-my-zsh users (recommended)
67
+
68
+ ```sh
69
+ completion-ai claude --install
70
+ ```
71
+
72
+ This creates `$ZSH/custom/plugins/completion-ai/_claude` plus a
73
+ plugin stub. **First time only**, add the plugin to `~/.zshrc`:
74
+
75
+ ```zsh
76
+ plugins=(git ... completion-ai)
77
+ ```
78
+
79
+ Then refresh:
80
+
81
+ ```sh
82
+ rm -f ~/.zcompdump* && exec zsh
83
+ ```
84
+
85
+ Subsequent `completion-ai <cmd> --install` calls drop new completions into the
86
+ same plugin directory — no zshrc edits needed.
87
+
88
+ ### Option B — plain zsh
89
+
90
+ ```sh
91
+ completion-ai claude -o ~/.zsh/completions/_claude
92
+
93
+ # in ~/.zshrc (one-time):
94
+ fpath=(~/.zsh/completions $fpath)
95
+ autoload -U compinit && compinit
96
+ ```
97
+
98
+ ## How it works
99
+
100
+ ```
101
+ [crawler] run `<cmd> --help`
102
+
103
+ [llm] discover subcommand names from help text (1 call per node)
104
+
105
+ [crawler] recursively run discovered subcommands' --help
106
+
107
+ [llm] extract structured JSON schema from all help texts (1 call)
108
+
109
+ [renderer] JSON → #compdef zsh template (deterministic, not LLM)
110
+
111
+ [validator] zsh -n syntax check
112
+ ```
113
+
114
+ For `depth=2`, that's **2 LLM calls** total (1 discover + 1 extract).
115
+
116
+ ## Limitations
117
+
118
+ - Static only: dynamic completions (e.g. `git checkout <branch>`) are not
119
+ generated — the script may suggest a hint type (`branch`, `host`, ...) but
120
+ won't query live state.
121
+ - The LLM may miss hidden flags or mislabel value hints. Always diff against
122
+ `<cmd> --help` before trusting.
123
+ - Only zsh for now.
124
+
125
+ ## Development
126
+
127
+ ```sh
128
+ git clone <repo> && cd completion-ai
129
+ uv venv && source .venv/bin/activate
130
+ uv pip install -e .
131
+ ```
132
+
133
+ ## License
134
+
135
+ MIT
@@ -0,0 +1,198 @@
1
+ """completion-ai CLI entry point."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ OMZ_PLUGIN_NAME = "completion-ai"
13
+
14
+ from . import __version__
15
+ from .crawler import crawl
16
+ from .llm import DEFAULT_MODEL, discover_subcommands, extract_schema
17
+ from .renderer import render_zsh
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ p = argparse.ArgumentParser(
22
+ prog="completion-ai",
23
+ description="Generate zsh completion scaffolding for any CLI using an LLM.",
24
+ )
25
+ p.add_argument("command", help="Target CLI command (must be on PATH)")
26
+ p.add_argument(
27
+ "-o", "--output", type=Path, default=None,
28
+ help="Output path (default: ./_<command>)",
29
+ )
30
+ p.add_argument(
31
+ "-d", "--depth", type=int, default=2,
32
+ help="Max subcommand crawl depth (default: 2)",
33
+ )
34
+ p.add_argument(
35
+ "--model", default=DEFAULT_MODEL,
36
+ help=f"LLM model id (default: {DEFAULT_MODEL}; env COMPLETION_AI_MODEL)",
37
+ )
38
+ p.add_argument(
39
+ "--dump-schema", type=Path, default=None,
40
+ help="Also write the intermediate JSON schema here",
41
+ )
42
+ p.add_argument(
43
+ "--no-syntax-check", action="store_true",
44
+ help="Skip the zsh -n syntax validation",
45
+ )
46
+ p.add_argument(
47
+ "--install", action="store_true",
48
+ help=(
49
+ "Install the generated completion into the oh-my-zsh plugin "
50
+ f"'{OMZ_PLUGIN_NAME}' (overrides --output)."
51
+ ),
52
+ )
53
+ p.add_argument("-v", "--verbose", action="store_true")
54
+ p.add_argument("--version", action="version", version=f"completion-ai {__version__}")
55
+ return p
56
+
57
+
58
+ def _syntax_check(script: str) -> tuple[bool, str]:
59
+ with tempfile.NamedTemporaryFile("w", suffix=".zsh", delete=False) as f:
60
+ f.write(script)
61
+ path = f.name
62
+ try:
63
+ r = subprocess.run(
64
+ ["zsh", "-n", path],
65
+ capture_output=True, text=True, timeout=5,
66
+ )
67
+ return r.returncode == 0, (r.stderr or r.stdout).strip()
68
+ except FileNotFoundError:
69
+ return True, "zsh not found, skipping syntax check"
70
+ finally:
71
+ Path(path).unlink(missing_ok=True)
72
+
73
+
74
+ def main(argv: list[str] | None = None) -> int:
75
+ args = build_parser().parse_args(argv)
76
+ cmd = args.command
77
+
78
+ def log(msg: str) -> None:
79
+ if args.verbose:
80
+ print(f"[completion-ai] {msg}", file=sys.stderr)
81
+
82
+ discover_calls = [0]
83
+
84
+ def _discover(help_text: str) -> list[str]:
85
+ discover_calls[0] += 1
86
+ subs = discover_subcommands(help_text, model=args.model)
87
+ log(f"discover #{discover_calls[0]}: {subs}")
88
+ return subs
89
+
90
+ try:
91
+ log(f"crawling `{cmd} --help` (depth={args.depth}, model={args.model})")
92
+ root = crawl(cmd, max_depth=args.depth, discover=_discover)
93
+ except FileNotFoundError as e:
94
+ print(f"error: {e}", file=sys.stderr)
95
+ return 2
96
+ except Exception as e:
97
+ print(f"error: crawl failed: {e}", file=sys.stderr)
98
+ return 3
99
+
100
+ n_nodes = len(root.flatten())
101
+ log(f"collected {n_nodes} help section(s) via {discover_calls[0]} discover call(s)")
102
+
103
+ try:
104
+ log("extracting schema")
105
+ schema = extract_schema(root, model=args.model, verbose=args.verbose)
106
+ except Exception as e:
107
+ print(f"error: LLM call failed: {e}", file=sys.stderr)
108
+ return 3
109
+
110
+ if not schema.get("name"):
111
+ schema["name"] = cmd
112
+
113
+ if args.dump_schema:
114
+ args.dump_schema.write_text(json.dumps(schema, indent=2, ensure_ascii=False))
115
+ log(f"wrote schema -> {args.dump_schema}")
116
+
117
+ script = render_zsh(schema)
118
+
119
+ if not args.no_syntax_check:
120
+ ok, msg = _syntax_check(script)
121
+ if not ok:
122
+ print("warning: generated script failed `zsh -n` check:", file=sys.stderr)
123
+ print(msg, file=sys.stderr)
124
+ print("output still written; please review.", file=sys.stderr)
125
+ else:
126
+ log("zsh -n syntax check passed")
127
+
128
+ if args.install:
129
+ return _install_to_omz(cmd, script, log)
130
+
131
+ out = args.output or Path.cwd() / f"_{cmd}"
132
+ out.write_text(script)
133
+ print(f"wrote {out}")
134
+ print(
135
+ "next steps:\n"
136
+ f" 1. review {out}\n"
137
+ f" 2. move to a directory on $fpath, e.g.:\n"
138
+ f" mkdir -p ~/.zsh/completions && mv {out} ~/.zsh/completions/\n"
139
+ f" # add to ~/.zshrc if not already: fpath=(~/.zsh/completions $fpath)\n"
140
+ f" 3. reload completions: autoload -U compinit && compinit\n"
141
+ "tip: use --install to drop it straight into oh-my-zsh."
142
+ )
143
+ return 0
144
+
145
+
146
+ def _omz_root() -> Path:
147
+ return Path(os.environ.get("ZSH") or Path.home() / ".oh-my-zsh")
148
+
149
+
150
+ def _install_to_omz(cmd: str, script: str, log) -> int:
151
+ omz = _omz_root()
152
+ if not omz.is_dir():
153
+ print(
154
+ f"error: oh-my-zsh not found at {omz}. "
155
+ "Set $ZSH or install oh-my-zsh first.",
156
+ file=sys.stderr,
157
+ )
158
+ return 4
159
+
160
+ plugin_dir = omz / "custom" / "plugins" / OMZ_PLUGIN_NAME
161
+ plugin_dir.mkdir(parents=True, exist_ok=True)
162
+ target = plugin_dir / f"_{cmd}"
163
+ target.write_text(script)
164
+ log(f"installed -> {target}")
165
+
166
+ # Ensure the plugin has an entrypoint file so oh-my-zsh recognizes it.
167
+ stub = plugin_dir / f"{OMZ_PLUGIN_NAME}.plugin.zsh"
168
+ if not stub.exists():
169
+ stub.write_text(
170
+ "# Auto-generated by completion-ai.\n"
171
+ "# Adds this directory to fpath so the _<cmd> files here are picked up.\n"
172
+ "fpath=(${0:A:h} $fpath)\n"
173
+ )
174
+ log(f"created plugin stub -> {stub}")
175
+
176
+ zshrc = Path.home() / ".zshrc"
177
+ plugin_listed = False
178
+ if zshrc.exists():
179
+ try:
180
+ plugin_listed = OMZ_PLUGIN_NAME in zshrc.read_text()
181
+ except OSError:
182
+ pass
183
+
184
+ print(f"installed completion for `{cmd}` -> {target}")
185
+ if not plugin_listed:
186
+ print(
187
+ "\nOne more step: add the plugin to ~/.zshrc, e.g.:\n"
188
+ f" plugins=(... {OMZ_PLUGIN_NAME})\n"
189
+ )
190
+ print(
191
+ "Then reload:\n"
192
+ " rm -f ~/.zcompdump* && exec zsh"
193
+ )
194
+ return 0
195
+
196
+
197
+ if __name__ == "__main__":
198
+ sys.exit(main())
@@ -0,0 +1,61 @@
1
+ """Recursively collect --help text for a target CLI.
2
+
3
+ Subcommand discovery is delegated to a caller-supplied callback (typically an
4
+ LLM call) so this module doesn't need to understand help-format dialects.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import shutil
9
+ import subprocess
10
+ from dataclasses import dataclass, field
11
+ from typing import Callable
12
+
13
+
14
+ @dataclass
15
+ class HelpNode:
16
+ path: list[str]
17
+ help_text: str
18
+ children: list["HelpNode"] = field(default_factory=list)
19
+
20
+ def flatten(self) -> list["HelpNode"]:
21
+ out = [self]
22
+ for c in self.children:
23
+ out.extend(c.flatten())
24
+ return out
25
+
26
+
27
+ DiscoverFn = Callable[[str], list[str]]
28
+
29
+
30
+ def run_help(argv: list[str], timeout: float = 8.0) -> str:
31
+ try:
32
+ r = subprocess.run(
33
+ argv + ["--help"],
34
+ capture_output=True,
35
+ text=True,
36
+ timeout=timeout,
37
+ )
38
+ except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError) as e:
39
+ return f"[completion-ai: failed to run {' '.join(argv)} --help: {e}]"
40
+ return (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
41
+
42
+
43
+ def crawl(cmd: str, max_depth: int, discover: DiscoverFn) -> HelpNode:
44
+ if shutil.which(cmd) is None:
45
+ raise FileNotFoundError(f"command not found on PATH: {cmd}")
46
+ root = HelpNode(path=[cmd], help_text=run_help([cmd]))
47
+ if max_depth > 1:
48
+ _expand(root, max_depth, 1, discover)
49
+ return root
50
+
51
+
52
+ def _expand(node: HelpNode, max_depth: int, depth: int, discover: DiscoverFn) -> None:
53
+ if depth >= max_depth:
54
+ return
55
+ for sub in discover(node.help_text):
56
+ if not sub or not isinstance(sub, str):
57
+ continue
58
+ child_path = node.path + [sub]
59
+ child = HelpNode(path=child_path, help_text=run_help(child_path))
60
+ node.children.append(child)
61
+ _expand(child, max_depth, depth + 1, discover)
@@ -0,0 +1,130 @@
1
+ """Call Qwen (DashScope, OpenAI-compatible) for:
2
+ 1. discover_subcommands - given one help text, list direct subcommand names.
3
+ 2. extract_schema - given the full help tree, return structured CLI schema.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import textwrap
10
+
11
+ from openai import OpenAI
12
+
13
+ from .crawler import HelpNode
14
+
15
+ DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
16
+ DEFAULT_MODEL = os.environ.get("COMPLETION_AI_MODEL", "qwen-plus")
17
+
18
+
19
+ def _client() -> OpenAI:
20
+ api_key = os.environ.get("DASHSCOPE_API_KEY")
21
+ if not api_key:
22
+ raise RuntimeError("DASHSCOPE_API_KEY is not set")
23
+ base_url = os.environ.get("COMPLETION_AI_BASE_URL", DEFAULT_BASE_URL)
24
+ return OpenAI(api_key=api_key, base_url=base_url)
25
+
26
+
27
+ DISCOVER_SYSTEM = textwrap.dedent(
28
+ """
29
+ You read one CLI's --help output and list its direct subcommand names.
30
+
31
+ Output JSON exactly like: {"subcommands": ["name1", "name2"]}
32
+
33
+ Rules:
34
+ - Only direct subcommands (not flags, not nested subcommands).
35
+ - For aliased entries like "plugin|plugins" return the canonical first
36
+ name only ("plugin").
37
+ - Strip placeholder tokens like "[options]", "[args]", "<target>", etc.
38
+ - Skip "help" if listed as a subcommand.
39
+ - If no subcommand section exists, return {"subcommands": []}.
40
+ - Be conservative: only what is explicitly listed.
41
+ - Do not invent commands not present in the help text.
42
+ """
43
+ ).strip()
44
+
45
+
46
+ EXTRACT_SYSTEM = textwrap.dedent(
47
+ """
48
+ You analyze CLI --help output and produce a JSON schema describing the
49
+ command tree, flags, and positional arguments. Be conservative: only
50
+ include things explicitly shown in the help text. Do not invent flags.
51
+
52
+ Output JSON with this exact shape (no prose, no markdown fences):
53
+ {
54
+ "name": "<root command>",
55
+ "description": "<one-line summary>",
56
+ "flags": [
57
+ {"long": "--foo", "short": "-f", "takes_value": true, "value_hint": "file|dir|enum|string", "choices": ["a","b"], "description": "..."}
58
+ ],
59
+ "positionals": [
60
+ {"name": "PATH", "value_hint": "file|dir|string", "description": "...", "repeatable": false}
61
+ ],
62
+ "subcommands": [
63
+ { ... same shape recursively, omit fields not present ... }
64
+ ]
65
+ }
66
+
67
+ Rules:
68
+ - "short" may be omitted if absent. "choices" only when help lists them.
69
+ - "value_hint" must be one of: file, dir, command, host, user, branch,
70
+ string, enum, integer. Use "string" when unsure.
71
+ - Keep descriptions under 80 chars, single line.
72
+ - Omit any field you are unsure about rather than guessing.
73
+ - Include every subcommand whose help text appears in the input, even if
74
+ its own help is sparse.
75
+ """
76
+ ).strip()
77
+
78
+
79
+ def discover_subcommands(help_text: str, model: str = DEFAULT_MODEL) -> list[str]:
80
+ client = _client()
81
+ resp = client.chat.completions.create(
82
+ model=model,
83
+ messages=[
84
+ {"role": "system", "content": DISCOVER_SYSTEM},
85
+ {"role": "user", "content": help_text},
86
+ ],
87
+ temperature=0,
88
+ response_format={"type": "json_object"},
89
+ )
90
+ data = json.loads(resp.choices[0].message.content or "{}")
91
+ raw = data.get("subcommands") or []
92
+ out: list[str] = []
93
+ seen: set[str] = set()
94
+ for s in raw:
95
+ if not isinstance(s, str):
96
+ continue
97
+ name = s.strip().split()[0] if s.strip() else ""
98
+ if name and name not in seen and not name.startswith("-"):
99
+ seen.add(name)
100
+ out.append(name)
101
+ return out
102
+
103
+
104
+ def _serialize_tree(node: HelpNode) -> str:
105
+ parts = []
106
+ for n in node.flatten():
107
+ header = "$ " + " ".join(n.path) + " --help"
108
+ parts.append(f"{header}\n{n.help_text.rstrip()}")
109
+ return "\n\n---\n\n".join(parts)
110
+
111
+
112
+ def extract_schema(root: HelpNode, model: str = DEFAULT_MODEL, verbose: bool = False) -> dict:
113
+ client = _client()
114
+ user_content = (
115
+ f"Root command: {root.path[0]}\n\n"
116
+ f"Help outputs (root + subcommands):\n\n{_serialize_tree(root)}"
117
+ )
118
+ if verbose:
119
+ print(f"[llm] extract model={model} prompt_chars={len(user_content)}")
120
+ resp = client.chat.completions.create(
121
+ model=model,
122
+ messages=[
123
+ {"role": "system", "content": EXTRACT_SYSTEM},
124
+ {"role": "user", "content": user_content},
125
+ ],
126
+ temperature=0.1,
127
+ response_format={"type": "json_object"},
128
+ )
129
+ content = resp.choices[0].message.content or "{}"
130
+ return json.loads(content)
@@ -0,0 +1,141 @@
1
+ """Render extracted schema into a zsh _compdef completion script."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ VALUE_HINT_TO_ZSH = {
7
+ "file": "_files",
8
+ "dir": "_files -/",
9
+ "directory": "_files -/",
10
+ "command": "_command_names",
11
+ "host": "_hosts",
12
+ "user": "_users",
13
+ "branch": "__git_branch_names 2>/dev/null || _message branch",
14
+ "integer": "_message number",
15
+ "string": "",
16
+ "enum": "",
17
+ }
18
+
19
+
20
+ def _zsh_escape(s: str) -> str:
21
+ if s is None:
22
+ return ""
23
+ return s.replace("\\", "\\\\").replace("'", "''").replace("[", "\\[").replace("]", "\\]").replace(":", "\\:")
24
+
25
+
26
+ def _action_for(flag: dict[str, Any]) -> str:
27
+ if not flag.get("takes_value"):
28
+ return ""
29
+ choices = flag.get("choices") or []
30
+ if choices:
31
+ joined = " ".join(_zsh_escape(c) for c in choices)
32
+ return f":value:({joined})"
33
+ hint = (flag.get("value_hint") or "string").lower()
34
+ action = VALUE_HINT_TO_ZSH.get(hint, "")
35
+ label = _zsh_escape(hint or "value")
36
+ return f":{label}:{action}".rstrip(":")
37
+
38
+
39
+ def _flag_spec(flag: dict[str, Any]) -> str:
40
+ long = flag.get("long") or ""
41
+ short = flag.get("short") or ""
42
+ desc = _zsh_escape(flag.get("description") or "")
43
+ action = _action_for(flag)
44
+
45
+ forms = [f for f in (short, long) if f]
46
+ if not forms:
47
+ return ""
48
+ if len(forms) == 2:
49
+ head = "{" + ",".join(forms) + "}"
50
+ exclusion = "(" + " ".join(forms) + ")"
51
+ return f"'{exclusion}'{head}'[{desc}]{action}'"
52
+ return f"'{forms[0]}[{desc}]{action}'"
53
+
54
+
55
+ def _positional_spec(pos: dict[str, Any], idx: int) -> str:
56
+ name = _zsh_escape(pos.get("name") or f"arg{idx}")
57
+ hint = (pos.get("value_hint") or "string").lower()
58
+ action = VALUE_HINT_TO_ZSH.get(hint, "")
59
+ quantifier = "*" if pos.get("repeatable") else f"{idx}"
60
+ return f"'{quantifier}:{name}:{action}'".rstrip("'") + "'"
61
+
62
+
63
+ def _has_subcommands(node: dict[str, Any]) -> bool:
64
+ return bool(node.get("subcommands"))
65
+
66
+
67
+ def _render_node(node: dict[str, Any], func_name: str, indent: str = " ") -> tuple[str, list[str]]:
68
+ """Return (function body, list of child function definitions)."""
69
+ flags = node.get("flags") or []
70
+ positionals = node.get("positionals") or []
71
+ subs = node.get("subcommands") or []
72
+
73
+ lines: list[str] = []
74
+ lines.append(f"{func_name}() {{")
75
+ lines.append(f"{indent}local curcontext=$curcontext state line ret=1")
76
+ lines.append(f"{indent}local -a args")
77
+ lines.append(f"{indent}args=(")
78
+
79
+ for f in flags:
80
+ spec = _flag_spec(f)
81
+ if spec:
82
+ lines.append(f"{indent}{indent}{spec}")
83
+
84
+ if subs:
85
+ lines.append(f"{indent}{indent}'1: :->cmds'")
86
+ lines.append(f"{indent}{indent}'*::arg:->args'")
87
+ else:
88
+ for i, p in enumerate(positionals, start=1):
89
+ lines.append(f"{indent}{indent}{_positional_spec(p, i)}")
90
+
91
+ lines.append(f"{indent})")
92
+ lines.append(f"{indent}_arguments -C $args && ret=0")
93
+
94
+ children_defs: list[str] = []
95
+ if subs:
96
+ lines.append(f"{indent}case $state in")
97
+ lines.append(f"{indent}{indent}cmds)")
98
+ sub_descs = []
99
+ for s in subs:
100
+ sname = s.get("name") or ""
101
+ sdesc = _zsh_escape(s.get("description") or "")
102
+ if sname:
103
+ sub_descs.append(f"'{_zsh_escape(sname)}:{sdesc}'")
104
+ lines.append(f"{indent}{indent}{indent}local -a subcmds")
105
+ lines.append(f"{indent}{indent}{indent}subcmds=({' '.join(sub_descs)})")
106
+ lines.append(f"{indent}{indent}{indent}_describe 'command' subcmds && ret=0")
107
+ lines.append(f"{indent}{indent};;")
108
+ lines.append(f"{indent}{indent}args)")
109
+ lines.append(f"{indent}{indent}{indent}case $line[1] in")
110
+ for s in subs:
111
+ sname = s.get("name") or ""
112
+ if not sname:
113
+ continue
114
+ child_fn = f"{func_name}_{sname.replace('-', '_')}"
115
+ lines.append(f"{indent}{indent}{indent}{indent}{sname}) {child_fn} ;;")
116
+ body, more = _render_node(s, child_fn, indent)
117
+ children_defs.append(body)
118
+ children_defs.extend(more)
119
+ lines.append(f"{indent}{indent}{indent}esac")
120
+ lines.append(f"{indent}{indent};;")
121
+ lines.append(f"{indent}esac")
122
+
123
+ lines.append(f"{indent}return ret")
124
+ lines.append("}")
125
+ return "\n".join(lines), children_defs
126
+
127
+
128
+ def render_zsh(schema: dict[str, Any]) -> str:
129
+ cmd = schema.get("name") or "cmd"
130
+ safe_cmd = cmd.replace("-", "_")
131
+ root_fn = f"_{safe_cmd}"
132
+ body, children = _render_node(schema, root_fn)
133
+
134
+ header = (
135
+ f"#compdef {cmd}\n"
136
+ f"# Generated by completion-ai. Review before installing.\n"
137
+ f"# Move to a directory on $fpath (e.g. ~/.zsh/completions/_{cmd}),\n"
138
+ f"# then run: autoload -U compinit && compinit\n"
139
+ )
140
+ parts = [header, body, *children, f"{root_fn} \"$@\""]
141
+ return "\n\n".join(parts) + "\n"
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "completion-ai"
3
+ version = "0.1.0"
4
+ description = "LLM-powered zsh completion scaffolding for arbitrary CLIs"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "yangwb", email = "binyang617@gmail.com" }]
9
+ keywords = ["cli", "completion", "zsh", "llm", "qwen", "dashscope"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: MacOS",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Software Development",
22
+ "Topic :: System :: Shells",
23
+ "Topic :: Utilities",
24
+ ]
25
+ dependencies = [
26
+ "openai>=1.40.0",
27
+ "httpx[socks]>=0.27.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/Yangeyu/completion-ai"
32
+ Repository = "https://github.com/Yangeyu/completion-ai"
33
+ Issues = "https://github.com/Yangeyu/completion-ai/issues"
34
+
35
+ [project.scripts]
36
+ completion-ai = "completion_ai.cli:main"
37
+
38
+ [build-system]
39
+ requires = ["hatchling"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["completion_ai"]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = [
47
+ "completion_ai",
48
+ "README.md",
49
+ "LICENSE",
50
+ "pyproject.toml",
51
+ ]