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
3 changes: 3 additions & 0 deletions .changes/unreleased/added-20260101-125718.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: added
body: Support fab command to start interactive (repl) mode
time: 2026-01-01T12:57:18.777151041Z
20 changes: 15 additions & 5 deletions src/fabric_cli/commands/config/fab_config_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,21 @@ def _handle_fab_config_mode(previous_mode: str, current_mode: str) -> None:
Context().cleanup_context_files(cleanup_all_stale=True, cleanup_current=True)

if current_mode == fab_constant.FAB_MODE_INTERACTIVE:
utils_ui.print("Switching to interactive mode...")
# Show deprecation warning
utils_ui.print_warning(
"Mode configuration is deprecated. Running 'fab' now automatically enters interactive mode."
)
utils_ui.print("Starting interactive mode...")
from fabric_cli.core.fab_interactive import start_interactive_mode
start_interactive_mode()

elif (current_mode == fab_constant.FAB_MODE_COMMANDLINE
and previous_mode == fab_constant.FAB_MODE_INTERACTIVE):
utils_ui.print("Exiting interactive mode. Goodbye!")
os._exit(0)
elif current_mode == fab_constant.FAB_MODE_COMMANDLINE:
# Show deprecation warning with better messaging
utils_ui.print_warning(
"Mode configuration is deprecated. Running 'fab' now automatically enters interactive mode."
)
utils_ui.print("Configuration saved for backward compatibility.")

if previous_mode == fab_constant.FAB_MODE_INTERACTIVE:
utils_ui.print("Exiting interactive mode. Goodbye!")
os._exit(0)
2 changes: 1 addition & 1 deletion src/fabric_cli/core/fab_constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@

# Interactive command constants
INTERACTIVE_QUIT_COMMANDS = ["quit", "q", "exit"]
INTERACTIVE_HELP_COMMANDS = ["help", "h", "fab", "-h", "--help"]
INTERACTIVE_HELP_COMMANDS = ["help", "h", "-h", "--help"]
INTERACTIVE_VERSION_COMMANDS = ["version", "v", "-v", "--version"]

# Platform metadata
Expand Down
76 changes: 49 additions & 27 deletions src/fabric_cli/core/fab_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ class InteractiveCLI:
def __init__(self, parser=None, subparsers=None):
"""Initialize the interactive CLI."""
if parser is None or subparsers is None:
from fabric_cli.core.fab_parser_setup import get_global_parser_and_subparsers
from fabric_cli.core.fab_parser_setup import (
get_global_parser_and_subparsers,
)

parser, subparsers = get_global_parser_and_subparsers()

self.parser = parser
self.parser.set_mode(fab_constant.FAB_MODE_INTERACTIVE)
self.subparsers = subparsers
Expand All @@ -47,22 +50,27 @@ def handle_command(self, command):
"""Process the user command."""
fab_logger.print_log_file_path()

command_parts = shlex.split(command.strip()) # Split the command into parts
command_parts = shlex.split(command.strip())

# Handle special commands first
if command in fab_constant.INTERACTIVE_QUIT_COMMANDS:
utils_ui.print(fab_constant.INTERACTIVE_EXIT_MESSAGE)
return True # Exit
return True
elif command in fab_constant.INTERACTIVE_HELP_COMMANDS:
utils_ui.display_help(
fab_commands.COMMANDS, "Usage: <command> <subcommand> [flags]"
)
return False # Do not exit
return False
elif command in fab_constant.INTERACTIVE_VERSION_COMMANDS:
utils_ui.print_version()
return False # Do not exit
return False
elif command.strip() == "fab":
utils_ui.print(
"In interactive mode, commands don't require the fab prefix. Use --help to view the list of supported commands."
)
return False
elif not command.strip():
return False

# Interactive mode
self.parser.set_mode(fab_constant.FAB_MODE_INTERACTIVE)

# Now check for subcommands
Expand All @@ -88,10 +96,9 @@ def handle_command(self, command):
f"No function associated with the command: {command.strip()}"
)
except SystemExit:
# Catch SystemExit raised by ArgumentParser and prevent exiting
return
else:
self.parser.error(f"invalid choice: '{command.strip()}'")
self.parser.error(f"invalid choice: '{command.strip()}'. Type 'help' for available commands.")

return False

