diff --git a/CHANGELOG.md b/CHANGELOG.md index f25558fc..cb790742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Added - Plugin system: executables named `tk-` or `ticket-` in PATH are invoked automatically +- Plugin discovery from install-private `libexec/ticket/plugins` directories, with `TK_PLUGIN_DIR` override support - `super` command to bypass plugins and run built-in commands directly - `TICKETS_DIR` and `TK_SCRIPT` environment variables exported for plugins - `help` command lists installed plugins with descriptions diff --git a/features/steps/ticket_steps.py b/features/steps/ticket_steps.py index 54d3ab3f..bc515f51 100644 --- a/features/steps/ticket_steps.py +++ b/features/steps/ticket_steps.py @@ -27,6 +27,16 @@ def get_ticket_script(context): return str(Path(context.project_dir) / 'ticket') +def command_env(context): + """Build an environment for running ticket commands in tests.""" + env = os.environ.copy() + if hasattr(context, 'plugin_dir'): + env['PATH'] = context.plugin_dir + ':' + env.get('PATH', '') + if hasattr(context, 'plugin_libexec_dir'): + env['TK_PLUGIN_DIR'] = context.plugin_libexec_dir + return env + + def create_ticket(context, ticket_id, title, priority=2, parent=None): """Helper to create a ticket file.""" tickets_dir = Path(context.test_dir) / '.tickets' @@ -267,7 +277,7 @@ def step_run_command_with_env(context, command, tickets_dir): cwd = getattr(context, 'working_dir', context.test_dir) # Resolve tickets_dir relative to test_dir - env = os.environ.copy() + env = command_env(context) env['TICKETS_DIR'] = str(Path(context.test_dir) / tickets_dir) result = subprocess.run( @@ -299,10 +309,7 @@ def step_run_command(context, command): # Use working_dir if set (from subdirectory step), otherwise test_dir cwd = getattr(context, 'working_dir', context.test_dir) - # Include plugin directory in PATH if plugins were created - env = os.environ.copy() - if hasattr(context, 'plugin_dir'): - env['PATH'] = context.plugin_dir + ':' + env.get('PATH', '') + env = command_env(context) result = subprocess.run( cmd, @@ -633,6 +640,20 @@ def create_plugin(context, name, content): return plugin_path +def create_libexec_plugin(context, name, content): + """Helper to create a plugin script in the test libexec plugin directory.""" + if not hasattr(context, 'plugin_libexec_dir'): + context.plugin_libexec_dir = str(Path(context.test_dir) / 'libexec_plugins') + + plugin_dir = Path(context.plugin_libexec_dir) + plugin_dir.mkdir(parents=True, exist_ok=True) + + plugin_path = plugin_dir / name + plugin_path.write_text(content) + plugin_path.chmod(0o755) + return plugin_path + + def run_with_plugin_path(context, command): """Run a command with the plugin directory in PATH.""" command = command.replace('\\"', '"') @@ -641,9 +662,7 @@ def run_with_plugin_path(context, command): cwd = getattr(context, 'working_dir', context.test_dir) - env = os.environ.copy() - if hasattr(context, 'plugin_dir'): - env['PATH'] = context.plugin_dir + ':' + env.get('PATH', '') + env = command_env(context) result = subprocess.run( cmd, @@ -715,6 +734,38 @@ def step_plugin_with_description(context, name, desc): create_plugin(context, name, content) +@given(r'a bundled plugin "(?P[^"]+)" that outputs "(?P[^"]+)"') +def step_bundled_plugin_outputs(context, name, output): + """Create a bundled plugin that outputs a fixed string.""" + content = f'''#!/usr/bin/env bash +# tk-plugin: Test bundled plugin +echo "{output}" +''' + create_libexec_plugin(context, name, content) + + +@given(r'a bundled plugin "(?P[^"]+)" with description "(?P[^"]+)"') +def step_bundled_plugin_with_description(context, name, desc): + """Create a bundled plugin with a specific description.""" + content = f'''#!/usr/bin/env bash +# tk-plugin: {desc} +echo "bundled plugin executed" +''' + create_libexec_plugin(context, name, content) + + +@given(r'a bundled plugin "(?P[^"]+)" that outputs plugin context and arguments') +def step_bundled_plugin_outputs_context(context, name): + """Create a bundled plugin that outputs env context and received args.""" + content = '''#!/usr/bin/env bash +# tk-plugin: Output plugin context +printf '%s\\n' "$TICKETS_DIR" +printf '%s\\n' "$TK_SCRIPT" +echo "$@" +''' + create_libexec_plugin(context, name, content) + + @given(r'a plugin "(?P[^"]+)" that outputs "(?P[^"]+)" without metadata') def step_plugin_no_metadata(context, name, output): """Create a plugin without tk-plugin metadata comment.""" diff --git a/features/ticket_plugins.feature b/features/ticket_plugins.feature index ee70bf4f..67ce2037 100644 --- a/features/ticket_plugins.feature +++ b/features/ticket_plugins.feature @@ -21,6 +21,18 @@ Feature: Plugin System Then the command should succeed And the output should be "Greetings!" + Scenario: Bundled plugin is executed for unknown command + Given a bundled plugin "tk-libhello" that outputs "Hello from bundled plugin!" + When I run "ticket libhello" + Then the command should succeed + And the output should be "Hello from bundled plugin!" + + Scenario: Bundled ticket- prefix plugins are also discovered + Given a bundled plugin "ticket-bundlegreet" that outputs "Bundled greetings!" + When I run "ticket bundlegreet" + Then the command should succeed + And the output should be "Bundled greetings!" + Scenario: tk- prefix takes precedence over ticket- prefix Given a plugin "tk-test" that outputs "tk-prefix" And a plugin "ticket-test" that outputs "ticket-prefix" @@ -28,6 +40,27 @@ Feature: Plugin System Then the command should succeed And the output should be "tk-prefix" + Scenario: tk- prefix takes precedence over ticket- prefix in bundled plugins + Given a bundled plugin "tk-bundlepick" that outputs "tk-prefix" + And a bundled plugin "ticket-bundlepick" that outputs "ticket-prefix" + When I run "ticket bundlepick" + Then the command should succeed + And the output should be "tk-prefix" + + Scenario: PATH plugin takes precedence over bundled plugin with same name + Given a plugin "tk-pathwins" that outputs "path-prefix" + And a bundled plugin "tk-pathwins" that outputs "bundled-prefix" + When I run "ticket pathwins" + Then the command should succeed + And the output should be "path-prefix" + + Scenario: PATH tk- plugin takes precedence over bundled ticket- plugin + Given a plugin "tk-pathbundle" that outputs "path-prefix" + And a bundled plugin "ticket-pathbundle" that outputs "bundled-ticket-prefix" + When I run "ticket pathbundle" + Then the command should succeed + And the output should be "path-prefix" + Scenario: Super command bypasses plugins Given a clean tickets directory And a plugin "tk-create" that outputs "plugin create" @@ -55,6 +88,13 @@ Feature: Plugin System And the output should contain "myplugin" And the output should contain "My custom plugin" + Scenario: Help command lists bundled plugins + Given a bundled plugin "tk-bundlehelp" with description "Bundled custom plugin" + When I run "ticket help" + Then the command should succeed + And the output should contain "bundlehelp" + And the output should contain "Bundled custom plugin" + Scenario: Help shows plugins without description as no description Given a plugin "tk-nodesc" that outputs "test" without metadata When I run "ticket help" @@ -62,6 +102,15 @@ Feature: Plugin System And the output should contain "nodesc" And the output should contain "(no description)" + Scenario: Bundled plugin receives environment and command arguments + Given a clean tickets directory + And a bundled plugin "tk-bundlectx" that outputs plugin context and arguments + When I run "ticket bundlectx alpha beta" + Then the command should succeed + And the output should contain ".tickets" + And the output should contain "ticket" + And the output should contain "alpha beta" + Scenario: Plugin can call built-in commands via super Given a clean tickets directory And a plugin "tk-wrapper" that calls super create diff --git a/ticket b/ticket index 0aea72c1..a8c75697 100755 --- a/ticket +++ b/ticket @@ -1222,6 +1222,51 @@ cmd_add_note() { echo "Note added to $(basename "$file" .md)" } +_tk_script_path() { + printf '%s/%s\n' "$(cd "$(dirname "$0")" && pwd)" "$(basename "$0")" +} + +_plugin_libexec_dir() { + if [[ -n "${TK_PLUGIN_DIR:-}" ]]; then + printf '%s\n' "$TK_PLUGIN_DIR" + return 0 + fi + + printf '%s/../libexec/ticket/plugins\n' "$(cd "$(dirname "$0")" && pwd)" +} + +_resolve_plugin() { + local plugin="$1" path plugin_dir + + if path=$(command -v "$plugin" 2>/dev/null); then + [[ -n "$path" ]] && { printf '%s\n' "$path"; return 0; } + fi + + plugin_dir=$(_plugin_libexec_dir) + path="$plugin_dir/$plugin" + if [[ -x "$path" && ! -d "$path" ]]; then + printf '%s\n' "$path" + return 0 + fi + + return 1 +} + +_plugin_candidates() { + local prefix="$1" plugin_dir plugin_path + + compgen -c "${prefix}-" 2>/dev/null | sort -u || true + + plugin_dir=$(_plugin_libexec_dir) + if [[ -d "$plugin_dir" ]]; then + for plugin_path in "$plugin_dir"/"${prefix}-"*; do + [[ -e "$plugin_path" ]] || continue + [[ -x "$plugin_path" && ! -d "$plugin_path" ]] || continue + printf '%s\n' "${plugin_path##*/}" + done | sort -u + fi +} + # List installed plugins with descriptions # Scripts: # tk-plugin: description (comment in first 10 lines) # Binaries: --tk-describe flag outputs description @@ -1237,7 +1282,7 @@ _list_plugins() { case " $seen " in *" $cmd "*) continue ;; esac seen="$seen $cmd" - path=$(command -v "$plugin" 2>/dev/null) || continue + path=$(_resolve_plugin "$plugin") || continue [[ -f "$path" ]] || continue desc="" @@ -1253,7 +1298,7 @@ _list_plugins() { fi printf " %-22s %s\n" "$cmd" "${desc:-(no description)}" - done < <(compgen -c "${prefix}-" 2>/dev/null | sort -u) + done < <(_plugin_candidates "$prefix") done } @@ -1300,7 +1345,7 @@ EOF if [[ -n "$plugins" ]]; then cat << EOF -Plugins (tk- or ticket- in PATH): +Plugins (tk- or ticket-): $plugins EOF fi @@ -1331,7 +1376,7 @@ fi if [[ $_tk_super -eq 0 && -n "${1:-}" && "${1:-}" != "help" && "${1:-}" != "--help" && "${1:-}" != "-h" ]]; then for _prefix in tk ticket; do _plugin="${_prefix}-$1" - if command -v "$_plugin" &>/dev/null; then + if _plugin_path=$(_resolve_plugin "$_plugin"); then # Export context for plugins if [[ -z "${TICKETS_DIR:-}" ]]; then if _found=$(find_tickets_dir 2>/dev/null); then @@ -1345,9 +1390,9 @@ if [[ $_tk_super -eq 0 && -n "${1:-}" && "${1:-}" != "help" && "${1:-}" != "--he fi export TICKETS_DIR fi - export TK_SCRIPT="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" + export TK_SCRIPT="$(_tk_script_path)" shift - exec "$_plugin" "$@" + exec "$_plugin_path" "$@" fi done fi