Skip to content
Merged
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
29 changes: 25 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,35 @@ schemas and the `paramify` CLI — not the internal code.
tenant records *how* each piece of evidence is generated. A provisioning step
separate from `paramify upload`: it reconciles the tenant to the repo GitOps-style
(marker-keyed identity in the script description, `fetcher.yaml` `version` as the
update signal, a sha256 drift guard), with `--dry-run` / `--force` /
`--reassociate` / `--json`. Backed by the new `uploaders/paramify_scripts/`
uploader and surfaced in the TUI's Paramify tab. Only `SCRIPT` associations are
automated; control / solution-capability / validator linkage stays Paramify-side.
update signal, a sha256 drift guard). **Scoped to a manifest by default** — it
provisions scripts only for the fetchers you collect, mirroring how `upload` is
run-scoped — with `--all` to push the whole catalog, plus `--dry-run` / `--force` /
`--reassociate` / `--json`. Backed by the `uploaders/paramify_scripts/` uploader.
Only `SCRIPT` associations are automated; control / solution-capability / validator
linkage stays Paramify-side.
- [`docs/uploader_design.md`](docs/uploader_design.md) — a dedicated uploader design
doc covering both built uploaders and the shared evidence-set identity model, and
a README section + docs-table entries pointing to it.

### Changed

- **TUI Paramify tab redesigned** into stacked *evidence upload* and *scripts sync*
panels. Scripts sync gained a **Preview** action that runs a read-only dry-run and
surfaces the per-fetcher plan (create / update / drift / noop) in a table — flagging
which drifted scripts `--force` would push — and syncs the active manifest's fetchers.

### Fixed

- TUI: page keyboard shortcuts (`ctrl+r` / `ctrl+u` / `ctrl+s`) now fire regardless of
which control is focused, and default focus lands in the active pane on mount, on tab
switches (mouse clicks included), and after `escape` — previously they worked only
right after a number-key tab switch.
- TUI: the *Add fetchers* picker no longer drops a category once all its fetchers are in
the manifest; already-added fetchers show greyed-out and non-selectable, so a
fully-added category (e.g. `datadog`) stays visible.
- TUI: the Paramify action row (Preview / Sync Scripts / force / reassociate) now uses
uniform control sizes instead of content-sized widths and mismatched heights.

## [0.2.1-beta] - 2026-07-10

### Changed
Expand Down
17 changes: 15 additions & 2 deletions framework/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -782,8 +782,13 @@ def scripts_sync_preflight(
config_path: Optional[Path] = None,
*,
dry_run: bool = False,
include: Optional[set] = None,
) -> dict:
"""Inspect scripts-sync readiness without making Paramify API calls."""
"""Inspect scripts-sync readiness without making Paramify API calls.

``include`` restricts the fetcher count to those names (a manifest's
fetchers); ``None`` counts every discovered fetcher.
"""
uploader = _load_paramify_scripts_uploader(root)
uploader.load_dotenv()
config = uploader.load_config(str(config_path)) if config_path else {}
Expand All @@ -797,10 +802,13 @@ def scripts_sync_preflight(
errors: List[str] = []
fetcher_count = 0
for f in discover_fetchers(root).values():
if include is not None and f.name not in include:
continue
if f.evidence_set and f.entry_path.exists():
fetcher_count += 1
if fetcher_count == 0:
errors.append("No fetchers with an evidence_set and a readable entry file to sync")
scope = "in the manifest " if include is not None else ""
errors.append(f"No fetchers {scope}with an evidence_set and a readable entry file to sync")

url_error = uploader._base_url_error(base_url)
if url_error:
Expand Down Expand Up @@ -829,12 +837,16 @@ def scripts_sync(
dry_run: bool = False,
force: bool = False,
reassociate: bool = False,
include: Optional[set] = None,
on_event: Optional[Callable[[dict], None]] = None,
) -> dict:
"""Sync fetcher entry scripts to Paramify and associate them to evidence sets.

Fires sync_start / sync_item / sync_complete so front-ends can render
progress. Raises ValueError for setup errors; returns the uploader summary.

``include`` restricts the sweep to those fetcher names (a manifest's
fetchers); ``None`` syncs every discovered fetcher.
"""
uploader = _load_paramify_scripts_uploader(root)
config = uploader.load_config(str(config_path)) if config_path else {}
Expand All @@ -844,6 +856,7 @@ def scripts_sync(
dry_run=dry_run,
force=force,
reassociate=reassociate,
include=include,
on_event=on_event,
)

Expand Down
18 changes: 16 additions & 2 deletions framework/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,17 +633,30 @@ def on_event(ev: dict) -> None:

@scripts_app.command("sync")
def scripts_sync_cmd(
manifest: Optional[str] = typer.Argument(None, help=f"Sync only the fetchers this manifest uses (default: {_DEFAULT_MANIFEST}). Ignored with --all."),
config: Optional[str] = typer.Option(None, "--config", help="Uploader config YAML (base_url, overrides)"),
dry_run: bool = typer.Option(False, "--dry-run", help="Report the plan; read-only (no writes)"),
force: bool = typer.Option(False, "--force", help="Push scripts whose code drifted without a version bump"),
reassociate: bool = typer.Option(False, "--reassociate", help="Ensure the association for every fetcher, not just changed ones"),
all_fetchers: bool = typer.Option(False, "--all", help="Sync every fetcher in the repo, not just the manifest's"),
json_out: bool = typer.Option(False, "--json", help="Emit JSON summary"),
):
"""Sync fetcher entry scripts to Paramify and associate them to evidence sets."""
"""Sync fetcher entry scripts to Paramify and associate them to evidence sets.

Scoped to a manifest by default — it provisions scripts for the fetchers you
actually collect, mirroring how `upload` is run-scoped. Use --all to push the
whole catalog.
"""
root = api.find_repo_root()
config_path = Path(config).resolve() if config else None
include = None
if not all_fetchers:
mpath = Path(manifest).resolve() if manifest else (root / _DEFAULT_MANIFEST)
m = api.read_manifest(mpath)
entries = (m.get("run") or {}).get("fetchers") or []
include = {e.get("use") for e in entries if e.get("use")}
try:
preflight = api.scripts_sync_preflight(root, config_path, dry_run=dry_run)
preflight = api.scripts_sync_preflight(root, config_path, dry_run=dry_run, include=include)
except Exception as e: # noqa: BLE001 — surface setup errors to CLI users
if json_out:
typer.echo(json.dumps({"ok": False, "errors": [str(e)]}, indent=2))
Expand All @@ -665,6 +678,7 @@ def scripts_sync_cmd(
dry_run=dry_run,
force=force,
reassociate=reassociate,
include=include,
on_event=None if json_out else _human_scripts_printer(),
)
except Exception as e: # noqa: BLE001
Expand Down
25 changes: 21 additions & 4 deletions framework/tui/modals.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,14 +164,18 @@ class MultiPickerModal(ModalScreen[list]):
]

