flow
This commit is contained in:
525
commands/completion.py
Normal file
525
commands/completion.py
Normal file
@@ -0,0 +1,525 @@
|
||||
"""flow completion — shell completion support (dynamic zsh)."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Sequence, Set
|
||||
|
||||
from flow.commands.enter import HOST_TEMPLATES
|
||||
from flow.core.config import load_config, load_manifest
|
||||
from flow.core.paths import DOTFILES_DIR, INSTALLED_STATE
|
||||
|
||||
ZSH_RC_START = "# >>> flow completion >>>"
|
||||
ZSH_RC_END = "# <<< flow completion <<<"
|
||||
|
||||
TOP_LEVEL_COMMANDS = [
|
||||
"enter",
|
||||
"dev",
|
||||
"dotfiles",
|
||||
"dot",
|
||||
"bootstrap",
|
||||
"setup",
|
||||
"provision",
|
||||
"package",
|
||||
"pkg",
|
||||
"sync",
|
||||
"completion",
|
||||
]
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
p = subparsers.add_parser("completion", help="Shell completion helpers")
|
||||
sub = p.add_subparsers(dest="completion_command")
|
||||
|
||||
zsh = sub.add_parser("zsh", help="Print zsh completion script")
|
||||
zsh.set_defaults(handler=run_zsh_script)
|
||||
|
||||
install = sub.add_parser("install-zsh", help="Install zsh completion script")
|
||||
install.add_argument(
|
||||
"--dir",
|
||||
default="~/.zsh/completions",
|
||||
help="Directory where _flow completion file is written",
|
||||
)
|
||||
install.add_argument(
|
||||
"--rc",
|
||||
default="~/.zshrc",
|
||||
help="Shell rc file to update with fpath/compinit snippet",
|
||||
)
|
||||
install.add_argument(
|
||||
"--no-rc",
|
||||
action="store_true",
|
||||
help="Do not modify rc file; only write completion script",
|
||||
)
|
||||
install.set_defaults(handler=run_install_zsh)
|
||||
|
||||
hidden = sub.add_parser("_zsh_complete", help=argparse.SUPPRESS)
|
||||
hidden.add_argument("--cword", type=int, required=True, help=argparse.SUPPRESS)
|
||||
hidden.add_argument("words", nargs="*", help=argparse.SUPPRESS)
|
||||
hidden.set_defaults(handler=run_zsh_complete)
|
||||
|
||||
p.set_defaults(handler=lambda _ctx, args: p.print_help())
|
||||
|
||||
|
||||
def _canonical_command(command: str) -> str:
|
||||
alias_map = {
|
||||
"dot": "dotfiles",
|
||||
"setup": "bootstrap",
|
||||
"provision": "bootstrap",
|
||||
"pkg": "package",
|
||||
}
|
||||
return alias_map.get(command, command)
|
||||
|
||||
|
||||
def _safe_config():
|
||||
try:
|
||||
return load_config()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_manifest():
|
||||
try:
|
||||
return load_manifest()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _list_targets() -> List[str]:
|
||||
cfg = _safe_config()
|
||||
if cfg is None:
|
||||
return []
|
||||
return sorted({f"{t.namespace}@{t.platform}" for t in cfg.targets})
|
||||
|
||||
|
||||
def _list_namespaces() -> List[str]:
|
||||
cfg = _safe_config()
|
||||
if cfg is None:
|
||||
return []
|
||||
return sorted({t.namespace for t in cfg.targets})
|
||||
|
||||
|
||||
def _list_platforms() -> List[str]:
|
||||
cfg = _safe_config()
|
||||
config_platforms: Set[str] = set()
|
||||
if cfg is not None:
|
||||
config_platforms = {t.platform for t in cfg.targets}
|
||||
return sorted(set(HOST_TEMPLATES.keys()) | config_platforms)
|
||||
|
||||
|
||||
def _list_bootstrap_profiles() -> List[str]:
|
||||
manifest = _safe_manifest()
|
||||
return sorted(manifest.get("profiles", {}).keys())
|
||||
|
||||
|
||||
def _list_manifest_packages() -> List[str]:
|
||||
manifest = _safe_manifest()
|
||||
return sorted(manifest.get("binaries", {}).keys())
|
||||
|
||||
|
||||
def _list_installed_packages() -> List[str]:
|
||||
if not INSTALLED_STATE.exists():
|
||||
return []
|
||||
try:
|
||||
with open(INSTALLED_STATE) as f:
|
||||
state = json.load(f)
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(state, dict):
|
||||
return []
|
||||
return sorted(state.keys())
|
||||
|
||||
|
||||
def _list_dotfiles_profiles() -> List[str]:
|
||||
profiles_dir = DOTFILES_DIR / "profiles"
|
||||
if not profiles_dir.is_dir():
|
||||
return []
|
||||
return sorted([p.name for p in profiles_dir.iterdir() if p.is_dir() and not p.name.startswith(".")])
|
||||
|
||||
|
||||
def _list_dotfiles_packages(profile: Optional[str] = None) -> List[str]:
|
||||
package_names: Set[str] = set()
|
||||
|
||||
common = DOTFILES_DIR / "common"
|
||||
if common.is_dir():
|
||||
for pkg in common.iterdir():
|
||||
if pkg.is_dir() and not pkg.name.startswith("."):
|
||||
package_names.add(pkg.name)
|
||||
|
||||
if profile:
|
||||
profile_dir = DOTFILES_DIR / "profiles" / profile
|
||||
if profile_dir.is_dir():
|
||||
for pkg in profile_dir.iterdir():
|
||||
if pkg.is_dir() and not pkg.name.startswith("."):
|
||||
package_names.add(pkg.name)
|
||||
else:
|
||||
profiles_dir = DOTFILES_DIR / "profiles"
|
||||
if profiles_dir.is_dir():
|
||||
for profile_dir in profiles_dir.iterdir():
|
||||
if not profile_dir.is_dir():
|
||||
continue
|
||||
for pkg in profile_dir.iterdir():
|
||||
if pkg.is_dir() and not pkg.name.startswith("."):
|
||||
package_names.add(pkg.name)
|
||||
|
||||
return sorted(package_names)
|
||||
|
||||
|
||||
def _list_container_names() -> List[str]:
|
||||
runtime = None
|
||||
for rt in ("docker", "podman"):
|
||||
if shutil.which(rt):
|
||||
runtime = rt
|
||||
break
|
||||
|
||||
if not runtime:
|
||||
return []
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
runtime,
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
"label=dev=true",
|
||||
"--format",
|
||||
'{{.Label "dev.name"}}',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1,
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
names = []
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
names.append(line)
|
||||
return sorted(set(names))
|
||||
|
||||
|
||||
def _split_words(words: Sequence[str], cword: int):
|
||||
tokens = list(words)
|
||||
index = max(0, cword - 1)
|
||||
|
||||
if tokens:
|
||||
tokens = tokens[1:]
|
||||
index = max(0, cword - 2)
|
||||
|
||||
if index > len(tokens):
|
||||
index = len(tokens)
|
||||
|
||||
current = tokens[index] if index < len(tokens) else ""
|
||||
before = tokens[:index]
|
||||
return before, current
|
||||
|
||||
|
||||
def _filter(candidates: Sequence[str], prefix: str) -> List[str]:
|
||||
unique = sorted(set(candidates))
|
||||
if not prefix:
|
||||
return unique
|
||||
return [c for c in unique if c.startswith(prefix)]
|
||||
|
||||
|
||||
def _profile_from_before(before: Sequence[str]) -> Optional[str]:
|
||||
for i, token in enumerate(before):
|
||||
if token == "--profile" and i + 1 < len(before):
|
||||
return before[i + 1]
|
||||
return None
|
||||
|
||||
|
||||
def _complete_dev(before: Sequence[str], current: str) -> List[str]:
|
||||
if len(before) <= 1:
|
||||
return _filter(["create", "exec", "connect", "list", "stop", "remove", "rm", "respawn"], current)
|
||||
|
||||
sub = "remove" if before[1] == "rm" else before[1]
|
||||
|
||||
if sub in {"remove", "stop", "connect", "exec", "respawn"}:
|
||||
options = {
|
||||
"remove": ["-f", "--force", "-h", "--help"],
|
||||
"stop": ["--kill", "-h", "--help"],
|
||||
"exec": ["-h", "--help"],
|
||||
"connect": ["-h", "--help"],
|
||||
"respawn": ["-h", "--help"],
|
||||
}[sub]
|
||||
|
||||
if current.startswith("-"):
|
||||
return _filter(options, current)
|
||||
|
||||
non_opt = [t for t in before[2:] if not t.startswith("-")]
|
||||
if len(non_opt) == 0:
|
||||
return _filter(_list_container_names(), current)
|
||||
return []
|
||||
|
||||
if sub == "create":
|
||||
options = ["-i", "--image", "-p", "--project", "-h", "--help"]
|
||||
if before and before[-1] in ("-i", "--image"):
|
||||
return _filter(["tm0/node", "docker/python", "docker/alpine"], current)
|
||||
|
||||
if current.startswith("-"):
|
||||
return _filter(options, current)
|
||||
|
||||
return []
|
||||
|
||||
if sub == "list":
|
||||
return []
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _complete_dotfiles(before: Sequence[str], current: str) -> List[str]:
|
||||
if len(before) <= 1:
|
||||
return _filter(
|
||||
["init", "link", "unlink", "status", "sync", "relink", "clean", "edit"],
|
||||
current,
|
||||
)
|
||||
|
||||
sub = before[1]
|
||||
|
||||
if sub == "init":
|
||||
return _filter(["--repo", "-h", "--help"], current) if current.startswith("-") else []
|
||||
|
||||
if sub in {"link", "relink"}:
|
||||
if before and before[-1] == "--profile":
|
||||
return _filter(_list_dotfiles_profiles(), current)
|
||||
|
||||
if current.startswith("-"):
|
||||
return _filter(["--profile", "--copy", "--force", "--dry-run", "-h", "--help"], current)
|
||||
|
||||
profile = _profile_from_before(before)
|
||||
return _filter(_list_dotfiles_packages(profile), current)
|
||||
|
||||
if sub == "unlink":
|
||||
if current.startswith("-"):
|
||||
return _filter(["-h", "--help"], current)
|
||||
return _filter(_list_dotfiles_packages(), current)
|
||||
|
||||
if sub == "edit":
|
||||
if current.startswith("-"):
|
||||
return _filter(["--no-commit", "-h", "--help"], current)
|
||||
non_opt = [t for t in before[2:] if not t.startswith("-")]
|
||||
if len(non_opt) == 0:
|
||||
return _filter(_list_dotfiles_packages(), current)
|
||||
return []
|
||||
|
||||
if sub == "clean":
|
||||
return _filter(["--dry-run", "-h", "--help"], current) if current.startswith("-") else []
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _complete_bootstrap(before: Sequence[str], current: str) -> List[str]:
|
||||
if len(before) <= 1:
|
||||
return _filter(["run", "list", "show"], current)
|
||||
|
||||
sub = before[1]
|
||||
|
||||
if sub == "run":
|
||||
if before and before[-1] == "--profile":
|
||||
return _filter(_list_bootstrap_profiles(), current)
|
||||
if current.startswith("-"):
|
||||
return _filter(["--profile", "--dry-run", "--var", "-h", "--help"], current)
|
||||
return []
|
||||
|
||||
if sub == "show":
|
||||
if current.startswith("-"):
|
||||
return _filter(["-h", "--help"], current)
|
||||
non_opt = [t for t in before[2:] if not t.startswith("-")]
|
||||
if len(non_opt) == 0:
|
||||
return _filter(_list_bootstrap_profiles(), current)
|
||||
return []
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _complete_package(before: Sequence[str], current: str) -> List[str]:
|
||||
if len(before) <= 1:
|
||||
return _filter(["install", "list", "remove"], current)
|
||||
|
||||
sub = before[1]
|
||||
|
||||
if sub == "install":
|
||||
if current.startswith("-"):
|
||||
return _filter(["--dry-run", "-h", "--help"], current)
|
||||
return _filter(_list_manifest_packages(), current)
|
||||
|
||||
if sub == "remove":
|
||||
if current.startswith("-"):
|
||||
return _filter(["-h", "--help"], current)
|
||||
return _filter(_list_installed_packages(), current)
|
||||
|
||||
if sub == "list":
|
||||
if current.startswith("-"):
|
||||
return _filter(["--all", "-h", "--help"], current)
|
||||
return []
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _complete_sync(before: Sequence[str], current: str) -> List[str]:
|
||||
if len(before) <= 1:
|
||||
return _filter(["check", "fetch", "summary"], current)
|
||||
|
||||
sub = before[1]
|
||||
if sub == "check" and current.startswith("-"):
|
||||
return _filter(["--fetch", "--no-fetch", "-h", "--help"], current)
|
||||
|
||||
if current.startswith("-"):
|
||||
return _filter(["-h", "--help"], current)
|
||||
return []
|
||||
|
||||
|
||||
def complete(words: Sequence[str], cword: int) -> List[str]:
|
||||
before, current = _split_words(words, cword)
|
||||
|
||||
if not before:
|
||||
return _filter(TOP_LEVEL_COMMANDS + ["-h", "--help", "-v", "--version"], current)
|
||||
|
||||
command = _canonical_command(before[0])
|
||||
|
||||
if command == "enter":
|
||||
if before and before[-1] in ("-p", "--platform"):
|
||||
return _filter(_list_platforms(), current)
|
||||
if before and before[-1] in ("-n", "--namespace"):
|
||||
return _filter(_list_namespaces(), current)
|
||||
if current.startswith("-"):
|
||||
return _filter(
|
||||
["-u", "--user", "-n", "--namespace", "-p", "--platform", "-s", "--session", "--no-tmux", "-d", "--dry-run", "-h", "--help"],
|
||||
current,
|
||||
)
|
||||
return _filter(_list_targets(), current)
|
||||
|
||||
if command == "dev":
|
||||
return _complete_dev(before, current)
|
||||
|
||||
if command == "dotfiles":
|
||||
return _complete_dotfiles(before, current)
|
||||
|
||||
if command == "bootstrap":
|
||||
return _complete_bootstrap(before, current)
|
||||
|
||||
if command == "package":
|
||||
return _complete_package(before, current)
|
||||
|
||||
if command == "sync":
|
||||
return _complete_sync(before, current)
|
||||
|
||||
if command == "completion":
|
||||
if len(before) <= 1:
|
||||
return _filter(["zsh", "install-zsh"], current)
|
||||
|
||||
sub = before[1]
|
||||
if sub == "install-zsh" and current.startswith("-"):
|
||||
return _filter(["--dir", "--rc", "--no-rc", "-h", "--help"], current)
|
||||
|
||||
return []
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def run_zsh_complete(_ctx, args):
|
||||
candidates = complete(args.words, args.cword)
|
||||
for item in candidates:
|
||||
print(item)
|
||||
|
||||
|
||||
def _zsh_script_text() -> str:
|
||||
return r'''#compdef flow
|
||||
|
||||
_flow() {
|
||||
local -a suggestions
|
||||
suggestions=("${(@f)$(flow completion _zsh_complete --cword "$CURRENT" -- "${words[@]}" 2>/dev/null)}")
|
||||
|
||||
if (( ${#suggestions[@]} > 0 )); then
|
||||
compadd -Q -- "${suggestions[@]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$words[CURRENT]" == */* || "$words[CURRENT]" == ./* || "$words[CURRENT]" == ~* ]]; then
|
||||
_files
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
compdef _flow flow
|
||||
'''
|
||||
|
||||
|
||||
def _zsh_dir_for_rc(path: Path) -> str:
|
||||
home = Path.home().resolve()
|
||||
resolved = path.expanduser().resolve()
|
||||
try:
|
||||
rel = resolved.relative_to(home)
|
||||
return f"~/{rel}" if str(rel) != "." else "~"
|
||||
except ValueError:
|
||||
return str(resolved)
|
||||
|
||||
|
||||
def _zsh_rc_snippet(completions_dir: Path) -> str:
|
||||
dir_expr = _zsh_dir_for_rc(completions_dir)
|
||||
return (
|
||||
f"{ZSH_RC_START}\n"
|
||||
f"fpath=({dir_expr} $fpath)\n"
|
||||
"autoload -Uz compinit && compinit\n"
|
||||
f"{ZSH_RC_END}\n"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_rc_snippet(rc_path: Path, completions_dir: Path) -> bool:
|
||||
snippet = _zsh_rc_snippet(completions_dir)
|
||||
if rc_path.exists():
|
||||
content = rc_path.read_text()
|
||||
else:
|
||||
content = ""
|
||||
|
||||
if ZSH_RC_START in content and ZSH_RC_END in content:
|
||||
start = content.find(ZSH_RC_START)
|
||||
end = content.find(ZSH_RC_END, start)
|
||||
if end >= 0:
|
||||
end += len(ZSH_RC_END)
|
||||
updated = content[:start] + snippet.rstrip("\n") + content[end:]
|
||||
if updated == content:
|
||||
return False
|
||||
rc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rc_path.write_text(updated)
|
||||
return True
|
||||
|
||||
sep = "" if content.endswith("\n") or content == "" else "\n"
|
||||
rc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rc_path.write_text(content + sep + snippet)
|
||||
return True
|
||||
|
||||
|
||||
def run_install_zsh(_ctx, args):
|
||||
completions_dir = Path(args.dir).expanduser()
|
||||
completions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
completion_file = completions_dir / "_flow"
|
||||
completion_file.write_text(_zsh_script_text())
|
||||
print(f"Installed completion script: {completion_file}")
|
||||
|
||||
if args.no_rc:
|
||||
print("Skipped rc file update (--no-rc)")
|
||||
return
|
||||
|
||||
rc_path = Path(args.rc).expanduser()
|
||||
changed = _ensure_rc_snippet(rc_path, completions_dir)
|
||||
if changed:
|
||||
print(f"Updated shell rc: {rc_path}")
|
||||
else:
|
||||
print(f"Shell rc already configured: {rc_path}")
|
||||
|
||||
print("Restart shell or run: autoload -Uz compinit && compinit")
|
||||
|
||||
|
||||
def run_zsh_script(_ctx, _args):
|
||||
print(_zsh_script_text())
|
||||
Reference in New Issue
Block a user