Expand All @@ -102,31 +109,46 @@ def start_interactive(self):
return

self._is_running = True

try:
utils_ui.print("\nWelcome to the Fabric CLI ⚑")
utils_ui.print("Type 'help' for help. \n")

while True:
context = Context().context
pwd_context = f"/{context.path.strip('/')}"

prompt_text = HTML(
f"<prompt>fab</prompt><detail>:</detail><context>{html.escape(pwd_context)}</context><detail>$</detail> "
)

user_input = self.session.prompt(
prompt_text,
style=self.custom_style,
cursor=CursorShape.BLINKING_BEAM,
enable_history_search=True,
)
should_exit = self.handle_command(user_input)
if should_exit: # Check if the command was to exit
break
try:
context = Context().context
pwd_context = f"/{context.path.strip('/')}"

prompt_text = HTML(
f"<prompt>fab</prompt><detail>:</detail><context>{html.escape(pwd_context)}</context><detail>$</detail> "
)

user_input = self.session.prompt(
prompt_text,
style=self.custom_style,
cursor=CursorShape.BLINKING_BEAM,
enable_history_search=True,
)
should_exit = self.handle_command(user_input)
if should_exit: # Check if the command was to exit
break

except KeyboardInterrupt:
# Handle Ctrl+C gracefully during command input
utils_ui.print("\nUse 'quit' or 'exit' to leave interactive mode.")
continue
except Exception as e:
# Handle unexpected errors during prompt processing
utils_ui.print(f"Error in interactive session: {str(e)}")
utils_ui.print("Session will continue. Type 'quit' to exit safely.")
continue
Comment on lines +136 to +144
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you explain why we need these changes?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

today when user hit Ctrl+C we exit the interactive mode.
with this change we will exit only on quit/exit same as other platform.

regarding the second exception, these lines handle exceptions in the interactive session loop, they catch errors that occur during: Context retrieval, Prompt rendering, Session management issues
These errors shouldn't terminate the interactive session. The continue statement keeps the session alive, which is the correct behavior for interactive mode (roo :)).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What will happen if user types ctrl+c? It should interrupt the current running command. There are other Python REPL keyboard shortcuts that should terminate the interactive mode - CTRL+D for example, will it work?

Copy link
Collaborator Author

@aviatco aviatco Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if user types ctrl+c the command will be interrupt but will not terminate the interactive mode - to exit interactive mode user should use quit/exit
I checked ctrl +d - it fails into the error section getting this:
Error in interactive session:
Session will continue. Type 'quit' to exit safely.

Do you think it is wrong?


except (EOFError, KeyboardInterrupt):
utils_ui.print(f"\n{fab_constant.INTERACTIVE_EXIT_MESSAGE}")
except Exception as e:
# Handle critical errors that would terminate the session
utils_ui.print(f"\nCritical error in interactive mode: {str(e)}")
utils_ui.print(fab_constant.INTERACTIVE_EXIT_MESSAGE)
finally:
self._is_running = False

Expand Down
82 changes: 57 additions & 25 deletions src/fabric_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
from fabric_cli.core import fab_constant, fab_logger, fab_state_config
from fabric_cli.core.fab_commands import Command
from fabric_cli.core.fab_exceptions import FabricCLIError
from fabric_cli.core.fab_interactive import start_interactive_mode
from fabric_cli.core.fab_parser_setup import get_global_parser_and_subparsers
from fabric_cli.parsers import fab_auth_parser as auth_parser
from fabric_cli.utils import fab_ui
from fabric_cli.utils.fab_commands import COMMANDS
from fabric_cli.core.fab_interactive import start_interactive_mode
from fabric_cli.core.fab_parser_setup import get_global_parser_and_subparsers


def main():
Expand All @@ -25,6 +25,7 @@ def main():

try:
fab_state_config.init_defaults()

if args.command == "auth" and args.auth_command == None:
auth_parser.show_help(args)
return
Expand All @@ -35,8 +36,8 @@ def main():
fab_state_config.get_config(fab_constant.FAB_MODE)
== fab_constant.FAB_MODE_INTERACTIVE
):
# Use shared interactive mode startup
start_interactive_mode()
return

if args.command == "auth" and args.auth_command == "logout":
login.logout(args)
Expand All @@ -47,6 +48,7 @@ def main():
return

