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.
Files changed (37) hide show
  1. {copa_cli-0.2.1/copa_cli.egg-info → copa_cli-0.3.0}/PKG-INFO +1 -1
  2. copa_cli-0.3.0/copa/cli_common.py +89 -0
  3. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/cli_internal.py +23 -44
  4. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/cli_llm.py +40 -17
  5. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/config.py +21 -18
  6. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/copa.zsh +34 -11
  7. {copa_cli-0.2.1 → copa_cli-0.3.0/copa_cli.egg-info}/PKG-INFO +1 -1
  8. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa_cli.egg-info/SOURCES.txt +1 -0
  9. {copa_cli-0.2.1 → copa_cli-0.3.0}/pyproject.toml +1 -1
  10. copa_cli-0.3.0/tests/test_modal.py +178 -0
  11. copa_cli-0.2.1/copa/cli_common.py +0 -43
  12. {copa_cli-0.2.1 → copa_cli-0.3.0}/LICENSE +0 -0
  13. {copa_cli-0.2.1 → copa_cli-0.3.0}/README.md +0 -0
  14. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/__init__.py +0 -0
  15. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/__main__.py +0 -0
  16. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/cli.py +0 -0
  17. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/cli_share.py +0 -0
  18. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/db.py +0 -0
  19. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/evolve.py +0 -0
  20. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/fzf.py +0 -0
  21. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/history.py +0 -0
  22. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/llm.py +0 -0
  23. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/mcp_server.py +0 -0
  24. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/models.py +0 -0
  25. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/scanner.py +0 -0
  26. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/scoring.py +0 -0
  27. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa/sharing.py +0 -0
  28. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa_cli.egg-info/dependency_links.txt +0 -0
  29. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa_cli.egg-info/entry_points.txt +0 -0
  30. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa_cli.egg-info/requires.txt +0 -0
  31. {copa_cli-0.2.1 → copa_cli-0.3.0}/copa_cli.egg-info/top_level.txt +0 -0
  32. {copa_cli-0.2.1 → copa_cli-0.3.0}/setup.cfg +0 -0
  33. {copa_cli-0.2.1 → copa_cli-0.3.0}/tests/test_cli_and_sharing.py +0 -0
  34. {copa_cli-0.2.1 → copa_cli-0.3.0}/tests/test_db.py +0 -0
  35. {copa_cli-0.2.1 → copa_cli-0.3.0}/tests/test_fzf.py +0 -0
  36. {copa_cli-0.2.1 → copa_cli-0.3.0}/tests/test_models.py +0 -0
  37. {copa_cli-0.2.1 → copa_cli-0.3.0}/tests/test_scanner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: copa-cli
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Command Palette — smart command tracking, ranking, and sharing for your shell
5
5
  Author: Mark Stanford
6
6
  License-Expression: MIT
@@ -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
- click.echo(f" [{cmd.id}] {click.style(cmd.command, bold=True)}")
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
- click.echo(f" Current: {cmd.description}")
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
- click.echo(click.style(f" Generating ({backend})...", dim=True), nl=False)
145
+ tty_write(f" Generating ({backend})...")
130
146
  suggestion = generate_description(cmd.command, backend=backend, model=model)
131
147
 
132
- if suggestion:
133
- click.echo(f"\r Suggestion: {click.style(suggestion, fg='cyan')} ")
134
- desc = input(f" Description [{suggestion}]: ").strip()
135
- if desc.lower() == "q":
136
- return
137
- if not desc:
138
- desc = suggestion
139
- else:
140
- click.echo("\r (no suggestion generated) ")
141
- desc = input(" Description: ").strip()
142
- if not desc:
143
- return
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
- click.echo(click.style(" saved", fg="green"))
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: Copa | ^R:cycle | ^G:& | ^O:2>&1 | ...
157
- header_parts = ["Copa", f"{_format_key_label('ctrl-r')}:cycle"]
158
- for action in (
159
- "background",
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
- header_parts.append(f"{_format_key_label(key)}:{label}")
174
- header = " | ".join(header_parts)
175
- lines.append(f"_COPA_HEADER='{header}'")
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='Copa | ^R:cycle | ^V:& | ^O:2>&1 | ^X:| | ^T:> | ^A:&& | ^/:quiet | ^G:grp | ^D:desc | ^F:flag | ^S:scope | ^N:↻grp'
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}:transform:
76
- group=\$(${copa_bin} _list-groups | fzf --height 40% --layout reverse --prompt 'group> ' --header 'Select group (ESC=cancel)');
77
- if [[ \$group == '(all)' ]]; then
78
- echo \"reload(${copa_bin} fzf-list --mode all)+change-prompt(copa> )\"
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 'enter:accept' \
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
- if [[ -n "$selected" ]]; then
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}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: copa-cli
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Command Palette — smart command tracking, ranking, and sharing for your shell
5
5
  Author: Mark Stanford
6
6
  License-Expression: MIT
@@ -29,5 +29,6 @@ copa_cli.egg-info/top_level.txt
29
29
  tests/test_cli_and_sharing.py
30
30
  tests/test_db.py
31
31
  tests/test_fzf.py
32
+ tests/test_modal.py
32
33
  tests/test_models.py
33
34
  tests/test_scanner.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "copa-cli"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Command Palette — smart command tracking, ranking, and sharing for your shell"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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