copa-cli 0.2.1__tar.gz → 0.3.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.
- {copa_cli-0.2.1/copa_cli.egg-info → copa_cli-0.3.0}/PKG-INFO +1 -1
- copa_cli-0.3.0/copa/cli_common.py +89 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/cli_internal.py +23 -44
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/cli_llm.py +40 -17
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/config.py +21 -18
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/copa.zsh +34 -11
- {copa_cli-0.2.1 → copa_cli-0.3.0/copa_cli.egg-info}/PKG-INFO +1 -1
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa_cli.egg-info/SOURCES.txt +1 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/pyproject.toml +1 -1
- copa_cli-0.3.0/tests/test_modal.py +178 -0
- copa_cli-0.2.1/copa/cli_common.py +0 -43
- {copa_cli-0.2.1 → copa_cli-0.3.0}/LICENSE +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/README.md +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/__init__.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/__main__.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/cli.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/cli_share.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/db.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/evolve.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/fzf.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/history.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/llm.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/mcp_server.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/models.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/scanner.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/scoring.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/sharing.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa_cli.egg-info/dependency_links.txt +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa_cli.egg-info/entry_points.txt +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa_cli.egg-info/requires.txt +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/copa_cli.egg-info/top_level.txt +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/setup.cfg +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/tests/test_cli_and_sharing.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/tests/test_db.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/tests/test_fzf.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/tests/test_models.py +0 -0
- {copa_cli-0.2.1 → copa_cli-0.3.0}/tests/test_scanner.py +0 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Shared utilities for Copa CLI modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from click.shell_completion import CompletionItem
|
|
6
|
+
|
|
7
|
+
from .db import Database
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_db() -> Database:
|
|
11
|
+
db = Database()
|
|
12
|
+
db.init_db()
|
|
13
|
+
return db
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# --- TTY helpers for fzf execute() bindings ---
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _open_tty():
|
|
20
|
+
"""Open /dev/tty with echo enabled for use inside fzf execute() bindings.
|
|
21
|
+
|
|
22
|
+
fzf disables terminal echo before launching execute() subcommands.
|
|
23
|
+
We re-enable it so users can see what they type.
|
|
24
|
+
|
|
25
|
+
Returns (tty_file, original_termios) or (None, None) on failure.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
tty = open("/dev/tty", "r+")
|
|
29
|
+
except OSError:
|
|
30
|
+
return None, None
|
|
31
|
+
|
|
32
|
+
old_attrs = None
|
|
33
|
+
try:
|
|
34
|
+
import termios
|
|
35
|
+
|
|
36
|
+
fd = tty.fileno()
|
|
37
|
+
old_attrs = termios.tcgetattr(fd)
|
|
38
|
+
new_attrs = termios.tcgetattr(fd)
|
|
39
|
+
# Enable echo (ECHO) and canonical mode (ICANON) for line-buffered input
|
|
40
|
+
new_attrs[3] |= termios.ECHO | termios.ICANON
|
|
41
|
+
termios.tcsetattr(fd, termios.TCSANOW, new_attrs)
|
|
42
|
+
except (ImportError, termios.error):
|
|
43
|
+
pass # termios not available (non-Unix) — proceed without echo fix
|
|
44
|
+
|
|
45
|
+
return tty, old_attrs
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _close_tty(tty, old_attrs):
|
|
49
|
+
"""Restore terminal attributes and close the tty file."""
|
|
50
|
+
if tty is None:
|
|
51
|
+
return
|
|
52
|
+
if old_attrs is not None:
|
|
53
|
+
try:
|
|
54
|
+
import termios
|
|
55
|
+
|
|
56
|
+
termios.tcsetattr(tty.fileno(), termios.TCSANOW, old_attrs)
|
|
57
|
+
except (ImportError, termios.error):
|
|
58
|
+
pass
|
|
59
|
+
tty.close()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- Shell completion helpers ---
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def complete_group(ctx, param, incomplete):
|
|
66
|
+
"""Complete group names from the database."""
|
|
67
|
+
try:
|
|
68
|
+
db = get_db()
|
|
69
|
+
return [CompletionItem(g) for g in db.get_groups() if g.startswith(incomplete)]
|
|
70
|
+
except Exception:
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def complete_shared_set(ctx, param, incomplete):
|
|
75
|
+
"""Complete shared set names from the database."""
|
|
76
|
+
try:
|
|
77
|
+
db = get_db()
|
|
78
|
+
return [CompletionItem(s.name) for s in db.get_shared_sets() if s.name.startswith(incomplete)]
|
|
79
|
+
except Exception:
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def complete_source(ctx, param, incomplete):
|
|
84
|
+
"""Complete source values from the database."""
|
|
85
|
+
try:
|
|
86
|
+
db = get_db()
|
|
87
|
+
return [CompletionItem(s) for s in db.get_sources() if s.startswith(incomplete)]
|
|
88
|
+
except Exception:
|
|
89
|
+
return []
|
|
@@ -6,50 +6,7 @@ import sys
|
|
|
6
6
|
|
|
7
7
|
import click
|
|
8
8
|
|
|
9
|
-
from .cli_common import complete_group, complete_shared_set, get_db
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def _open_tty():
|
|
13
|
-
"""Open /dev/tty with echo enabled for use inside fzf execute() bindings.
|
|
14
|
-
|
|
15
|
-
fzf disables terminal echo before launching execute() subcommands.
|
|
16
|
-
We re-enable it so users can see what they type.
|
|
17
|
-
|
|
18
|
-
Returns (tty_file, original_termios) or (None, None) on failure.
|
|
19
|
-
"""
|
|
20
|
-
try:
|
|
21
|
-
tty = open("/dev/tty", "r+")
|
|
22
|
-
except OSError:
|
|
23
|
-
return None, None
|
|
24
|
-
|
|
25
|
-
old_attrs = None
|
|
26
|
-
try:
|
|
27
|
-
import termios
|
|
28
|
-
|
|
29
|
-
fd = tty.fileno()
|
|
30
|
-
old_attrs = termios.tcgetattr(fd)
|
|
31
|
-
new_attrs = termios.tcgetattr(fd)
|
|
32
|
-
# Enable echo (ECHO) and canonical mode (ICANON) for line-buffered input
|
|
33
|
-
new_attrs[3] |= termios.ECHO | termios.ICANON
|
|
34
|
-
termios.tcsetattr(fd, termios.TCSANOW, new_attrs)
|
|
35
|
-
except (ImportError, termios.error):
|
|
36
|
-
pass # termios not available (non-Unix) — proceed without echo fix
|
|
37
|
-
|
|
38
|
-
return tty, old_attrs
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def _close_tty(tty, old_attrs):
|
|
42
|
-
"""Restore terminal attributes and close the tty file."""
|
|
43
|
-
if tty is None:
|
|
44
|
-
return
|
|
45
|
-
if old_attrs is not None:
|
|
46
|
-
try:
|
|
47
|
-
import termios
|
|
48
|
-
|
|
49
|
-
termios.tcsetattr(tty.fileno(), termios.TCSANOW, old_attrs)
|
|
50
|
-
except (ImportError, termios.error):
|
|
51
|
-
pass
|
|
52
|
-
tty.close()
|
|
9
|
+
from .cli_common import _close_tty, _open_tty, complete_group, complete_shared_set, get_db
|
|
53
10
|
|
|
54
11
|
|
|
55
12
|
@click.command("_record", hidden=True)
|
|
@@ -225,6 +182,26 @@ def list_groups():
|
|
|
225
182
|
click.echo(g)
|
|
226
183
|
|
|
227
184
|
|
|
185
|
+
@click.command("_list-groups-for-assign", hidden=True)
|
|
186
|
+
def list_groups_for_assign():
|
|
187
|
+
"""Output group names for group-assign modal."""
|
|
188
|
+
db = get_db()
|
|
189
|
+
click.echo("(none)")
|
|
190
|
+
for g in db.get_groups():
|
|
191
|
+
click.echo(g)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@click.command("_set-group-direct", hidden=True)
|
|
195
|
+
@click.argument("cmd_id", type=int)
|
|
196
|
+
@click.argument("group_name", required=False, default=None)
|
|
197
|
+
def set_group_direct(cmd_id, group_name):
|
|
198
|
+
"""Assign group non-interactively (for fzf modal)."""
|
|
199
|
+
db = get_db()
|
|
200
|
+
if group_name == "(none)":
|
|
201
|
+
group_name = None
|
|
202
|
+
db.update_group(cmd_id, group_name)
|
|
203
|
+
|
|
204
|
+
|
|
228
205
|
@click.command("_next-group", hidden=True)
|
|
229
206
|
@click.argument("current", default="(all)")
|
|
230
207
|
def next_group(current: str):
|
|
@@ -327,6 +304,8 @@ def register(cli):
|
|
|
327
304
|
cli.add_command(set_group)
|
|
328
305
|
cli.add_command(set_flags)
|
|
329
306
|
cli.add_command(list_groups)
|
|
307
|
+
cli.add_command(list_groups_for_assign)
|
|
308
|
+
cli.add_command(set_group_direct)
|
|
330
309
|
cli.add_command(next_group)
|
|
331
310
|
cli.add_command(complete_word)
|
|
332
311
|
cli.add_command(mcp_cmd)
|
|
@@ -6,7 +6,7 @@ import sys
|
|
|
6
6
|
|
|
7
7
|
import click
|
|
8
8
|
|
|
9
|
-
from .cli_common import get_db
|
|
9
|
+
from .cli_common import _close_tty, _open_tty, get_db
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
@click.command()
|
|
@@ -119,31 +119,54 @@ def describe(cmd_id: int):
|
|
|
119
119
|
click.echo(f"Command {cmd_id} not found.", err=True)
|
|
120
120
|
sys.exit(1)
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
tty, old_attrs = _open_tty()
|
|
123
|
+
|
|
124
|
+
def tty_write(msg: str):
|
|
125
|
+
if tty:
|
|
126
|
+
tty.write(msg + "\n")
|
|
127
|
+
tty.flush()
|
|
128
|
+
else:
|
|
129
|
+
click.echo(msg)
|
|
130
|
+
|
|
131
|
+
def tty_read(prompt: str) -> str:
|
|
132
|
+
if tty:
|
|
133
|
+
tty.write(prompt)
|
|
134
|
+
tty.flush()
|
|
135
|
+
return tty.readline().rstrip("\n")
|
|
136
|
+
return input(prompt)
|
|
137
|
+
|
|
138
|
+
tty_write(f" [{cmd.id}] {cmd.command}")
|
|
123
139
|
if cmd.description:
|
|
124
|
-
|
|
140
|
+
tty_write(f" Current: {cmd.description}")
|
|
125
141
|
|
|
126
142
|
backend = db.get_meta("llm_backend") or "claude"
|
|
127
143
|
model = db.get_meta("ollama_model") or "llama3.2:3b"
|
|
128
144
|
|
|
129
|
-
|
|
145
|
+
tty_write(f" Generating ({backend})...")
|
|
130
146
|
suggestion = generate_description(cmd.command, backend=backend, model=model)
|
|
131
147
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
try:
|
|
149
|
+
if suggestion:
|
|
150
|
+
tty_write(f" Suggestion: {suggestion}")
|
|
151
|
+
desc = tty_read(f" Description [{suggestion}]: ").strip()
|
|
152
|
+
if desc.lower() == "q":
|
|
153
|
+
_close_tty(tty, old_attrs)
|
|
154
|
+
return
|
|
155
|
+
if not desc:
|
|
156
|
+
desc = suggestion
|
|
157
|
+
else:
|
|
158
|
+
tty_write(" (no suggestion generated)")
|
|
159
|
+
desc = tty_read(" Description: ").strip()
|
|
160
|
+
if not desc:
|
|
161
|
+
_close_tty(tty, old_attrs)
|
|
162
|
+
return
|
|
163
|
+
except (EOFError, KeyboardInterrupt):
|
|
164
|
+
_close_tty(tty, old_attrs)
|
|
165
|
+
return
|
|
144
166
|
|
|
145
167
|
db.update_description(cmd.id, desc)
|
|
146
|
-
|
|
168
|
+
tty_write(" saved")
|
|
169
|
+
_close_tty(tty, old_attrs)
|
|
147
170
|
|
|
148
171
|
|
|
149
172
|
@click.command()
|
|
@@ -18,6 +18,7 @@ DEFAULT_KEYS: dict[str, str] = {
|
|
|
18
18
|
"flags": "ctrl-f",
|
|
19
19
|
"filter_group": "ctrl-s",
|
|
20
20
|
"cycle_group": "ctrl-n",
|
|
21
|
+
"toggle_header": "ctrl-h",
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
# Action name -> shell suffix appended to the command
|
|
@@ -43,6 +44,7 @@ LABELS: dict[str, str] = {
|
|
|
43
44
|
"flags": "flag",
|
|
44
45
|
"filter_group": "scope",
|
|
45
46
|
"cycle_group": "↻grp",
|
|
47
|
+
"toggle_header": "keys",
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
# Keys that cannot be overridden by user config
|
|
@@ -140,6 +142,7 @@ def emit_zsh_config(config: dict[str, str]) -> str:
|
|
|
140
142
|
flags_key = config.get("flags", DEFAULT_KEYS["flags"])
|
|
141
143
|
filter_group_key = config.get("filter_group", DEFAULT_KEYS["filter_group"])
|
|
142
144
|
cycle_group_key = config.get("cycle_group", DEFAULT_KEYS["cycle_group"])
|
|
145
|
+
toggle_header_key = config.get("toggle_header", DEFAULT_KEYS["toggle_header"])
|
|
143
146
|
expect_keys = [
|
|
144
147
|
config[action]
|
|
145
148
|
for action in ("background", "merge_output", "pipe", "redirect", "chain", "suppress")
|
|
@@ -152,27 +155,27 @@ def emit_zsh_config(config: dict[str, str]) -> str:
|
|
|
152
155
|
lines.append(f"_COPA_FLAGS_KEY='{flags_key}'")
|
|
153
156
|
lines.append(f"_COPA_FILTER_GROUP_KEY='{filter_group_key}'")
|
|
154
157
|
lines.append(f"_COPA_CYCLE_GROUP_KEY='{cycle_group_key}'")
|
|
158
|
+
lines.append(f"_COPA_TOGGLE_HEADER_KEY='{toggle_header_key}'")
|
|
155
159
|
|
|
156
|
-
# Build header
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
"merge_output",
|
|
161
|
-
"pipe",
|
|
162
|
-
"redirect",
|
|
163
|
-
"chain",
|
|
164
|
-
"suppress",
|
|
165
|
-
"group",
|
|
166
|
-
"describe",
|
|
167
|
-
"flags",
|
|
168
|
-
"filter_group",
|
|
169
|
-
"cycle_group",
|
|
170
|
-
):
|
|
160
|
+
# Build 2-line header to avoid wrapping on narrow terminals
|
|
161
|
+
# Row 1: composition keys + toggle
|
|
162
|
+
row1_parts = ["Copa", f"{_format_key_label('ctrl-r')}:cycle"]
|
|
163
|
+
for action in ("background", "merge_output", "pipe", "redirect", "chain", "suppress", "toggle_header"):
|
|
171
164
|
key = config.get(action, DEFAULT_KEYS[action])
|
|
172
165
|
label = LABELS[action]
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
166
|
+
row1_parts.append(f"{_format_key_label(key)}:{label}")
|
|
167
|
+
row1 = " | ".join(row1_parts)
|
|
168
|
+
|
|
169
|
+
# Row 2: action keys
|
|
170
|
+
row2_parts = []
|
|
171
|
+
for action in ("group", "describe", "flags", "filter_group", "cycle_group"):
|
|
172
|
+
key = config.get(action, DEFAULT_KEYS[action])
|
|
173
|
+
label = LABELS[action]
|
|
174
|
+
row2_parts.append(f"{_format_key_label(key)}:{label}")
|
|
175
|
+
row2 = " | ".join(row2_parts)
|
|
176
|
+
|
|
177
|
+
# Use $'...\n...' quoting so zsh interprets the newline
|
|
178
|
+
lines.append(f"_COPA_HEADER=$'{row1}\\n{row2}'")
|
|
176
179
|
|
|
177
180
|
# Completion branding
|
|
178
181
|
branding = config.get("_completion_branding", True)
|
|
@@ -17,8 +17,9 @@ eval "$(copa _fzf-config 2>/dev/null)" || {
|
|
|
17
17
|
_COPA_FLAGS_KEY='ctrl-f'
|
|
18
18
|
_COPA_FILTER_GROUP_KEY='ctrl-s'
|
|
19
19
|
_COPA_CYCLE_GROUP_KEY='ctrl-n'
|
|
20
|
+
_COPA_TOGGLE_HEADER_KEY='ctrl-h'
|
|
20
21
|
_COPA_COMPLETION_BRANDING='true'
|
|
21
|
-
_COPA_HEADER
|
|
22
|
+
_COPA_HEADER=$'Copa | ^R:cycle | ^V:& | ^O:2>&1 | ^X:| | ^T:> | ^A:&& | ^/:quiet | ^H:keys\n^G:grp | ^D:desc | ^F:flag | ^S:scope | ^N:↻grp'
|
|
22
23
|
typeset -gA _COPA_SUFFIXES
|
|
23
24
|
_COPA_SUFFIXES[ctrl-v]=' &'
|
|
24
25
|
_COPA_SUFFIXES[ctrl-o]=' 2>&1'
|
|
@@ -57,6 +58,7 @@ _copa_fzf_widget() {
|
|
|
57
58
|
local mode="all"
|
|
58
59
|
local output
|
|
59
60
|
local copa_bin="${commands[copa]:-copa}"
|
|
61
|
+
local _copa_modal_file=$(mktemp -t copa_modal.XXXXXX)
|
|
60
62
|
|
|
61
63
|
output=$("$copa_bin" fzf-list --mode "$mode" | \
|
|
62
64
|
fzf --ansi \
|
|
@@ -70,15 +72,11 @@ _copa_fzf_widget() {
|
|
|
70
72
|
--layout reverse \
|
|
71
73
|
--expect "$_COPA_EXPECT" \
|
|
72
74
|
--bind "${_COPA_DESCRIBE_KEY}:execute($copa_bin describe {1})+refresh-preview" \
|
|
73
|
-
--bind "${_COPA_GROUP_KEY}:execute($copa_bin _set-group {1})+reload($copa_bin fzf-list)+refresh-preview" \
|
|
74
75
|
--bind "${_COPA_FLAGS_KEY}:execute($copa_bin _set-flags {1})+reload($copa_bin fzf-list)+refresh-preview" \
|
|
75
|
-
--bind "${_COPA_FILTER_GROUP_KEY}:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
elif [[ -n \$group ]]; then
|
|
80
|
-
echo \"reload(${copa_bin} fzf-list --mode group --group \$group)+change-prompt(copa [\$group]> )\"
|
|
81
|
-
fi" \
|
|
76
|
+
--bind "${_COPA_FILTER_GROUP_KEY}:reload($copa_bin _list-groups)+change-prompt(scope> )+clear-query" \
|
|
77
|
+
--bind "${_COPA_GROUP_KEY}:transform:
|
|
78
|
+
echo {1} > ${_copa_modal_file};
|
|
79
|
+
echo \"reload(${copa_bin} _list-groups-for-assign)+change-prompt(group> )+clear-query\"" \
|
|
82
80
|
--bind "${_COPA_CYCLE_GROUP_KEY}:transform:
|
|
83
81
|
cur_group='(all)';
|
|
84
82
|
if [[ \$FZF_PROMPT =~ 'copa \\[(.+)\\]> ' ]]; then
|
|
@@ -98,16 +96,41 @@ _copa_fzf_widget() {
|
|
|
98
96
|
else
|
|
99
97
|
echo "reload('"$copa_bin"' fzf-list --mode all)+change-prompt(copa> )"
|
|
100
98
|
fi' \
|
|
101
|
-
--bind
|
|
99
|
+
--bind "${_COPA_TOGGLE_HEADER_KEY}:toggle-header" \
|
|
100
|
+
--bind 'enter:transform:
|
|
101
|
+
if [[ $FZF_PROMPT == "scope> " ]]; then
|
|
102
|
+
selected={};
|
|
103
|
+
if [[ $selected == "(all)" ]]; then
|
|
104
|
+
echo "reload('"$copa_bin"' fzf-list --mode all)+change-prompt(copa> )+clear-query"
|
|
105
|
+
else
|
|
106
|
+
echo "reload('"$copa_bin"' fzf-list --mode group --group $selected)+change-prompt(copa [$selected]> )+clear-query"
|
|
107
|
+
fi
|
|
108
|
+
elif [[ $FZF_PROMPT == "group> " ]]; then
|
|
109
|
+
cmd_id=$(cat '"${_copa_modal_file}"');
|
|
110
|
+
selected={};
|
|
111
|
+
'"$copa_bin"' _set-group-direct $cmd_id $selected;
|
|
112
|
+
echo "reload('"$copa_bin"' fzf-list)+change-prompt(copa> )+clear-query"
|
|
113
|
+
else
|
|
114
|
+
echo "accept"
|
|
115
|
+
fi' \
|
|
116
|
+
--bind 'esc:transform:
|
|
117
|
+
if [[ $FZF_PROMPT == "scope> " || $FZF_PROMPT == "group> " ]]; then
|
|
118
|
+
echo "reload('"$copa_bin"' fzf-list)+change-prompt(copa> )+clear-query"
|
|
119
|
+
else
|
|
120
|
+
echo "abort"
|
|
121
|
+
fi' \
|
|
102
122
|
)
|
|
103
123
|
|
|
124
|
+
[[ -f "$_copa_modal_file" ]] && rm -f "$_copa_modal_file"
|
|
125
|
+
|
|
104
126
|
if [[ -n "$output" ]]; then
|
|
105
127
|
# --expect output: line 1 = key pressed (empty for Enter), line 2+ = selected item
|
|
106
128
|
local key selected cmd suffix
|
|
107
129
|
key=$(echo "$output" | head -1)
|
|
108
130
|
selected=$(echo "$output" | tail -n +2)
|
|
109
131
|
|
|
110
|
-
|
|
132
|
+
# Skip lines without ┃ (modal group names don't have the delimiter)
|
|
133
|
+
if [[ -n "$selected" && "$selected" == *┃* ]]; then
|
|
111
134
|
cmd=$(echo "$selected" | cut -d'┃' -f2 | sed 's/^ *//;s/ *$//')
|
|
112
135
|
suffix="${_COPA_SUFFIXES[$key]}"
|
|
113
136
|
LBUFFER="${cmd}${suffix}"
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Tests for modal group commands and config changes."""
|
|
2
|
+
|
|
3
|
+
from click.testing import CliRunner
|
|
4
|
+
|
|
5
|
+
from copa.cli import cli
|
|
6
|
+
from copa.config import DEFAULT_KEYS, LABELS, emit_zsh_config, load_config
|
|
7
|
+
from copa.db import Database
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestListGroupsForAssign:
|
|
11
|
+
"""Test the _list-groups-for-assign hidden command."""
|
|
12
|
+
|
|
13
|
+
def _make_db(self, tmp_path, monkeypatch):
|
|
14
|
+
db_path = tmp_path / "test.db"
|
|
15
|
+
db = Database(db_path)
|
|
16
|
+
db.init_db()
|
|
17
|
+
import copa.cli_common
|
|
18
|
+
import copa.cli_internal
|
|
19
|
+
|
|
20
|
+
monkeypatch.setattr(copa.cli_common, "get_db", lambda: db)
|
|
21
|
+
monkeypatch.setattr(copa.cli_internal, "get_db", lambda: db)
|
|
22
|
+
return db
|
|
23
|
+
|
|
24
|
+
def test_outputs_none_first(self, tmp_path, monkeypatch):
|
|
25
|
+
self._make_db(tmp_path, monkeypatch)
|
|
26
|
+
runner = CliRunner()
|
|
27
|
+
result = runner.invoke(cli, ["_list-groups-for-assign"])
|
|
28
|
+
assert result.exit_code == 0
|
|
29
|
+
lines = result.output.strip().split("\n")
|
|
30
|
+
assert lines[0] == "(none)"
|
|
31
|
+
|
|
32
|
+
def test_outputs_groups(self, tmp_path, monkeypatch):
|
|
33
|
+
db = self._make_db(tmp_path, monkeypatch)
|
|
34
|
+
db.add_command("cmd1", group_name="alpha")
|
|
35
|
+
db.add_command("cmd2", group_name="beta")
|
|
36
|
+
runner = CliRunner()
|
|
37
|
+
result = runner.invoke(cli, ["_list-groups-for-assign"])
|
|
38
|
+
assert result.exit_code == 0
|
|
39
|
+
lines = result.output.strip().split("\n")
|
|
40
|
+
assert lines[0] == "(none)"
|
|
41
|
+
assert "alpha" in lines
|
|
42
|
+
assert "beta" in lines
|
|
43
|
+
|
|
44
|
+
def test_no_groups_just_none(self, tmp_path, monkeypatch):
|
|
45
|
+
self._make_db(tmp_path, monkeypatch)
|
|
46
|
+
runner = CliRunner()
|
|
47
|
+
result = runner.invoke(cli, ["_list-groups-for-assign"])
|
|
48
|
+
assert result.exit_code == 0
|
|
49
|
+
assert result.output.strip() == "(none)"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestSetGroupDirect:
|
|
53
|
+
"""Test the _set-group-direct hidden command."""
|
|
54
|
+
|
|
55
|
+
def _make_db(self, tmp_path, monkeypatch):
|
|
56
|
+
db_path = tmp_path / "test.db"
|
|
57
|
+
db = Database(db_path)
|
|
58
|
+
db.init_db()
|
|
59
|
+
import copa.cli_common
|
|
60
|
+
import copa.cli_internal
|
|
61
|
+
|
|
62
|
+
monkeypatch.setattr(copa.cli_common, "get_db", lambda: db)
|
|
63
|
+
monkeypatch.setattr(copa.cli_internal, "get_db", lambda: db)
|
|
64
|
+
return db
|
|
65
|
+
|
|
66
|
+
def test_assigns_group(self, tmp_path, monkeypatch):
|
|
67
|
+
db = self._make_db(tmp_path, monkeypatch)
|
|
68
|
+
cmd_id = db.add_command("echo hello")
|
|
69
|
+
runner = CliRunner()
|
|
70
|
+
result = runner.invoke(cli, ["_set-group-direct", str(cmd_id), "mygroup"])
|
|
71
|
+
assert result.exit_code == 0
|
|
72
|
+
cmd = db.get_command(cmd_id)
|
|
73
|
+
assert cmd.group_name == "mygroup"
|
|
74
|
+
|
|
75
|
+
def test_clears_group_with_none(self, tmp_path, monkeypatch):
|
|
76
|
+
db = self._make_db(tmp_path, monkeypatch)
|
|
77
|
+
cmd_id = db.add_command("echo hello", group_name="old")
|
|
78
|
+
runner = CliRunner()
|
|
79
|
+
result = runner.invoke(cli, ["_set-group-direct", str(cmd_id), "(none)"])
|
|
80
|
+
assert result.exit_code == 0
|
|
81
|
+
cmd = db.get_command(cmd_id)
|
|
82
|
+
assert cmd.group_name is None
|
|
83
|
+
|
|
84
|
+
def test_no_group_arg_clears(self, tmp_path, monkeypatch):
|
|
85
|
+
db = self._make_db(tmp_path, monkeypatch)
|
|
86
|
+
cmd_id = db.add_command("echo hello", group_name="old")
|
|
87
|
+
runner = CliRunner()
|
|
88
|
+
result = runner.invoke(cli, ["_set-group-direct", str(cmd_id)])
|
|
89
|
+
assert result.exit_code == 0
|
|
90
|
+
cmd = db.get_command(cmd_id)
|
|
91
|
+
assert cmd.group_name is None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestConfigToggleHeader:
|
|
95
|
+
"""Test toggle_header key in config."""
|
|
96
|
+
|
|
97
|
+
def test_toggle_header_in_defaults(self):
|
|
98
|
+
assert "toggle_header" in DEFAULT_KEYS
|
|
99
|
+
assert DEFAULT_KEYS["toggle_header"] == "ctrl-h"
|
|
100
|
+
|
|
101
|
+
def test_toggle_header_label(self):
|
|
102
|
+
assert "toggle_header" in LABELS
|
|
103
|
+
assert LABELS["toggle_header"] == "keys"
|
|
104
|
+
|
|
105
|
+
def test_load_config_includes_toggle_header(self):
|
|
106
|
+
config = load_config()
|
|
107
|
+
assert config["toggle_header"] == "ctrl-h"
|
|
108
|
+
|
|
109
|
+
def test_emit_zsh_config_has_toggle_header_key(self):
|
|
110
|
+
config = load_config()
|
|
111
|
+
output = emit_zsh_config(config)
|
|
112
|
+
assert "_COPA_TOGGLE_HEADER_KEY='ctrl-h'" in output
|
|
113
|
+
|
|
114
|
+
def test_header_is_two_lines(self):
|
|
115
|
+
config = load_config()
|
|
116
|
+
output = emit_zsh_config(config)
|
|
117
|
+
# Find the _COPA_HEADER line
|
|
118
|
+
for line in output.split("\n"):
|
|
119
|
+
if line.startswith("_COPA_HEADER="):
|
|
120
|
+
# Should contain \\n for the 2-line split
|
|
121
|
+
assert "\\n" in line
|
|
122
|
+
break
|
|
123
|
+
else:
|
|
124
|
+
raise AssertionError("_COPA_HEADER not found in output")
|
|
125
|
+
|
|
126
|
+
def test_header_row1_has_keys_label(self):
|
|
127
|
+
config = load_config()
|
|
128
|
+
output = emit_zsh_config(config)
|
|
129
|
+
for line in output.split("\n"):
|
|
130
|
+
if line.startswith("_COPA_HEADER="):
|
|
131
|
+
assert "^H:keys" in line
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
def test_header_row2_has_action_keys(self):
|
|
135
|
+
config = load_config()
|
|
136
|
+
output = emit_zsh_config(config)
|
|
137
|
+
for line in output.split("\n"):
|
|
138
|
+
if line.startswith("_COPA_HEADER="):
|
|
139
|
+
# After the \n split, second row should have these
|
|
140
|
+
assert "^G:grp" in line
|
|
141
|
+
assert "^D:desc" in line
|
|
142
|
+
assert "^F:flag" in line
|
|
143
|
+
assert "^S:scope" in line
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class TestTtyHelpersInCommon:
|
|
148
|
+
"""Test that tty helpers are accessible from cli_common."""
|
|
149
|
+
|
|
150
|
+
def test_open_tty_importable(self):
|
|
151
|
+
from copa.cli_common import _open_tty
|
|
152
|
+
|
|
153
|
+
assert callable(_open_tty)
|
|
154
|
+
|
|
155
|
+
def test_close_tty_importable(self):
|
|
156
|
+
from copa.cli_common import _close_tty
|
|
157
|
+
|
|
158
|
+
assert callable(_close_tty)
|
|
159
|
+
|
|
160
|
+
def test_close_tty_noop_on_none(self):
|
|
161
|
+
from copa.cli_common import _close_tty
|
|
162
|
+
|
|
163
|
+
# Should not raise
|
|
164
|
+
_close_tty(None, None)
|
|
165
|
+
|
|
166
|
+
def test_cli_internal_imports_from_common(self):
|
|
167
|
+
"""cli_internal should import tty helpers from cli_common."""
|
|
168
|
+
import copa.cli_internal
|
|
169
|
+
|
|
170
|
+
assert copa.cli_internal._open_tty is not None
|
|
171
|
+
assert copa.cli_internal._close_tty is not None
|
|
172
|
+
|
|
173
|
+
def test_cli_llm_imports_from_common(self):
|
|
174
|
+
"""cli_llm should import tty helpers from cli_common."""
|
|
175
|
+
import copa.cli_llm
|
|
176
|
+
|
|
177
|
+
assert copa.cli_llm._open_tty is not None
|
|
178
|
+
assert copa.cli_llm._close_tty is not None
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
"""Shared utilities for Copa CLI modules."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from click.shell_completion import CompletionItem
|
|
6
|
-
|
|
7
|
-
from .db import Database
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def get_db() -> Database:
|
|
11
|
-
db = Database()
|
|
12
|
-
db.init_db()
|
|
13
|
-
return db
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
# --- Shell completion helpers ---
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def complete_group(ctx, param, incomplete):
|
|
20
|
-
"""Complete group names from the database."""
|
|
21
|
-
try:
|
|
22
|
-
db = get_db()
|
|
23
|
-
return [CompletionItem(g) for g in db.get_groups() if g.startswith(incomplete)]
|
|
24
|
-
except Exception:
|
|
25
|
-
return []
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def complete_shared_set(ctx, param, incomplete):
|
|
29
|
-
"""Complete shared set names from the database."""
|
|
30
|
-
try:
|
|
31
|
-
db = get_db()
|
|
32
|
-
return [CompletionItem(s.name) for s in db.get_shared_sets() if s.name.startswith(incomplete)]
|
|
33
|
-
except Exception:
|
|
34
|
-
return []
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def complete_source(ctx, param, incomplete):
|
|
38
|
-
"""Complete source values from the database."""
|
|
39
|
-
try:
|
|
40
|
-
db = get_db()
|
|
41
|
-
return [CompletionItem(s) for s in db.get_sources() if s.startswith(incomplete)]
|
|
42
|
-
except Exception:
|
|
43
|
-
return []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|