last_exit_code = fab_constant.EXIT_CODE_SUCCESS

if args.command:
if args.command not in ["auth"]:
fab_logger.print_log_file_path()
Expand All @@ -56,21 +58,21 @@ def main():
commands_execs = 0
for index, command in enumerate(args.command):
command_parts = command.strip().split()
subparser = subparsers.choices[command_parts[0]]
subparser_args = subparser.parse_args(command_parts[1:])
subparser_args.command = command_parts[0]
last_exit_code = _execute_command(
subparser_args, subparsers, parser
)
commands_execs += 1
if index != len(args.command) - 1:
fab_ui.print_grey("------------------------------")
if command_parts: # Ensure we have valid command parts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this required? Isn't "if args.command" above sufficient?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if I understand the flow correctly, here is the case where we have concat commands (in command line mode) so args.command means we gave command provided, command_parts is after we split the commands and here, we check if indeed we have more than one command provided.

subparser = subparsers.choices[command_parts[0]]
subparser_args = subparser.parse_args(command_parts[1:])
subparser_args.command = command_parts[0]
last_exit_code = _execute_command(
subparser_args, subparsers, parser
)
commands_execs += 1
if index != len(args.command) - 1:
fab_ui.print_grey("------------------------------")
if commands_execs > 1:
fab_ui.print("\n")
fab_ui.print_output_format(
args, message=f"{len(args.command)} commands executed."
)

else:
last_exit_code = _execute_command(args, subparsers, parser)

Expand All @@ -82,26 +84,55 @@ def main():
elif args.version:
fab_ui.print_version()
else:
# Display help if "fab"
fab_ui.display_help(COMMANDS)
# AUTO-REPL: When no command is provided, automatically enter interactive mode
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you consider using a dedicated command? for example, in azure-cli the command is "az interactive". Not sure if this was already discussed and agreed.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the idea was to implement the logic similar to how it is done in node/python etc... for example to start REPL in python you just type python + enter

_start_auto_repl()

except KeyboardInterrupt:
fab_ui.print_output_error(
FabricCLIError(
"Operation cancelled",
fab_constant.ERROR_OPERATION_CANCELLED,
),
output_format_type=args.output_format,
)
sys.exit(fab_constant.EXIT_CODE_CANCELLED_OR_MISUSE_BUILTINS)
_handle_keyboard_interrupt(args)
except Exception as err:
_handle_unexpected_error(err, args)


def _start_auto_repl():
"""Start Auto-REPL with proper initialization and error handling."""
try:
start_interactive_mode()
except Exception as err:
fab_ui.print_output_error(
FabricCLIError(err.args[0], fab_constant.ERROR_UNEXPECTED_ERROR),
output_format_type=args.output_format,
FabricCLIError(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move it inside start_interactive_mode? just the printing and re-throw. I guess we would like to print it in all places where we call start_interactive_mode

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually it might be redundant since we have try catch inside start_interactive_mode (in start_interactive). will check it

f"Failed to start interactive mode: {str(err)}",
fab_constant.ERROR_UNEXPECTED_ERROR
)
)
sys.exit(fab_constant.EXIT_CODE_ERROR)


def _handle_keyboard_interrupt(args):
"""Handle KeyboardInterrupt with proper error formatting."""
fab_ui.print_output_error(
FabricCLIError(
"Operation cancelled",
fab_constant.ERROR_OPERATION_CANCELLED,
),
output_format_type=args.output_format,
)
sys.exit(fab_constant.EXIT_CODE_CANCELLED_OR_MISUSE_BUILTINS)


def _handle_unexpected_error(err, args):
"""Handle unexpected errors with proper error formatting."""
try:
error_message = str(err.args[0]) if err.args else str(err)
except:
error_message = "An unexpected error occurred"

fab_ui.print_output_error(
FabricCLIError(error_message, fab_constant.ERROR_UNEXPECTED_ERROR),
output_format_type=args.output_format,
)
sys.exit(fab_constant.EXIT_CODE_ERROR)


def _execute_command(args, subparsers, parser):
if args.command in subparsers.choices:
subparser_args = args
Expand All @@ -120,3 +151,4 @@ def _execute_command(args, subparsers, parser):

if __name__ == "__main__":
main()

Loading
Loading