def __init__(
self, title: str, groups: List[Tuple[str, List[str]]], subtitle: str = ""
self, title: str, groups: List[Tuple[str, List[str]]], subtitle: str = "",
disabled: set[str] | None = None,
) -> None:
# groups: ordered [(platform, [fetcher_name, ...]), ...] — categories as
# the catalog sorts them; only non-already-added fetchers.
# groups: ordered [(platform, [fetcher_name, ...]), ...] — every discovered
# fetcher, in the catalog's order. `disabled` names are already in the
# manifest: shown for context (so a fully-added category still appears)
# but not selectable.
super().__init__()
self._title = title
self._subtitle = subtitle
self._groups = groups
self._disabled = set(disabled or ())
self._cat_of = {name: cat for cat, names in groups for name in names}
self._all_ids = [name for _, names in groups for name in names]
self._chosen: set = set()
Expand Down Expand Up @@ -203,6 +207,13 @@ def on_mount(self) -> None:
def _leaf_label(self, name: str) -> Text:
# A Rich Text (not a markup string) — "[x]" would otherwise be parsed as
# a console-markup tag and vanish. Lets us tint the checked marker too.
if name in self._disabled:
# Already in the manifest: shown for context, not selectable.
label = Text()
label.append("✓ ", style="green")
label.append(name, style="dim")
label.append(" in manifest", style="dim italic")
return label
checked = name in self._chosen
label = Text()
label.append("[x] " if checked else "[ ] ", style="green" if checked else "dim")
Expand All @@ -217,9 +228,13 @@ def _populate(self, flt: str) -> None:
matches = [n for n in names if not flt or flt in n.lower()]
if not matches:
continue
# Count what's actually addable; a fully-added platform still shows
# (labelled "all added") so the category never silently disappears.
addable = sum(1 for n in matches if n not in self._disabled)
count = f"({addable})" if addable else "(all added)"
# Filtering opens the platforms with hits; otherwise stay collapsed
# so a long catalog reads as a tidy list of platforms to open.
node = tree.root.add(f"{cat} ({len(matches)})", expand=bool(flt))
node = tree.root.add(f"{cat} {count}", expand=bool(flt))
for name in matches:
node.add_leaf(self._leaf_label(name), data=name)

Expand All @@ -234,6 +249,8 @@ def _on_select(self, event: Tree.NodeSelected) -> None:
if name is None: # a platform row → open/close the dropdown
node.toggle()
return
if name in self._disabled:
return # already in the manifest — shown for context, not selectable
if name in self._chosen:
self._chosen.discard(name)
else:
Expand Down
10 changes: 7 additions & 3 deletions framework/tui/screens/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,14 @@ def action_add_fetcher(self) -> None:
groups = []
if cat:
for c in cat["categories"]:
names = [f["name"] for f in c["fetchers"] if f["name"] not in existing]
# Pass every fetcher (not just addable ones): the picker shows the
# already-added ones greyed out so a fully-added category — e.g.
# datadog once all 13 are in — still appears instead of vanishing.
names = [f["name"] for f in c["fetchers"]]
if names:
groups.append((c["name"], names))
if not groups:
self.notify("Every discovered fetcher is already in the manifest.")
self.notify("No fetchers discovered.")
return

def done(names: Optional[List[str]]) -> None:
Expand Down Expand Up @@ -325,7 +328,8 @@ def done(names: Optional[List[str]]) -> None:
MultiPickerModal(
"Add fetchers",
groups,
subtitle="enter/space opens a platform or toggles a fetcher · type to filter",
subtitle="enter/space opens a platform or toggles a fetcher · ✓ = already in manifest · type to filter",
disabled=existing,
),
done,
)
Expand Down
Loading
Loading