fixdoc 0.0.1__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.
- fixdoc/__init__.py +8 -0
- fixdoc/cli.py +26 -0
- fixdoc/commands/__init__.py +11 -0
- fixdoc/commands/analyze.py +313 -0
- fixdoc/commands/capture.py +109 -0
- fixdoc/commands/capture_handlers.py +298 -0
- fixdoc/commands/delete.py +72 -0
- fixdoc/commands/edit.py +118 -0
- fixdoc/commands/manage.py +67 -0
- fixdoc/commands/search.py +65 -0
- fixdoc/commands/sync.py +268 -0
- fixdoc/config.py +113 -0
- fixdoc/fix.py +19 -0
- fixdoc/formatter.py +62 -0
- fixdoc/git.py +263 -0
- fixdoc/markdown_parser.py +106 -0
- fixdoc/models.py +83 -0
- fixdoc/parsers/__init__.py +24 -0
- fixdoc/parsers/base.py +131 -0
- fixdoc/parsers/kubernetes.py +584 -0
- fixdoc/parsers/router.py +160 -0
- fixdoc/parsers/terraform.py +409 -0
- fixdoc/storage.py +146 -0
- fixdoc/sync_engine.py +330 -0
- fixdoc/terraform_parser.py +135 -0
- fixdoc-0.0.1.dist-info/METADATA +261 -0
- fixdoc-0.0.1.dist-info/RECORD +30 -0
- fixdoc-0.0.1.dist-info/WHEEL +5 -0
- fixdoc-0.0.1.dist-info/entry_points.txt +2 -0
- fixdoc-0.0.1.dist-info/top_level.txt +1 -0
fixdoc/__init__.py
ADDED
fixdoc/cli.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""CLI assembly for fixdoc."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from .commands import capture, search, show, analyze, list_fixes, stats, delete, edit, sync
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_cli() -> click.Group:
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
@click.version_option(version="0.1.0", prog_name="fixdoc")
|
|
12
|
+
def cli():
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
# group commands
|
|
16
|
+
cli.add_command(capture)
|
|
17
|
+
cli.add_command(search)
|
|
18
|
+
cli.add_command(show)
|
|
19
|
+
cli.add_command(analyze)
|
|
20
|
+
cli.add_command(list_fixes)
|
|
21
|
+
cli.add_command(stats)
|
|
22
|
+
cli.add_command(delete)
|
|
23
|
+
cli.add_command(edit)
|
|
24
|
+
cli.add_command(sync)
|
|
25
|
+
|
|
26
|
+
return cli
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""CLI commands for fixdoc."""
|
|
2
|
+
|
|
3
|
+
from .capture import capture
|
|
4
|
+
from .search import search, show
|
|
5
|
+
from .analyze import analyze
|
|
6
|
+
from .manage import list_fixes, stats
|
|
7
|
+
from .delete import delete
|
|
8
|
+
from .edit import edit
|
|
9
|
+
from .sync import sync
|
|
10
|
+
|
|
11
|
+
__all__ = ["capture", "search", "show", "analyze", "list_fixes", "stats", "delete", "edit", "sync"]
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Analyze command for fixdoc CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from ..models import Fix
|
|
11
|
+
from ..storage import FixRepository
|
|
12
|
+
from ..parsers.base import CloudProvider
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class AnalysisMatch:
|
|
17
|
+
"""Represents a potential issue found during terraform plan analysis."""
|
|
18
|
+
|
|
19
|
+
resource_address: str
|
|
20
|
+
resource_type: str
|
|
21
|
+
related_fix: Fix
|
|
22
|
+
cloud_provider: CloudProvider = CloudProvider.UNKNOWN
|
|
23
|
+
|
|
24
|
+
def format_warning(self) -> str:
|
|
25
|
+
"""Format as a warning message for CLI output."""
|
|
26
|
+
short_id = self.related_fix.id[:8]
|
|
27
|
+
issue = self.related_fix.issue
|
|
28
|
+
resolution = self.related_fix.resolution
|
|
29
|
+
|
|
30
|
+
issue_preview = issue[:80] + "..." if len(issue) > 80 else issue
|
|
31
|
+
resolution_preview = resolution[:80] + "..." if len(resolution) > 80 else resolution
|
|
32
|
+
|
|
33
|
+
lines = [
|
|
34
|
+
f"⚠ {self.resource_address} may relate to FIX-{short_id}",
|
|
35
|
+
f" Previous issue: {issue_preview}",
|
|
36
|
+
f" Resolution: {resolution_preview}",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
if self.related_fix.tags:
|
|
40
|
+
lines.append(f" Tags: {self.related_fix.tags}")
|
|
41
|
+
|
|
42
|
+
return "\n".join(lines)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class PlanResource:
|
|
47
|
+
"""Represents a resource in a Terraform plan."""
|
|
48
|
+
|
|
49
|
+
address: str
|
|
50
|
+
resource_type: str
|
|
51
|
+
name: str
|
|
52
|
+
cloud_provider: CloudProvider
|
|
53
|
+
action: str # create, update, delete, no-op
|
|
54
|
+
module_path: Optional[str] = None
|
|
55
|
+
values: dict = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TerraformAnalyzer:
|
|
59
|
+
"""Analyzes terraform plan JSON output against known fixes."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, repo: Optional[FixRepository] = None):
|
|
62
|
+
self.repo = repo or FixRepository()
|
|
63
|
+
|
|
64
|
+
def load_plan(self, plan_path: Path) -> dict:
|
|
65
|
+
"""Load and parse a terraform plan JSON file."""
|
|
66
|
+
with open(plan_path, "r") as f:
|
|
67
|
+
return json.load(f)
|
|
68
|
+
|
|
69
|
+
def detect_cloud_provider(self, resource_type: str, provider_name: str = "") -> CloudProvider:
|
|
70
|
+
"""Detect cloud provider from resource type or provider name."""
|
|
71
|
+
resource_lower = resource_type.lower()
|
|
72
|
+
provider_lower = provider_name.lower()
|
|
73
|
+
|
|
74
|
+
if resource_lower.startswith("aws_") or "hashicorp/aws" in provider_lower:
|
|
75
|
+
return CloudProvider.AWS
|
|
76
|
+
elif resource_lower.startswith("azurerm_") or "hashicorp/azurerm" in provider_lower:
|
|
77
|
+
return CloudProvider.AZURE
|
|
78
|
+
elif resource_lower.startswith("google_") or "hashicorp/google" in provider_lower:
|
|
79
|
+
return CloudProvider.GCP
|
|
80
|
+
|
|
81
|
+
return CloudProvider.UNKNOWN
|
|
82
|
+
|
|
83
|
+
def extract_resources(self, plan: dict) -> list[PlanResource]:
|
|
84
|
+
"""Extract all resources from a Terraform plan with full metadata."""
|
|
85
|
+
resources = []
|
|
86
|
+
|
|
87
|
+
# Extract from resource_changes (most reliable for planned changes)
|
|
88
|
+
for change in plan.get("resource_changes", []):
|
|
89
|
+
address = change.get("address", "")
|
|
90
|
+
resource_type = change.get("type", "")
|
|
91
|
+
name = change.get("name", "")
|
|
92
|
+
provider_name = change.get("provider_name", "")
|
|
93
|
+
|
|
94
|
+
if not resource_type:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
# Determine action
|
|
98
|
+
actions = change.get("change", {}).get("actions", [])
|
|
99
|
+
if "create" in actions:
|
|
100
|
+
action = "create"
|
|
101
|
+
elif "delete" in actions:
|
|
102
|
+
action = "delete"
|
|
103
|
+
elif "update" in actions:
|
|
104
|
+
action = "update"
|
|
105
|
+
else:
|
|
106
|
+
action = "no-op"
|
|
107
|
+
|
|
108
|
+
# Extract module path if present
|
|
109
|
+
module_path = None
|
|
110
|
+
if address.startswith("module."):
|
|
111
|
+
parts = address.split(".")
|
|
112
|
+
module_parts = []
|
|
113
|
+
for i, part in enumerate(parts):
|
|
114
|
+
if part == "module" and i + 1 < len(parts):
|
|
115
|
+
module_parts.append(f"module.{parts[i + 1]}")
|
|
116
|
+
module_path = ".".join(module_parts) if module_parts else None
|
|
117
|
+
|
|
118
|
+
# Get planned values
|
|
119
|
+
values = change.get("change", {}).get("after", {}) or {}
|
|
120
|
+
|
|
121
|
+
resources.append(PlanResource(
|
|
122
|
+
address=address,
|
|
123
|
+
resource_type=resource_type,
|
|
124
|
+
name=name,
|
|
125
|
+
cloud_provider=self.detect_cloud_provider(resource_type, provider_name),
|
|
126
|
+
action=action,
|
|
127
|
+
module_path=module_path,
|
|
128
|
+
values=values,
|
|
129
|
+
))
|
|
130
|
+
|
|
131
|
+
# Also check planned_values for additional resources
|
|
132
|
+
self._extract_from_planned_values(plan.get("planned_values", {}), resources)
|
|
133
|
+
|
|
134
|
+
# Deduplicate by address
|
|
135
|
+
seen = set()
|
|
136
|
+
unique = []
|
|
137
|
+
for r in resources:
|
|
138
|
+
if r.address not in seen:
|
|
139
|
+
seen.add(r.address)
|
|
140
|
+
unique.append(r)
|
|
141
|
+
|
|
142
|
+
return unique
|
|
143
|
+
|
|
144
|
+
def _extract_from_planned_values(self, planned_values: dict, resources: list[PlanResource]):
|
|
145
|
+
"""Extract resources from planned_values section."""
|
|
146
|
+
existing_addresses = {r.address for r in resources}
|
|
147
|
+
|
|
148
|
+
def process_module(module: dict, prefix: str = ""):
|
|
149
|
+
# Process resources in this module
|
|
150
|
+
for resource in module.get("resources", []):
|
|
151
|
+
address = resource.get("address", "")
|
|
152
|
+
if address in existing_addresses:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
resource_type = resource.get("type", "")
|
|
156
|
+
if not resource_type:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
provider_name = resource.get("provider_name", "")
|
|
160
|
+
|
|
161
|
+
resources.append(PlanResource(
|
|
162
|
+
address=address,
|
|
163
|
+
resource_type=resource_type,
|
|
164
|
+
name=resource.get("name", ""),
|
|
165
|
+
cloud_provider=self.detect_cloud_provider(resource_type, provider_name),
|
|
166
|
+
action="unknown",
|
|
167
|
+
module_path=prefix or None,
|
|
168
|
+
values=resource.get("values", {}),
|
|
169
|
+
))
|
|
170
|
+
existing_addresses.add(address)
|
|
171
|
+
|
|
172
|
+
# Process child modules
|
|
173
|
+
for child in module.get("child_modules", []):
|
|
174
|
+
child_address = child.get("address", "")
|
|
175
|
+
process_module(child, child_address)
|
|
176
|
+
|
|
177
|
+
root = planned_values.get("root_module", {})
|
|
178
|
+
process_module(root)
|
|
179
|
+
|
|
180
|
+
def extract_resource_types(self, plan: dict) -> list[tuple[str, str]]:
|
|
181
|
+
"""Extract (resource_address, resource_type) tuples from a plan.
|
|
182
|
+
|
|
183
|
+
This is a simplified version for backward compatibility.
|
|
184
|
+
"""
|
|
185
|
+
resources = self.extract_resources(plan)
|
|
186
|
+
return [(r.address, r.resource_type) for r in resources]
|
|
187
|
+
|
|
188
|
+
def analyze(self, plan_path: Path) -> list[AnalysisMatch]:
|
|
189
|
+
"""Analyze a terraform plan for potential issues based on past fixes."""
|
|
190
|
+
plan = self.load_plan(plan_path)
|
|
191
|
+
resources = self.extract_resources(plan)
|
|
192
|
+
matches = []
|
|
193
|
+
|
|
194
|
+
for resource in resources:
|
|
195
|
+
for fix in self.repo.find_by_resource_type(resource.resource_type):
|
|
196
|
+
matches.append(
|
|
197
|
+
AnalysisMatch(
|
|
198
|
+
resource_address=resource.address,
|
|
199
|
+
resource_type=resource.resource_type,
|
|
200
|
+
related_fix=fix,
|
|
201
|
+
cloud_provider=resource.cloud_provider,
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return matches
|
|
206
|
+
|
|
207
|
+
def analyze_and_format(self, plan_path: Path) -> str:
|
|
208
|
+
"""Analyze a plan and return formatted output."""
|
|
209
|
+
matches = self.analyze(plan_path)
|
|
210
|
+
|
|
211
|
+
if not matches:
|
|
212
|
+
return "No known issues found for resources in this plan."
|
|
213
|
+
|
|
214
|
+
# Group by cloud provider
|
|
215
|
+
by_provider = {}
|
|
216
|
+
for match in matches:
|
|
217
|
+
provider = match.cloud_provider.value
|
|
218
|
+
by_provider.setdefault(provider, []).append(match)
|
|
219
|
+
|
|
220
|
+
lines = [
|
|
221
|
+
f"Found {len(matches)} potential issue(s) based on your fix history:",
|
|
222
|
+
"",
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
for provider, provider_matches in by_provider.items():
|
|
226
|
+
if provider != "unknown":
|
|
227
|
+
lines.append(f"── {provider.upper()} ──")
|
|
228
|
+
lines.append("")
|
|
229
|
+
|
|
230
|
+
for match in provider_matches:
|
|
231
|
+
lines.append(match.format_warning())
|
|
232
|
+
lines.append("")
|
|
233
|
+
|
|
234
|
+
lines.append("Run `fixdoc show <fix-id>` for full details on any fix.")
|
|
235
|
+
return "\n".join(lines)
|
|
236
|
+
|
|
237
|
+
def get_plan_summary(self, plan_path: Path) -> dict:
|
|
238
|
+
"""Get a summary of the plan resources by cloud provider and action."""
|
|
239
|
+
plan = self.load_plan(plan_path)
|
|
240
|
+
resources = self.extract_resources(plan)
|
|
241
|
+
|
|
242
|
+
summary = {
|
|
243
|
+
"total": len(resources),
|
|
244
|
+
"by_provider": {},
|
|
245
|
+
"by_action": {},
|
|
246
|
+
"by_type": {},
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for r in resources:
|
|
250
|
+
provider = r.cloud_provider.value
|
|
251
|
+
summary["by_provider"][provider] = summary["by_provider"].get(provider, 0) + 1
|
|
252
|
+
summary["by_action"][r.action] = summary["by_action"].get(r.action, 0) + 1
|
|
253
|
+
summary["by_type"][r.resource_type] = summary["by_type"].get(r.resource_type, 0) + 1
|
|
254
|
+
|
|
255
|
+
return summary
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@click.command()
|
|
259
|
+
@click.argument("plan_file", type=click.Path(exists=True))
|
|
260
|
+
@click.option("--summary", "-s", is_flag=True, help="Show plan summary instead of analysis")
|
|
261
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
|
|
262
|
+
def analyze(plan_file: str, summary: bool, verbose: bool):
|
|
263
|
+
"""
|
|
264
|
+
Analyze a terraform plan for issues.
|
|
265
|
+
|
|
266
|
+
\b
|
|
267
|
+
Usage:
|
|
268
|
+
terraform plan -out=plan.tfplan
|
|
269
|
+
terraform show -json plan.tfplan > plan.json
|
|
270
|
+
fixdoc analyze plan.json
|
|
271
|
+
|
|
272
|
+
\b
|
|
273
|
+
Options:
|
|
274
|
+
--summary Show resource summary by provider/action
|
|
275
|
+
--verbose Show detailed match information
|
|
276
|
+
"""
|
|
277
|
+
analyzer = TerraformAnalyzer()
|
|
278
|
+
plan_path = Path(plan_file)
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
if summary:
|
|
282
|
+
# Show plan summary
|
|
283
|
+
plan_summary = analyzer.get_plan_summary(plan_path)
|
|
284
|
+
click.echo(f"Plan Summary: {plan_summary['total']} resources\n")
|
|
285
|
+
|
|
286
|
+
if plan_summary['by_provider']:
|
|
287
|
+
click.echo("By Provider:")
|
|
288
|
+
for provider, count in sorted(plan_summary['by_provider'].items()):
|
|
289
|
+
click.echo(f" {provider}: {count}")
|
|
290
|
+
click.echo()
|
|
291
|
+
|
|
292
|
+
if plan_summary['by_action']:
|
|
293
|
+
click.echo("By Action:")
|
|
294
|
+
for action, count in sorted(plan_summary['by_action'].items()):
|
|
295
|
+
click.echo(f" {action}: {count}")
|
|
296
|
+
click.echo()
|
|
297
|
+
|
|
298
|
+
if verbose and plan_summary['by_type']:
|
|
299
|
+
click.echo("By Resource Type:")
|
|
300
|
+
for rtype, count in sorted(plan_summary['by_type'].items(), key=lambda x: -x[1]):
|
|
301
|
+
click.echo(f" {rtype}: {count}")
|
|
302
|
+
else:
|
|
303
|
+
# Show analysis
|
|
304
|
+
output = analyzer.analyze_and_format(plan_path)
|
|
305
|
+
click.echo(output)
|
|
306
|
+
|
|
307
|
+
except json.JSONDecodeError:
|
|
308
|
+
click.echo(f"Error: {plan_file} is not valid JSON", err=True)
|
|
309
|
+
click.echo("Make sure to use: terraform show -json plan.tfplan > plan.json", err=True)
|
|
310
|
+
raise SystemExit(1)
|
|
311
|
+
except Exception as e:
|
|
312
|
+
click.echo(f"Error analyzing plan: {e}", err=True)
|
|
313
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Capture command for fixdoc CLI."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from ..config import ConfigManager
|
|
10
|
+
from ..models import Fix
|
|
11
|
+
from ..storage import FixRepository
|
|
12
|
+
from .capture_handlers import (
|
|
13
|
+
handle_piped_input,
|
|
14
|
+
handle_quick_capture,
|
|
15
|
+
handle_interactive_capture,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_repo() -> FixRepository:
|
|
20
|
+
"""Get the fix repository instance."""
|
|
21
|
+
return FixRepository()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _reopen_stdin_from_terminal() -> bool:
|
|
25
|
+
"""Reopen stdin from terminal after reading piped input. Returns True if successful."""
|
|
26
|
+
try:
|
|
27
|
+
# Unix/Mac
|
|
28
|
+
if os.path.exists('/dev/tty'):
|
|
29
|
+
sys.stdin = open('/dev/tty', 'r')
|
|
30
|
+
return True
|
|
31
|
+
except OSError:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
# Windows
|
|
36
|
+
if sys.platform == 'win32':
|
|
37
|
+
sys.stdin = open('CON', 'r')
|
|
38
|
+
return True
|
|
39
|
+
except OSError:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@click.command()
|
|
46
|
+
@click.option(
|
|
47
|
+
"--quick", "-q", type=str, default=None,
|
|
48
|
+
help="Quick capture: 'issue | resolution'",
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--tags", "-t", type=str, default=None,
|
|
52
|
+
help="Tags (comma-separated)",
|
|
53
|
+
)
|
|
54
|
+
def capture(quick: Optional[str], tags: Optional[str]):
|
|
55
|
+
"""
|
|
56
|
+
Capture a new fix.
|
|
57
|
+
|
|
58
|
+
\b
|
|
59
|
+
Pipe terraform errors:
|
|
60
|
+
terraform apply 2>&1 | fixdoc capture
|
|
61
|
+
|
|
62
|
+
\b
|
|
63
|
+
Interactive:
|
|
64
|
+
fixdoc capture
|
|
65
|
+
|
|
66
|
+
\b
|
|
67
|
+
Quick:
|
|
68
|
+
fixdoc capture -q "issue | resolution" -t storage,rbac
|
|
69
|
+
"""
|
|
70
|
+
repo = get_repo()
|
|
71
|
+
|
|
72
|
+
# Check for piped input
|
|
73
|
+
if not sys.stdin.isatty():
|
|
74
|
+
fix = _handle_piped_input(tags)
|
|
75
|
+
elif quick:
|
|
76
|
+
fix = handle_quick_capture(quick, tags)
|
|
77
|
+
else:
|
|
78
|
+
fix = handle_interactive_capture(tags)
|
|
79
|
+
|
|
80
|
+
if fix:
|
|
81
|
+
# Set author from config if available
|
|
82
|
+
config = ConfigManager().load()
|
|
83
|
+
if config.user.name and not fix.author:
|
|
84
|
+
fix.author = config.user.name
|
|
85
|
+
fix.author_email = config.user.email
|
|
86
|
+
|
|
87
|
+
saved = repo.save(fix)
|
|
88
|
+
click.echo(f"\n----- Fix captured: {saved.id[:8]}")
|
|
89
|
+
click.echo(f" Markdown: ~/.fixdoc/docs/{saved.id}.md")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _handle_piped_input(tags: Optional[str]) -> Optional[Fix]:
|
|
93
|
+
"""Route piped input to appropriate handler using unified parser."""
|
|
94
|
+
# Read all piped input first
|
|
95
|
+
piped_input = sys.stdin.read()
|
|
96
|
+
|
|
97
|
+
if not piped_input.strip():
|
|
98
|
+
click.echo("No input received.", err=True)
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
# Reopen stdin from terminal so we can prompt user
|
|
102
|
+
if not _reopen_stdin_from_terminal():
|
|
103
|
+
click.echo("Error: Cannot prompt for input in this environment.", err=True)
|
|
104
|
+
click.echo("Use quick mode instead:", err=True)
|
|
105
|
+
click.echo(" fixdoc capture -q 'issue | resolution' -t tags", err=True)
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
# Use unified handler that auto-detects Terraform, K8s, Helm, etc.
|
|
109
|
+
return handle_piped_input(piped_input, tags)
|