redo-cli 0.1.0__py3-none-any.whl

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.
main.py ADDED
@@ -0,0 +1,301 @@
1
+ import typer
2
+ from rich.prompt import Confirm, Prompt
3
+
4
+ from modules import placeholders, runner, storage, ui
5
+
6
+
7
+ VERSION = "0.1.0"
8
+ CREDIT = "credit-vibeslayer"
9
+ COMMAND_CONTEXT = {"help_option_names": ["--help", "-h"]}
10
+
11
+ app = typer.Typer(
12
+ help="Redo saves repeated terminal workflows and runs them again with one command.",
13
+ no_args_is_help=False,
14
+ context_settings={"help_option_names": []},
15
+ )
16
+
17
+
18
+ def _print_result(result):
19
+ if result["code"] == 0:
20
+ ui.print_success(result["message"])
21
+ elif result["code"] == 2:
22
+ ui.print_warning(result["message"])
23
+ else:
24
+ ui.print_error(result["message"])
25
+
26
+
27
+ def _exit_for_result(result):
28
+ return 0 if result["code"] == 0 else 1
29
+
30
+
31
+ def _failure_exit_for_result(result):
32
+ return 1 if result["code"] == 1 else 0
33
+
34
+
35
+ def _raise_for_result(result):
36
+ raise typer.Exit(code=_exit_for_result(result))
37
+
38
+
39
+ def _offer_first_run_guide():
40
+ if not storage.should_offer_first_run_guide():
41
+ return
42
+
43
+ if Confirm.ask("First time using Redo? See the quick guide first?", default=True):
44
+ ui.show_guide()
45
+
46
+ result = storage.mark_first_run_guide_seen()
47
+ if result["code"] != 0:
48
+ ui.print_warning(result["message"])
49
+
50
+
51
+ @app.callback(invoke_without_command=True)
52
+ def root(
53
+ ctx: typer.Context,
54
+ help: bool = typer.Option(False, "--help", "-h", help="Show Redo help menu."),
55
+ info: bool = typer.Option(False, "--info", help="Show Redo version and credits."),
56
+ ):
57
+ """Bookmarks for terminal workflows."""
58
+ if help:
59
+ ui.show_help_menu(VERSION)
60
+ raise typer.Exit(code=0)
61
+
62
+ if info:
63
+ ui.show_info(VERSION, CREDIT)
64
+ raise typer.Exit(code=0)
65
+
66
+ if ctx.invoked_subcommand is None:
67
+ ui.show_banner()
68
+ raise typer.Exit(code=0)
69
+
70
+
71
+ @app.command("init", context_settings=COMMAND_CONTEXT)
72
+ def init():
73
+ """Create Redo workflow storage if needed."""
74
+ result = storage.initialize_file()
75
+ _print_result(result)
76
+ raise typer.Exit(code=_failure_exit_for_result(result))
77
+
78
+
79
+ @app.command("new", context_settings=COMMAND_CONTEXT)
80
+ def new_workflow(name: str = typer.Argument(..., help="Workflow name to create.")):
81
+ """Create a reusable workflow."""
82
+ name_result = storage.validate_workflow_name(name)
83
+ if name_result["code"] != 0:
84
+ _print_result(name_result)
85
+ raise typer.Exit(code=1)
86
+
87
+ storage_result = storage.load_workflows()
88
+ if storage_result["code"] != 0:
89
+ _print_result(storage_result)
90
+ raise typer.Exit(code=1)
91
+
92
+ _offer_first_run_guide()
93
+ description = Prompt.ask("Description")
94
+ commands = []
95
+
96
+ while True:
97
+ command = Prompt.ask("Command")
98
+ if command.strip() == ":done":
99
+ break
100
+ if command.strip():
101
+ commands.append(command)
102
+
103
+ if not commands:
104
+ ui.print_warning("No commands entered. Workflow was not saved.")
105
+ raise typer.Exit(code=1)
106
+
107
+ result = storage.add_workflow(name, description, commands)
108
+ _print_result(result)
109
+ _raise_for_result(result)
110
+
111
+
112
+ @app.command("list", context_settings=COMMAND_CONTEXT)
113
+ def list_workflows():
114
+ """List saved workflows."""
115
+ result = storage.load_workflows()
116
+ if result["code"] != 0:
117
+ _print_result(result)
118
+ raise typer.Exit(code=1)
119
+
120
+ ui.show_workflows_table(result["data"])
121
+
122
+
123
+ @app.command("search", context_settings=COMMAND_CONTEXT)
124
+ def search_workflows(query: str = typer.Argument(..., help="Text to find in workflows.")):
125
+ """Search workflow names, descriptions, and commands."""
126
+ result = storage.find_workflows(query)
127
+ if result["code"] != 0:
128
+ _print_result(result)
129
+ raise typer.Exit(code=1)
130
+
131
+ ui.show_workflows_table(result["data"])
132
+
133
+
134
+ @app.command("show", context_settings=COMMAND_CONTEXT)
135
+ def show_workflow(name: str = typer.Argument(..., help="Workflow name to inspect.")):
136
+ """Show workflow details."""
137
+ result = storage.get_workflow(name)
138
+ if result["code"] != 0:
139
+ _print_result(result)
140
+ raise typer.Exit(code=1)
141
+
142
+ ui.show_workflow_details(name, result["data"])
143
+
144
+
145
+ @app.command("delete", context_settings=COMMAND_CONTEXT)
146
+ def delete_workflow(name: str = typer.Argument(..., help="Workflow name to delete.")):
147
+ """Delete a saved workflow."""
148
+ result = storage.delete_workflow(name)
149
+ _print_result(result)
150
+ _raise_for_result(result)
151
+
152
+
153
+ @app.command("clearhistory", context_settings=COMMAND_CONTEXT)
154
+ def clearhistory(
155
+ yes: bool = typer.Option(False, "--yes", "-y", help="Clear without asking for confirmation."),
156
+ ):
157
+ """Clear every saved workflow from Redo storage."""
158
+ if not yes and not Confirm.ask("Clear all saved workflows?", default=False):
159
+ ui.print_warning("clear history cancelled")
160
+ raise typer.Exit(code=1)
161
+
162
+ result = storage.clear_workflows()
163
+ _print_result(result)
164
+ _raise_for_result(result)
165
+
166
+
167
+ @app.command("guide", context_settings=COMMAND_CONTEXT)
168
+ def guide():
169
+ """Show the Redo quick-start guide."""
170
+ ui.show_guide()
171
+
172
+
173
+ @app.command("copy", context_settings=COMMAND_CONTEXT)
174
+ def copy_workflow(
175
+ source: str = typer.Argument(..., help="Workflow to copy."),
176
+ target: str = typer.Argument(..., help="New workflow name."),
177
+ ):
178
+ """Copy a workflow into a new workflow name."""
179
+ result = storage.copy_workflow(source, target)
180
+ _print_result(result)
181
+ _raise_for_result(result)
182
+
183
+
184
+ @app.command("rename", context_settings=COMMAND_CONTEXT)
185
+ def rename_workflow(
186
+ old_name: str = typer.Argument(..., help="Current workflow name."),
187
+ new_name: str = typer.Argument(..., help="New workflow name."),
188
+ ):
189
+ """Rename a saved workflow."""
190
+ result = storage.rename_workflow(old_name, new_name)
191
+ _print_result(result)
192
+ _raise_for_result(result)
193
+
194
+
195
+ @app.command("run", context_settings=COMMAND_CONTEXT)
196
+ def run_workflow(
197
+ name: str = typer.Argument(..., help="Workflow name to run."),
198
+ dry: bool = typer.Option(False, "--dry", help="Preview commands without running them."),
199
+ ):
200
+ """Run a saved workflow."""
201
+ result = storage.get_workflow(name)
202
+ if result["code"] != 0:
203
+ _print_result(result)
204
+ raise typer.Exit(code=1)
205
+
206
+ commands = placeholders.process_commands(result["data"].get("commands", []))
207
+ if dry:
208
+ ui.show_commands(commands)
209
+
210
+ run_result = runner.run_workflow_commands(commands, dry_run=dry)
211
+ _print_result(run_result)
212
+
213
+ if run_result["code"] == 0 and not dry:
214
+ increment_result = storage.increment_runs(name)
215
+ if increment_result["code"] != 0:
216
+ _print_result(increment_result)
217
+ raise typer.Exit(code=1)
218
+ elif run_result["code"] != 0:
219
+ raise typer.Exit(code=1)
220
+
221
+
222
+ @app.command("stats", context_settings=COMMAND_CONTEXT)
223
+ def stats():
224
+ """Show workflow usage stats."""
225
+ result = storage.load_workflows()
226
+ if result["code"] != 0:
227
+ _print_result(result)
228
+ raise typer.Exit(code=1)
229
+
230
+ ui.show_stats(result["data"])
231
+
232
+
233
+ @app.command("path", context_settings=COMMAND_CONTEXT)
234
+ def workflow_path():
235
+ """Show where Redo stores workflows for this directory."""
236
+ typer.echo(str(storage.DATA_FILE.resolve()))
237
+
238
+
239
+ @app.command("export", context_settings=COMMAND_CONTEXT)
240
+ def export_workflows(destination: str = typer.Argument(..., help="JSON file to write.")):
241
+ """Export workflows to a JSON backup file."""
242
+ result = storage.export_workflows(destination)
243
+ _print_result(result)
244
+ _raise_for_result(result)
245
+
246
+
247
+ @app.command("import", context_settings=COMMAND_CONTEXT)
248
+ def import_workflows(
249
+ source: str = typer.Argument(..., help="JSON workflow file to import."),
250
+ replace: bool = typer.Option(False, "--replace", help="Replace existing workflows."),
251
+ ):
252
+ """Import workflows from a JSON file."""
253
+ result = storage.import_workflows(source, replace=replace)
254
+ _print_result(result)
255
+ _raise_for_result(result)
256
+
257
+
258
+ @app.command("doctor", context_settings=COMMAND_CONTEXT)
259
+ def doctor():
260
+ """Check workflow storage and flag risky saved commands."""
261
+ result = storage.load_workflows()
262
+ if result["code"] != 0:
263
+ _print_result(result)
264
+ raise typer.Exit(code=1)
265
+
266
+ workflows = result["data"]
267
+ dangerous_commands = []
268
+ placeholder_names = set()
269
+
270
+ for name, workflow in workflows.items():
271
+ for command in workflow.get("commands", []):
272
+ placeholder_names.update(placeholders.find_placeholders(command))
273
+ if runner.is_dangerous_command(command):
274
+ dangerous_commands.append((name, command))
275
+
276
+ report = {
277
+ "path": str(storage.DATA_FILE.resolve()),
278
+ "exists": storage.DATA_FILE.exists(),
279
+ "total_workflows": len(workflows),
280
+ "total_commands": sum(len(workflow.get("commands", [])) for workflow in workflows.values()),
281
+ "placeholder_count": len(placeholder_names),
282
+ "dangerous_count": len(dangerous_commands),
283
+ "dangerous_commands": dangerous_commands,
284
+ }
285
+ ui.show_doctor_report(report)
286
+
287
+
288
+ @app.command("autofix", context_settings=COMMAND_CONTEXT)
289
+ def autofix():
290
+ """Fix common Redo storage problems automatically."""
291
+ result = storage.autofix_storage()
292
+ _print_result(result)
293
+
294
+ for fix in result.get("data", {}).get("fixes", []):
295
+ ui.console.print(f"- {fix}")
296
+
297
+ _raise_for_result(result)
298
+
299
+
300
+ if __name__ == "__main__":
301
+ app()
modules/__init__.py ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,134 @@
1
+ import os
2
+ import re
3
+ import shlex
4
+
5
+ from rich.prompt import Prompt
6
+
7
+
8
+ PLACEHOLDER_PATTERN = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
9
+ QUOTED_PLACEHOLDER_PATTERN = re.compile(r"(?P<quote>['\"])\{([A-Za-z_][A-Za-z0-9_]*)\}(?P=quote)")
10
+ prompt = Prompt.ask
11
+
12
+
13
+ def find_placeholders(text):
14
+ placeholders = []
15
+ seen = set()
16
+
17
+ for match in PLACEHOLDER_PATTERN.finditer(text):
18
+ name = match.group(1)
19
+ if name not in seen:
20
+ placeholders.append(name)
21
+ seen.add(name)
22
+
23
+ return placeholders
24
+
25
+
26
+ def find_placeholders_in_commands(commands):
27
+ placeholders = []
28
+ seen = set()
29
+
30
+ for command in commands:
31
+ for name in find_placeholders(command):
32
+ if name not in seen:
33
+ placeholders.append(name)
34
+ seen.add(name)
35
+
36
+ return placeholders
37
+
38
+
39
+ def collect_placeholder_values(placeholders):
40
+ return {name: prompt(name) for name in placeholders}
41
+
42
+
43
+ def _normalize_placeholder_value(value):
44
+ normalized = str(value).replace("\r", " ").replace("\n", " ").replace("\x00", "")
45
+ return " ".join(normalized.split())
46
+
47
+
48
+ def _quote_cmd_value(value):
49
+ escaped = []
50
+ for character in value:
51
+ if character in '^&|<>()%!"':
52
+ escaped.append(f"^{character}")
53
+ else:
54
+ escaped.append(character)
55
+ return f'"{"".join(escaped)}"'
56
+
57
+
58
+ def _escape_cmd_value(value):
59
+ return _quote_cmd_value(value)[1:-1]
60
+
61
+
62
+ def _escape_posix_double_quoted_value(value):
63
+ escaped = []
64
+ for character in value:
65
+ if character in '\\$`"':
66
+ escaped.append(f"\\{character}")
67
+ else:
68
+ escaped.append(character)
69
+ return "".join(escaped)
70
+
71
+
72
+ def _escape_posix_single_quoted_value(value):
73
+ return value.replace("'", "'\\''")
74
+
75
+
76
+ def _shell_quote_placeholder_value(value):
77
+ normalized = _normalize_placeholder_value(value)
78
+ if os.name == "nt":
79
+ return _quote_cmd_value(normalized)
80
+ return shlex.quote(normalized)
81
+
82
+
83
+ def _quote_context(text, position):
84
+ quote = None
85
+ escaped = False
86
+ for character in text[:position]:
87
+ if escaped:
88
+ escaped = False
89
+ continue
90
+ if character == "\\" and quote != "'":
91
+ escaped = True
92
+ continue
93
+ if character in "'\"":
94
+ if quote == character:
95
+ quote = None
96
+ elif quote is None:
97
+ quote = character
98
+ return quote
99
+
100
+
101
+ def _placeholder_value_for_context(value, quote):
102
+ normalized = _normalize_placeholder_value(value)
103
+ if quote is None:
104
+ return _shell_quote_placeholder_value(normalized)
105
+ if os.name == "nt":
106
+ return _escape_cmd_value(normalized)
107
+ if quote == "'":
108
+ return _escape_posix_single_quoted_value(normalized)
109
+ return _escape_posix_double_quoted_value(normalized)
110
+
111
+
112
+ def replace_placeholders(text, values):
113
+ def quoted_replacement(match):
114
+ name = match.group(2)
115
+ if name not in values:
116
+ return match.group(0)
117
+ return _shell_quote_placeholder_value(values[name])
118
+
119
+ text = QUOTED_PLACEHOLDER_PATTERN.sub(quoted_replacement, text)
120
+
121
+ def replacement(match):
122
+ name = match.group(1)
123
+ if name not in values:
124
+ return match.group(0)
125
+ quote = _quote_context(text, match.start())
126
+ return _placeholder_value_for_context(values[name], quote)
127
+
128
+ return PLACEHOLDER_PATTERN.sub(replacement, text)
129
+
130
+
131
+ def process_commands(commands):
132
+ names = find_placeholders_in_commands(commands)
133
+ values = collect_placeholder_values(names)
134
+ return [replace_placeholders(command, values) for command in commands]