lazyops-cli 1.0.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.
commands/__init__.py ADDED
File without changes
commands/list.py ADDED
@@ -0,0 +1,22 @@
1
+ import typer
2
+
3
+ from registry.catalog import CatalogError, iter_installed_workflows
4
+
5
+
6
+ def register(app:typer.Typer):
7
+
8
+ @app.command("list",help="List all commands")
9
+ def list_workflows():
10
+ try:
11
+ items = iter_installed_workflows()
12
+ except CatalogError as exc:
13
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
14
+ raise typer.Exit(1)
15
+ if not items:
16
+ typer.echo("No workflows found.")
17
+ typer.echo("Install packs: lazyops pack add aws")
18
+ return
19
+ for i, item in enumerate(items, start=1):
20
+ wf = item["workflow"]
21
+ name = wf.get("name", item["plugin"])
22
+ typer.echo(f"{i}. {item['pack']}/{item['plugin']} — {name}")
commands/pack.py ADDED
@@ -0,0 +1,75 @@
1
+ import typer
2
+
3
+ from registry import packs as pack_registry
4
+ from registry.fetch import FetchError, list_pack_plugins
5
+ from registry.source import SourceError, get_source
6
+
7
+ pack_app = typer.Typer(help="Manage installed workflow packs (aws,kubernetes,...)")
8
+
9
+ def register(app: typer.Typer):
10
+ app.add_typer(pack_app,name="pack")
11
+
12
+ @pack_app.command("add")
13
+ def pack_add(name:str=typer.Argument(...,help="Pack id eg. aws")):
14
+ try:
15
+ pack_registry.add_pack(name)
16
+ except pack_registry.PackError as exc:
17
+ typer.secho(str(exc),fg=typer.colors.RED, err=True)
18
+ raise typer.Exit(1)
19
+
20
+ typer.secho(f"Installed pack: {name}", fg=typer.colors.GREEN)
21
+ typer.secho("Run workflow with: lazyops run <pack>/<plugin>")
22
+
23
+ @pack_app.command("list")
24
+ def pack_list(pack: str | None = typer.Argument(None, help="Optional pack id to list plugins")):
25
+ if pack is None:
26
+ items = pack_registry.list_packs()
27
+ if not items:
28
+ typer.echo("No packs installed.")
29
+ typer.echo("Install one: lazyops pack add aws")
30
+ return
31
+ try:
32
+ source = get_source()
33
+ except SourceError as exc:
34
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
35
+ raise typer.Exit(1)
36
+ for name in items:
37
+ try:
38
+ plugins = list_pack_plugins(
39
+ source["url"],
40
+ source["ref"],
41
+ source.get("path_prefix", "plugins"),
42
+ name,
43
+ )
44
+ typer.echo(f"{name} ({len(plugins)} plugins)")
45
+ except FetchError as exc:
46
+ typer.echo(f"{name} (failed to list: {exc})")
47
+ else:
48
+ try:
49
+ source = get_source()
50
+ plugins = list_pack_plugins(
51
+ source["url"],
52
+ source["ref"],
53
+ source.get("path_prefix", "plugins"),
54
+ pack,
55
+ )
56
+ except SourceError as exc:
57
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
58
+ raise typer.Exit(1)
59
+ except FetchError as exc:
60
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
61
+ raise typer.Exit(1)
62
+ if not plugins:
63
+ typer.echo(f"No plugins found in pack: {pack}")
64
+ return
65
+ for plugin in plugins:
66
+ typer.echo(f"{pack}/{plugin}")
67
+
68
+ @pack_app.command("remove")
69
+ def pack_remove(name: str = typer.Argument(..., help="Pack id to remove")):
70
+ try:
71
+ pack_registry.remove_pack(name)
72
+ except pack_registry.PackError as exc:
73
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
74
+ raise typer.Exit(1)
75
+ typer.secho(f"Removed pack: {name}", fg=typer.colors.GREEN)
commands/run.py ADDED
@@ -0,0 +1,185 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+
5
+ import typer
6
+ import yaml
7
+
8
+ from registry.discover import read_workflow
9
+ from registry.fetch import FetchError, fetch_pack_dir, fetch_plugin_dir, list_pack_plugins
10
+ from registry.packs import list_packs, pack_installed
11
+ from registry.paths import PROJECT_ROOT, venv_python
12
+ from registry.source import SourceError, get_source
13
+
14
+
15
+ def _parse_target(target:str)-> tuple[str,str]:
16
+ if "/" not in target:
17
+ raise typer.BadParameter("Use pack/plugin format, eg. aws/addpatchclasstag")
18
+
19
+ pack,plugin = target.split("/",1)
20
+ if not pack or not plugin:
21
+ raise typer.BadParameter("Use pack/plugin format, e.g. aws/addpatchclasstag")
22
+ return pack,plugin
23
+
24
+ def _run_target(pack: str, plugin: str, extra_args: list[str]) -> None:
25
+ if not pack_installed(pack):
26
+ typer.secho(
27
+ f"Pack not installed: {pack}. Run: lazyops pack add {pack}",
28
+ fg=typer.colors.RED,
29
+ err=True,
30
+ )
31
+ raise typer.Exit(1)
32
+
33
+ try:
34
+ source = get_source()
35
+ except SourceError as exc:
36
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
37
+ raise typer.Exit(1)
38
+
39
+ tmp_root = None
40
+ workflow_dir = None
41
+ try:
42
+ workflow_dir = fetch_plugin_dir(
43
+ url=source["url"],
44
+ ref=source["ref"],
45
+ path_prefix=source.get("path_prefix", "plugins"),
46
+ pack=pack,
47
+ plugin=plugin,
48
+ )
49
+ tmp_root = workflow_dir.parent.parent
50
+
51
+ workflow_path = workflow_dir / "workflow.yaml"
52
+ workflow = read_workflow(workflow_path)
53
+ entrypoint = workflow_dir / workflow["entrypoint"]
54
+ inputs = list(extra_args or [])
55
+
56
+ env = os.environ.copy()
57
+ env.setdefault("LAZYOPS_ROOT", str(PROJECT_ROOT))
58
+ env.setdefault("WORKFLOW_ID", workflow.get("id", plugin))
59
+ env.setdefault("WORKFLOW_ROOT", str(workflow_dir))
60
+ env.setdefault("WORKFLOW_RUNTIME", workflow["runtime"])
61
+ env.setdefault("WORKFLOW_VERSION", workflow.get("version", "1.0.0"))
62
+ env.setdefault("WORKFLOW_PACK", pack)
63
+ env.setdefault("WORKFLOW_PLUGIN", plugin)
64
+ env.setdefault("WORKFLOW_SOURCE_REF", source["ref"])
65
+
66
+ if workflow["runtime"] == "bash":
67
+ cmd = ["bash", str(entrypoint)] + inputs
68
+ elif workflow["runtime"] == "python":
69
+ cmd = [venv_python(), str(entrypoint)] + inputs
70
+ elif workflow["runtime"] == "node":
71
+ cmd = ["node", str(entrypoint)] + inputs
72
+ else:
73
+ typer.secho(f"Unsupported runtime: {workflow['runtime']}", fg=typer.colors.RED, err=True)
74
+ raise typer.Exit(1)
75
+
76
+ subprocess.run(cmd, check=True, env=env, cwd=workflow_dir)
77
+
78
+ except FetchError as exc:
79
+ typer.secho(f"Failed to fetch workflow: {exc}", fg=typer.colors.RED, err=True)
80
+ raise typer.Exit(1)
81
+ finally:
82
+ if tmp_root is not None:
83
+ shutil.rmtree(tmp_root, ignore_errors=True)
84
+
85
+
86
+ def _pick_index(prompt: str, max_index: int) -> int:
87
+ while True:
88
+ raw = typer.prompt(prompt)
89
+ try:
90
+ choice = int(raw)
91
+ except ValueError:
92
+ typer.secho(f"Enter a number between 1 and {max_index}", fg=typer.colors.RED)
93
+ continue
94
+ if 1 <= choice <= max_index:
95
+ return choice - 1
96
+ typer.secho(f"Enter a number between 1 and {max_index}", fg=typer.colors.RED)
97
+
98
+
99
+ def _interactive_run() -> tuple[str, str]:
100
+ packs = list_packs()
101
+ if not packs:
102
+ typer.secho("No packs installed. Run: lazyops pack add aws", fg=typer.colors.RED, err=True)
103
+ raise typer.Exit(1)
104
+
105
+ try:
106
+ source = get_source()
107
+ except SourceError as exc:
108
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
109
+ raise typer.Exit(1)
110
+
111
+ typer.echo("\nInstalled packs:")
112
+ pack_rows: list[tuple[str, list[str]]] = []
113
+ for i, pack in enumerate(packs, start=1):
114
+ try:
115
+ plugins = list_pack_plugins(
116
+ source["url"],
117
+ source["ref"],
118
+ source.get("path_prefix", "plugins"),
119
+ pack,
120
+ )
121
+ except FetchError as exc:
122
+ typer.secho(f" {i}. {pack} (failed: {exc})", fg=typer.colors.YELLOW)
123
+ plugins = []
124
+ pack_rows.append((pack, plugins))
125
+ typer.echo(f" {i}. {pack} ({len(plugins)} plugins)")
126
+
127
+ pack_idx = _pick_index(f"Select pack [1-{len(packs)}]: ", len(packs))
128
+ pack, plugins = pack_rows[pack_idx]
129
+
130
+ if not plugins:
131
+ typer.secho(f"No plugins in pack {pack}", fg=typer.colors.RED, err=True)
132
+ raise typer.Exit(1)
133
+
134
+ typer.echo(f"\nPlugins in {pack}:")
135
+ tmp_root = None
136
+ try:
137
+ tmp_root, pack_dir = fetch_pack_dir(
138
+ source["url"],
139
+ source["ref"],
140
+ source.get("path_prefix", "plugins"),
141
+ pack,
142
+ )
143
+
144
+ # plugins list order from git; enrich with local workflow.yaml names
145
+ plugin_labels: dict[str, str] = {}
146
+ for child in pack_dir.iterdir():
147
+ if not child.is_dir() or child.name == "pack.yaml":
148
+ continue
149
+ yaml_path = child / "workflow.yaml"
150
+ if yaml_path.is_file():
151
+ wf = yaml.safe_load(yaml_path.read_text()) or {}
152
+ plugin_labels[child.name] = wf.get("name", child.name)
153
+ else:
154
+ plugin_labels[child.name] = child.name
155
+
156
+ for i, plugin in enumerate(plugins, start=1):
157
+ label = plugin_labels.get(plugin, plugin)
158
+ typer.echo(f" {i}. {plugin} — {label}")
159
+
160
+ finally:
161
+ if tmp_root is not None:
162
+ shutil.rmtree(tmp_root, ignore_errors=True)
163
+
164
+ plugin_idx = _pick_index(f"Select plugin [1-{len(plugins)}]: ", len(plugins))
165
+ plugin = plugins[plugin_idx]
166
+
167
+ confirm = typer.confirm(f"Run {pack}/{plugin}?")
168
+ if not confirm:
169
+ typer.echo("Cancelled.")
170
+ raise typer.Exit(0)
171
+
172
+ return pack, plugin
173
+
174
+ def register(app: typer.Typer):
175
+
176
+ @app.command("run", help="Run a workflow: lazyops run <pack>/<plugin> [args...]")
177
+ def run_workflow(
178
+ target: str | None = typer.Argument(default=None, help="pack/plugin, e.g. aws/addpatchclasstag"),
179
+ extra_args: list[str] | None = typer.Argument(None),
180
+ ):
181
+ if target is None:
182
+ pack, plugin = _interactive_run()
183
+ else:
184
+ pack, plugin = _parse_target(target)
185
+ _run_target(pack, plugin, list(extra_args or []))
commands/search.py ADDED
@@ -0,0 +1,57 @@
1
+ import typer
2
+
3
+ from registry.catalog import CatalogError, iter_installed_workflows
4
+
5
+
6
+ def _matches(query: str, pack: str, plugin: str, workflow_data: dict) -> bool:
7
+ q = query.lower()
8
+ haystack = " ".join(
9
+ [
10
+ pack,
11
+ plugin,
12
+ f"{pack}/{plugin}",
13
+ str(workflow_data.get("id", "")),
14
+ str(workflow_data.get("name", "")),
15
+ str(workflow_data.get("description", "")),
16
+ ]
17
+ ).lower()
18
+ return q in haystack
19
+
20
+
21
+
22
+ def register(app: typer.Typer):
23
+
24
+ @app.command("search", help="Search details about any command")
25
+ def search_workflows(query: str):
26
+ try:
27
+ items = iter_installed_workflows()
28
+ except CatalogError as exc:
29
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
30
+ raise typer.Exit(1)
31
+ matches = 0
32
+ for item in items:
33
+ pack = item["pack"]
34
+ plugin = item["plugin"]
35
+ workflow_data = item["workflow"]
36
+ if not _matches(query, pack, plugin, workflow_data):
37
+ continue
38
+ matches += 1
39
+ wf_id = workflow_data.get("id", plugin)
40
+ inputs = workflow_data.get("inputs") or []
41
+ print(f"\033[1mId\033[0m: {pack}/{wf_id}")
42
+ print(f"\033[1mName\033[0m: {workflow_data.get('name', wf_id)}")
43
+ print(f"\033[1mWhat i do?\033[0m: {workflow_data.get('description', '')}")
44
+ if inputs:
45
+ print("\033[1mInputs\033[0m:")
46
+ for inp in inputs:
47
+ label = "required" if inp.get("required") else "optional"
48
+ print(f" - {inp['name']} ({label})")
49
+ else:
50
+ print("\033[1mInputs\033[0m: none")
51
+ arg_names = [inp["name"] for inp in inputs if inp.get("required")]
52
+ args = " ".join(f"<{name}>" for name in arg_names)
53
+ target = f"{pack}/{plugin}"
54
+ print(f"\n\033[1msyntax\033[0m: lazyops run {target}{(' ' + args) if args else ''}")
55
+ print()
56
+ if matches == 0:
57
+ typer.echo(f"No workflows matched '{query}'.")
commands/source.py ADDED
@@ -0,0 +1,52 @@
1
+ import typer
2
+
3
+ from registry import source as source_registry
4
+ from registry.config import CONFIG_PATH
5
+
6
+ source_app = typer.Typer(help="Manage workflow sources (git branches, tags, local paths)")
7
+
8
+
9
+ def register(app: typer.Typer):
10
+ app.add_typer(source_app, name="source")
11
+
12
+
13
+ @source_app.command("show")
14
+ def source_show():
15
+ try:
16
+ src = source_registry.get_source()
17
+ except source_registry.SourceError as exc:
18
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
19
+ raise typer.Exit(1)
20
+ typer.echo(f"url: {src['url']}")
21
+ typer.echo(f"ref: {src['ref']}")
22
+ typer.echo(f"path_prefix: {src.get('path_prefix', 'plugins')}")
23
+
24
+
25
+ @source_app.command("update")
26
+ def source_update(
27
+ ref: str = typer.Option(..., "--ref", "-r", help="New branch or tag"),
28
+ ):
29
+ try:
30
+ entry = source_registry.update_source_ref(ref)
31
+ except source_registry.SourceError as exc:
32
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
33
+ raise typer.Exit(1)
34
+ typer.secho(f"Source updated to ref: {entry['ref']}", fg=typer.colors.GREEN)
35
+
36
+
37
+ @source_app.command("init")
38
+ def source_init(
39
+ url: str = typer.Argument(..., help="Git URL of lazyops-plugins repo"),
40
+ ref: str = typer.Option("v1.0.0", "--ref", "-r", help="Branch or tag (version)"),
41
+ path_prefix: str = typer.Option("plugins", "--path-prefix", help="Root dir in repo"),
42
+ ):
43
+ try:
44
+ entry = source_registry.init_source(url, ref, path_prefix)
45
+ except source_registry.SourceError as exc:
46
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
47
+ raise typer.Exit(1)
48
+ typer.secho("Source configured", fg=typer.colors.GREEN)
49
+ typer.echo(f" url: {entry['url']}")
50
+ typer.echo(f" ref: {entry['ref']}")
51
+ typer.echo(f" path_prefix: {entry['path_prefix']}")
52
+ typer.echo(f" config: {CONFIG_PATH}")
File without changes
lazyops_cli/cli.py ADDED
@@ -0,0 +1,31 @@
1
+ import typer
2
+ from pathlib import Path
3
+ import importlib
4
+
5
+
6
+ app = typer.Typer()
7
+
8
+ COMMANDS_DIR = Path(__file__).parent.parent / "commands"
9
+
10
+
11
+ def discover_commands():
12
+ commands = []
13
+ for path in COMMANDS_DIR.iterdir():
14
+ if path.is_file() and path.suffix == ".py" and path.name != "__init__.py":
15
+ module=importlib.import_module(f"commands.{path.stem}")
16
+ if hasattr(module,"register"):
17
+ module.register(app)
18
+
19
+
20
+ @app.command()
21
+ def intro(name: str = "World"):
22
+ print(f"Hello {name}")
23
+
24
+ # This function will be targeted by your custom CLI name
25
+ discover_commands()
26
+
27
+ def run():
28
+ app()
29
+
30
+ if __name__ == "__main__":
31
+ run()
@@ -0,0 +1,247 @@
1
+ Metadata-Version: 2.4
2
+ Name: lazyops-cli
3
+ Version: 1.0.0
4
+ Summary: CLI to discover, fetch, and run reusable automation workflows from remote plugin packs.
5
+ Author: Mridul Tiwari
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/MridulTi/LazyOps
8
+ Project-URL: Repository, https://github.com/MridulTi/LazyOps
9
+ Project-URL: Issues, https://github.com/MridulTi/LazyOps/issues
10
+ Project-URL: Documentation, https://github.com/MridulTi/LazyOps#readme
11
+ Keywords: cli,automation,workflows,devops,ops
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
+ Classifier: Topic :: System :: Systems Administration
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: typer>=0.12.0
27
+ Requires-Dist: PyYAML>=6.0
28
+ Dynamic: license-file
29
+
30
+ # LazyOps
31
+
32
+ > Turn your scattered scripts into reusable, searchable, and shareable workflows.
33
+
34
+ ## Why LazyOps?
35
+
36
+ Every engineer has a folder full of scripts:
37
+
38
+ ```text
39
+ cleanup.sh
40
+ restart-pods.py
41
+ rotate_logs.sh
42
+ debug-nginx.sh
43
+ fix-permissions.sh
44
+ ```
45
+
46
+ After a few months:
47
+
48
+ * You forget they exist.
49
+ * You can't remember their arguments.
50
+ * They're undocumented.
51
+ * They only work on your machine.
52
+ * Your teammates rewrite the same automation.
53
+
54
+ LazyOps solves this by adding a lightweight workflow layer on top of existing scripts.
55
+
56
+ No rewrites. No proprietary language. Just structure.
57
+
58
+ ## Features
59
+
60
+ * 🚀 Run scripts as named workflows (`lazyops run pack/plugin`)
61
+ * 📦 Install workflow **packs** (aws, kubernetes, security, …) from a remote catalog
62
+ * 🔍 Search and list workflows across installed packs
63
+ * 🐍 Support multiple runtimes (Bash, Python, Node.js, executables)
64
+ * 📝 Interactive picker when you run `lazyops run` with no arguments
65
+ * ✅ Manifest validation before execution
66
+ * 🔖 Pin catalog version by git branch or tag (`source.ref`)
67
+ * 🤝 Workflows live in a separate repo — share and version them independently
68
+
69
+ ## Architecture
70
+
71
+ LazyOps is a **CLI-only installer**. Workflows are fetched at runtime from a separate plugins repo:
72
+
73
+ | Repo | Role |
74
+ |------|------|
75
+ | [LazyOps](https://github.com/MridulTi/LazyOps) | CLI, config, fetch & run |
76
+ | [lazyops-plugins](https://github.com/MridulTi/lazyops-plugins) | Workflow catalog (packs + plugins) |
77
+
78
+ Each **pack** is a bundle (e.g. `aws`, `kubernetes`). Each **plugin** is one workflow folder under that pack. The git **branch or tag** on the plugins repo is the catalog **version**.
79
+
80
+ ```text
81
+ lazyops-plugins/
82
+ └── plugins/
83
+ └── aws/
84
+ ├── pack.yaml
85
+ └── addpatchclasstag/
86
+ ├── workflow.yaml
87
+ ├── script.sh
88
+ └── README.md
89
+ ```
90
+
91
+ Run a workflow:
92
+
93
+ ```bash
94
+ lazyops run aws/addpatchclasstag
95
+ ```
96
+
97
+ LazyOps fetches the plugin at your configured `source.ref`, validates the manifest, and executes the script.
98
+
99
+ ## Installation
100
+
101
+ ### From PyPI (recommended)
102
+
103
+ The PyPI package is **`lazyops-cli`** (the name `lazyops` is taken by another project). The command is still `lazyops`:
104
+
105
+ ```bash
106
+ pipx install lazyops-cli
107
+ lazyops --help
108
+ ```
109
+
110
+ Requires Python 3.10+ and `git` (used to fetch workflows at runtime).
111
+
112
+ ### From source (development)
113
+
114
+ LazyOps runs from a project-local virtual environment (`.venv`). Do not install with system `pip`.
115
+
116
+ ```bash
117
+ git clone https://github.com/MridulTi/LazyOps.git
118
+ cd LazyOps
119
+ bash setup.sh
120
+ source ~/.zshrc # or ~/.bashrc — loads the lazyops alias
121
+ lazyops --help # works from any directory
122
+ ```
123
+
124
+ `setup.sh` adds a global shell alias pointing at the repo wrapper (which always uses `.venv`):
125
+
126
+ ```bash
127
+ alias lazyops='/path/to/LazyOps/lazyops'
128
+ export LAZYOPS_ROOT='/path/to/LazyOps'
129
+ ```
130
+
131
+ You can also run from the repo without the alias:
132
+
133
+ ```bash
134
+ ./lazyops --help
135
+ source .venv/bin/activate
136
+ ```
137
+
138
+ ## Quick start
139
+
140
+ Point LazyOps at the plugins catalog, enable packs, and run workflows:
141
+
142
+ ```bash
143
+ # 1. Configure the plugins source (branch = version)
144
+ lazyops source init https://github.com/MridulTi/lazyops-plugins.git --ref v1.0.0
145
+
146
+ # 2. Enable packs you need
147
+ lazyops pack add aws
148
+ lazyops pack add kubernetes
149
+
150
+ # 3. Discover and run
151
+ lazyops list
152
+ lazyops search patch
153
+ lazyops run aws/addpatchclasstag
154
+ lazyops run # interactive: pick pack → pick plugin → confirm
155
+
156
+ # 4. Upgrade catalog version
157
+ lazyops source update --ref v1.2.0
158
+ ```
159
+
160
+ For local development against a checkout of lazyops-plugins:
161
+
162
+ ```bash
163
+ lazyops source init file:///path/to/lazyops-plugins --ref main
164
+ ```
165
+
166
+ Config is stored at `~/.lazyops/config.yaml`:
167
+
168
+ ```yaml
169
+ source:
170
+ url: https://github.com/MridulTi/lazyops-plugins.git
171
+ ref: v1.0.0
172
+ path_prefix: plugins
173
+ packs:
174
+ - aws
175
+ - kubernetes
176
+ ```
177
+
178
+ ## Commands
179
+
180
+ | Command | Purpose |
181
+ |---------|---------|
182
+ | `lazyops source init <url> [--ref]` | Set plugins repo URL and version |
183
+ | `lazyops source show` | Show current source config |
184
+ | `lazyops source update --ref <ref>` | Bump catalog version |
185
+ | `lazyops pack add <pack>` | Enable a pack |
186
+ | `lazyops pack list` | List installed packs |
187
+ | `lazyops pack list <pack>` | List plugins in a pack |
188
+ | `lazyops pack remove <pack>` | Disable a pack |
189
+ | `lazyops run <pack>/<plugin> [args...]` | Fetch and run a workflow |
190
+ | `lazyops run` | Interactive workflow picker |
191
+ | `lazyops list` | List workflows in installed packs |
192
+ | `lazyops search <query>` | Search by name, id, or description |
193
+
194
+ ## Example manifest
195
+
196
+ ```yaml
197
+ id: addpatchclasstag
198
+
199
+ name: "Add Patch Class Tag"
200
+
201
+ description: "Add patch class tags to instances. Required env: AWS_REGION or REGION."
202
+
203
+ runtime: bash
204
+
205
+ entrypoint: script.sh
206
+
207
+ pack: aws
208
+
209
+ version: 1.0.0
210
+
211
+ inputs:
212
+ - name: instance_id
213
+ required: true
214
+ ```
215
+
216
+ ## Philosophy
217
+
218
+ Scripts should remain scripts.
219
+
220
+ LazyOps does not replace your automation. It makes it easier to organize, discover, document, and reuse — with a shared catalog your whole team can install and pin to a version.
221
+
222
+ ## Roadmap
223
+
224
+ * [x] Workflow specification
225
+ * [x] Remote plugin catalog (source + packs)
226
+ * [x] Fetch and run workflows from git
227
+ * [x] Interactive CLI
228
+ * [x] Search and list
229
+ * [ ] Input validation
230
+ * [ ] Plugin runtimes
231
+ * [ ] Optional web dashboard
232
+
233
+ ## Contributing
234
+
235
+ Contributions are welcome.
236
+
237
+ You can help by:
238
+
239
+ * Adding workflows to [lazyops-plugins](https://github.com/MridulTi/lazyops-plugins)
240
+ * Improving CLI documentation
241
+ * Supporting additional runtimes
242
+ * Reporting bugs
243
+ * Suggesting new features
244
+
245
+ ## License
246
+
247
+ MIT License
@@ -0,0 +1,22 @@
1
+ commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ commands/list.py,sha256=FntpRYPa5lOfRN1S1hY3uhSWjuxTLS-qEpRjzxy76yw,746
3
+ commands/pack.py,sha256=YmkS5kjsCw6Mhyk88fcSTNBLni5S1sPc3duc0UO7jSo,2705
4
+ commands/run.py,sha256=_Yjn5WWDDPyYLB7HgYLFU8Mp6sbZdE8ViI9P6eq4a68,6448
5
+ commands/search.py,sha256=gjuxp1eTxQRImO6OK2yI1zCho7kCaICxYZk_XokT4iM,2117
6
+ commands/source.py,sha256=fU_19SR0j8XB8Ot9WKKPs9IDturRS2whaPOGExsPMz0,1861
7
+ lazyops_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ lazyops_cli/cli.py,sha256=D83WJsI9ypGTxa3-DUC3O_l21Uq0qp-NNsuTN708sOM,664
9
+ lazyops_cli-1.0.0.dist-info/licenses/LICENSE,sha256=Qt8MKT0gY0r-pDAO1zEWfB7YHPycn9Bsy7YUDwF8daQ,1070
10
+ registry/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ registry/catalog.py,sha256=R09XEhval3a2CakDbFJrAPN7cw3HuR5b-ex1xOtzsSE,1567
12
+ registry/config.py,sha256=fjSycupyO-GrrgggjBsjHit2jkdUCq_tjwCAzHcWK-Q,1848
13
+ registry/discover.py,sha256=an7R9XmOGHVcTfB97FvUGaxHYtu5F_pImatoy0bu7z0,858
14
+ registry/fetch.py,sha256=ak9u-lephFVtlgxzcb69SWhdIbA5dxvoUoWve-V1O4A,5180
15
+ registry/packs.py,sha256=1UyOe_K2rT7q-EkG4IhLefVJ8_ds4ytp315yAclz9CU,831
16
+ registry/paths.py,sha256=6vxsHPTAgdAUH4cbFHz9Mda0DBfP3SH2ti0MHBU5XIw,261
17
+ registry/source.py,sha256=L71aPgTyHjzzEjawKdEAZu1gNrnGAJstNe8YQLEu33o,1158
18
+ lazyops_cli-1.0.0.dist-info/METADATA,sha256=g-vAFZMTDVjgiS9NkPR1UhPILeqgzKmzcnTcqB3JJPE,6797
19
+ lazyops_cli-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
20
+ lazyops_cli-1.0.0.dist-info/entry_points.txt,sha256=qlVXtgEEZ2FAyGIkzwsb0AigUm5To2vah8yX6C0K238,48
21
+ lazyops_cli-1.0.0.dist-info/top_level.txt,sha256=pTKCX7t0APvoQBsimdqrLxWeI-eX3X1FlWD1NRZNpRY,30
22
+ lazyops_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lazyops = lazyops_cli.cli:run
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mridul Tiwari
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ commands
2
+ lazyops_cli
3
+ registry
registry/__init__.py ADDED
File without changes
registry/catalog.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+
5
+ import yaml
6
+
7
+ from registry.fetch import FetchError, fetch_pack_dir
8
+ from registry.packs import list_packs
9
+ from registry.source import SourceError, get_source
10
+
11
+
12
+ class CatalogError(Exception):
13
+ pass
14
+
15
+
16
+ def iter_installed_workflows() -> list[dict]:
17
+ """
18
+ Return metadata for every plugin under installed packs.
19
+ Each item: {pack, plugin, workflow}
20
+ """
21
+ try:
22
+ source = get_source()
23
+ except SourceError as exc:
24
+ raise CatalogError(str(exc)) from exc
25
+ url = source["url"]
26
+ ref = source["ref"]
27
+ path_prefix = source.get("path_prefix", "plugins")
28
+ results: list[dict] = []
29
+ for pack in list_packs():
30
+ tmp_root = None
31
+ try:
32
+ tmp_root, pack_dir = fetch_pack_dir(url, ref, path_prefix, pack)
33
+ for child in sorted(pack_dir.iterdir()):
34
+ if not child.is_dir() or child.name == "pack.yaml":
35
+ continue
36
+ yaml_path = child / "workflow.yaml"
37
+ if not yaml_path.is_file():
38
+ continue
39
+ workflow = yaml.safe_load(yaml_path.read_text()) or {}
40
+ results.append({
41
+ "pack": pack,
42
+ "plugin": child.name,
43
+ "workflow": workflow,
44
+ })
45
+ except FetchError as exc:
46
+ raise CatalogError(f"Failed to load pack {pack}: {exc}") from exc
47
+ finally:
48
+ if tmp_root is not None:
49
+ shutil.rmtree(tmp_root, ignore_errors=True)
50
+ return results
registry/config.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import yaml,os
5
+
6
+ DEFAULT_CONFIG_DIR = Path(os.environ.get("LAZYOPS_CONFIG_DIR", str(Path.home() / ".lazyops")))
7
+ CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.yaml"
8
+
9
+ DEFAULT_CONFIG = {"source": None, "packs": []}
10
+
11
+
12
+ def ensure_config_dir() -> None:
13
+ DEFAULT_CONFIG_DIR.mkdir(parents=True,exist_ok=True)
14
+
15
+ def _migrate_config(data: dict) -> dict:
16
+ """Normalize config to source + packs; migrate legacy sources[]."""
17
+ if not isinstance(data, dict):
18
+ data = {}
19
+ # Legacy: sources[] → single source (first entry only)
20
+ if "source" not in data and isinstance(data.get("sources"), list):
21
+ legacy = data["sources"]
22
+ if legacy:
23
+ first = legacy[0]
24
+ data["source"] = {
25
+ "url": first.get("url", ""),
26
+ "ref": first.get("ref", "v1.0.0"),
27
+ "path_prefix": first.get("path_prefix", "plugins"),
28
+ }
29
+ del data["sources"]
30
+ if data.get("source") is None:
31
+ data["source"] = None
32
+ elif isinstance(data["source"], dict):
33
+ src = data["source"]
34
+ data["source"] = {
35
+ "url": src.get("url", ""),
36
+ "ref": src.get("ref", "v1.0.0"),
37
+ "path_prefix": src.get("path_prefix", "plugins"),
38
+ }
39
+ if "packs" not in data or not isinstance(data["packs"], list):
40
+ data["packs"] = []
41
+ return data
42
+
43
+ def load_config() -> dict:
44
+ ensure_config_dir()
45
+ if not CONFIG_PATH.is_file():
46
+ return dict(DEFAULT_CONFIG)
47
+ with CONFIG_PATH.open("r",encoding="utf-8") as f:
48
+ data = yaml.safe_load(f) or {}
49
+ return _migrate_config(data)
50
+
51
+ def save_config(data: dict) -> None:
52
+ ensure_config_dir()
53
+ with CONFIG_PATH.open("w", encoding="utf-8") as f:
54
+ yaml.safe_dump(data, f, sort_keys=False, default_flow_style=False)
registry/discover.py ADDED
@@ -0,0 +1,28 @@
1
+ from pathlib import Path
2
+ import yaml
3
+
4
+ def discover_workflows():
5
+ workflow_root= Path(__file__).parent.parent / "workflows"
6
+ workflows=[]
7
+ for workflows_dir in workflow_root.iterdir():
8
+ if workflows_dir.is_dir():
9
+ yaml_file=workflows_dir/ "workflow.yaml"
10
+ if yaml_file.is_file():
11
+ workflows.append(workflows_dir.name)
12
+ return workflows
13
+
14
+
15
+ def discover_workflow_path():
16
+ workflow_root= Path(__file__).parent.parent / "workflows"
17
+ workflows=[]
18
+ for workflows_dir in workflow_root.iterdir():
19
+ if workflows_dir.is_dir():
20
+ yaml_file=workflows_dir/ "workflow.yaml"
21
+ if yaml_file.is_file():
22
+ workflows.append(yaml_file)
23
+ return workflows
24
+
25
+
26
+ def read_workflow(workflow_path: Path):
27
+ with open(workflow_path,"r") as f:
28
+ return yaml.safe_load(f)
registry/fetch.py ADDED
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil,yaml
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+
9
+ class FetchError(Exception):
10
+ pass
11
+
12
+
13
+ def _run_git(args: list[str], cwd: Path | None = None) -> None:
14
+ result = subprocess.run(
15
+ ["git",*args],
16
+ cwd=cwd,
17
+ capture_output=True,
18
+ text=True,
19
+ )
20
+ if result.returncode !=0:
21
+ msg=(result.stderr or result.stdout or "").strip()
22
+ raise FetchError(msg or f"git {''.join(args)} failed")
23
+
24
+
25
+ def plugin_relpath(path_prefix: str, pack: str, plugin: str) -> str:
26
+ prefix = path_prefix.strip('/')
27
+ return f"{prefix}/{pack}/{plugin}"
28
+
29
+
30
+ def fetch_plugin_dir(
31
+ url: str,
32
+ ref: str,
33
+ path_prefix: str,
34
+ pack: str,
35
+ plugin: str
36
+ ) -> Path:
37
+ """
38
+ Sparse-clone one workflow dir at ref.
39
+ Returns path to the plugin dir (contains workflow.yaml).
40
+ Caller deletes parent temp dir when done.
41
+ """
42
+ rel = plugin_relpath(path_prefix, pack, plugin)
43
+ tmp_root = Path(tempfile.mkdtemp(prefix="lazyops-"))
44
+ repo_dir = tmp_root / "repo"
45
+ try:
46
+ _run_git(
47
+ [
48
+ "clone",
49
+ "--depth", "1",
50
+ "--filter=blob:none",
51
+ "--sparse",
52
+ "--branch", ref,
53
+ url,
54
+ str(repo_dir),
55
+ ]
56
+ )
57
+ _run_git(["sparse-checkout", "set", rel], cwd=repo_dir)
58
+ workflow_dir = repo_dir / rel
59
+ if not (workflow_dir / "workflow.yaml").is_file():
60
+ raise FetchError(
61
+ f"Workflow not found: {pack}/{plugin} at ref={ref} ({rel})"
62
+ )
63
+ dest = tmp_root / pack / plugin
64
+ dest.parent.mkdir(parents=True, exist_ok=True)
65
+ shutil.move(str(workflow_dir), str(dest))
66
+ shutil.rmtree(repo_dir, ignore_errors=True)
67
+ return dest
68
+ except Exception:
69
+ shutil.rmtree(tmp_root, ignore_errors=True)
70
+ raise
71
+
72
+ def fetch_pack_dir(
73
+ url: str,
74
+ ref: str,
75
+ path_prefix: str,
76
+ pack: str,
77
+ ) -> tuple[Path, Path]:
78
+ """
79
+ Sparse-clone plugins/<pack>/ once.
80
+ Returns (tmp_root, pack_dir). Caller must rm tmp_root when done.
81
+ """
82
+ rel = f"{path_prefix.strip('/')}/{pack}"
83
+ tmp_root = Path(tempfile.mkdtemp(prefix="lazyops-pack-"))
84
+ repo_dir = tmp_root / "repo"
85
+
86
+ try:
87
+ _run_git(
88
+ [
89
+ "clone",
90
+ "--depth", "1",
91
+ "--filter=blob:none",
92
+ "--sparse",
93
+ "--branch", ref,
94
+ url,
95
+ str(repo_dir),
96
+ ]
97
+ )
98
+ _run_git(["sparse-checkout", "set", rel], cwd=repo_dir)
99
+
100
+ pack_dir = repo_dir / rel
101
+ if not pack_dir.is_dir():
102
+ raise FetchError(f"Pack not found: {pack}")
103
+
104
+ dest = tmp_root / pack
105
+ shutil.move(str(pack_dir), str(dest))
106
+ shutil.rmtree(repo_dir, ignore_errors=True)
107
+ return tmp_root, dest
108
+
109
+ except Exception:
110
+ shutil.rmtree(tmp_root, ignore_errors=True)
111
+ raise
112
+
113
+ def list_pack_plugins(
114
+ url: str,
115
+ ref: str,
116
+ path_prefix: str,
117
+ pack: str,
118
+ ) -> list[str]:
119
+ """List plugin folder names under a pack (for pack list / interactive run)."""
120
+ prefix = path_prefix.strip("/")
121
+ pack_path = f"{prefix}/{pack}"
122
+ tmp_root = Path(tempfile.mkdtemp(prefix="lazyops-ls-"))
123
+ repo_dir = tmp_root / "repo"
124
+ repo_dir.mkdir(parents=True, exist_ok=True)
125
+ try:
126
+ _run_git(["init"], cwd=repo_dir)
127
+ _run_git(["remote", "add", "origin", url], cwd=repo_dir)
128
+ _run_git(["fetch", "--depth", "1", "origin", ref], cwd=repo_dir)
129
+ result = subprocess.run(
130
+ ["git", "ls-tree", "-d", "--name-only", f"FETCH_HEAD:{pack_path}"],
131
+ cwd=repo_dir,
132
+ capture_output=True,
133
+ text=True,
134
+ )
135
+ if result.returncode != 0:
136
+ raise FetchError(result.stderr.strip() or f"Pack not found: {pack}")
137
+ names = []
138
+ for line in result.stdout.splitlines():
139
+ name = line.strip()
140
+ if name and name != "pack.yaml":
141
+ names.append(name)
142
+ return sorted(names)
143
+ finally:
144
+ shutil.rmtree(tmp_root, ignore_errors=True)
145
+
146
+ def read_plugin_workflow(
147
+ url: str,
148
+ ref: str,
149
+ path_prefix: str,
150
+ pack: str,
151
+ plugin: str
152
+ ) -> dict:
153
+ """Read workflow.yaml for one plugin at ref (no full clone)"""
154
+ rel = plugin_relpath(path_prefix, pack , plugin) + "/workflow.yaml"
155
+ tmp_root = Path(tempfile.mkdtemp(prefix="lazyops-yaml-"))
156
+ repo_dir = tmp_root / "repo"
157
+ repo_dir.mkdir(parents=True, exist_ok=True)
158
+
159
+ try:
160
+ _run_git(["init"], cwd=repo_dir)
161
+ _run_git(["remote", "add", "origin", url], cwd=repo_dir)
162
+ _run_git(["fetch", "--depth", "1", "origin", ref], cwd=repo_dir)
163
+
164
+ result = subprocess.run(
165
+ ["git","show",f"FETCH_HEAD:{rel}"],
166
+ cwd=repo_dir,
167
+ capture_output=True,
168
+ text=True
169
+ )
170
+ if result.returncode != 0:
171
+ raise FetchError(f"Missing workflow.yaml for {pack}/{plugin}")
172
+
173
+ return yaml.safe_load(result.stdout) or {}
174
+ finally:
175
+ shutil.rmtree(tmp_root, ignore_errors=True)
registry/packs.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+ from registry.config import load_config,save_config
3
+
4
+ class PackError(Exception):
5
+ pass
6
+
7
+ def list_packs() -> list[str]:
8
+ return load_config().get("packs") or []
9
+
10
+ def pack_installed(name:str) -> bool:
11
+ return name in list_packs()
12
+
13
+ def add_pack(name:str) -> None:
14
+ if not name:
15
+ raise PackError("pack name is required")
16
+
17
+ config = load_config()
18
+ packs = config.setdefault("packs",[])
19
+
20
+ if name in packs:
21
+ raise PackError(f"Pack already installed: {name}")
22
+
23
+ packs.append(name)
24
+ save_config(config)
25
+
26
+ def remove_pack(name:str) -> None:
27
+ config = load_config()
28
+ packs = config.get("packs") or []
29
+
30
+ if name not in packs:
31
+ raise PackError(f"Pack not installed: {name}")
32
+
33
+ config["packs"] = [p for p in packs if p !=name]
34
+ save_config(config)
registry/paths.py ADDED
@@ -0,0 +1,11 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
5
+
6
+
7
+ def venv_python() -> str:
8
+ candidate = PROJECT_ROOT / ".venv" / "bin" / "python"
9
+ if candidate.is_file():
10
+ return str(candidate)
11
+ return sys.executable
registry/source.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from registry.config import load_config, save_config
4
+
5
+
6
+ class SourceError(Exception):
7
+ pass
8
+
9
+
10
+ def init_source(url: str, ref: str = "v1.0.0", path_prefix: str = "plugins") -> dict:
11
+ if not url:
12
+ raise SourceError("url is required")
13
+ config = load_config()
14
+ config["source"] = {
15
+ "url": url,
16
+ "ref": ref,
17
+ "path_prefix": path_prefix,
18
+ }
19
+ save_config(config)
20
+ return config["source"]
21
+
22
+
23
+ def get_source() -> dict:
24
+ config = load_config()
25
+ source = config.get("source")
26
+ if not source or not isinstance(source, dict) or not source.get("url"):
27
+ raise SourceError(
28
+ "No source configured. Run: lazyops source init <git-url> --ref v1.0.0"
29
+ )
30
+ return source
31
+
32
+
33
+ def update_source_ref(ref: str) -> dict:
34
+ if not ref:
35
+ raise SourceError("ref is required")
36
+ config = load_config()
37
+ source = config.get("source")
38
+ if not source or not isinstance(source, dict) or not source.get("url"):
39
+ raise SourceError("No source configured. Run: lazyops source init first")
40
+ source["ref"] = ref
41
+ save_config(config)
42
+ return source