Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

### Added
- Plugin system: executables named `tk-<cmd>` or `ticket-<cmd>` 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
Expand Down
67 changes: 59 additions & 8 deletions features/steps/ticket_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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('\\"', '"')
Expand All @@ -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,
Expand Down Expand Up @@ -715,6 +734,38 @@ def step_plugin_with_description(context, name, desc):
create_plugin(context, name, content)


@given(r'a bundled plugin "(?P<name>[^"]+)" that outputs "(?P<output>[^"]+)"')
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<name>[^"]+)" with description "(?P<desc>[^"]+)"')
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<name>[^"]+)" 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<name>[^"]+)" that outputs "(?P<output>[^"]+)" without metadata')
def step_plugin_no_metadata(context, name, output):
"""Create a plugin without tk-plugin metadata comment."""
Expand Down
49 changes: 49 additions & 0 deletions features/ticket_plugins.feature
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,46 @@ 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"
When I run "ticket test"
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"
Expand Down Expand Up @@ -55,13 +88,29 @@ 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"
Then the command should succeed
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
Expand Down
57 changes: 51 additions & 6 deletions ticket
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=""
Expand All @@ -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
}

Expand Down Expand Up @@ -1300,7 +1345,7 @@ EOF
if [[ -n "$plugins" ]]; then
cat << EOF

Plugins (tk-<cmd> or ticket-<cmd> in PATH):
Plugins (tk-<cmd> or ticket-<cmd>):
$plugins
EOF
fi
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down