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
55 changes: 40 additions & 15 deletions flocks/cli/service_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,15 @@ def resolve_python_subprocess_command(
"""Resolve a Python executable for child processes.

Priority:
1. Current runtime environment inferred from installed modules.
2. Project/install `.venv`.
3. Current `sys.executable`.
1. Project/install ``.venv``.
2. Current runtime environment inferred from installed modules.
3. Current ``sys.executable``.
"""
current_root = root or repo_root()
venv_python = _python_executable_from_env_root(current_root / ".venv")
if venv_python:
return [venv_python]

for module_name in preferred_modules:
env_root = _python_env_root_from_module(module_name)
if env_root is None:
Expand All @@ -198,26 +203,44 @@ def resolve_python_subprocess_command(
if resolved:
return [resolved]

current_root = root or repo_root()
venv_python = _python_executable_from_env_root(current_root / ".venv")
if venv_python:
return [venv_python]

return [sys.executable]


def _flocks_executable_from_venv(venv_root: Path) -> str | None:
"""Return the flocks CLI entry point inside a virtual environment."""
candidates = [
venv_root / "Scripts" / "flocks.exe",
venv_root / "Scripts" / "flocks.cmd",
venv_root / "bin" / "flocks",
]
for candidate in candidates:
if candidate.exists():
return str(candidate.resolve())
return None


def resolve_flocks_cli_command(root: Path | None = None) -> list[str]:
"""Resolve a command prefix that launches the `flocks` CLI reliably."""
"""Resolve a command prefix that launches the ``flocks`` CLI reliably.

On Windows, always uses ``python.exe -m flocks.cli.main`` instead of
``flocks.exe`` to avoid locking the console-script entry point, which
would prevent ``uv sync`` from replacing it during live upgrades.
"""
current_root = root or repo_root()

if sys.platform == "win32":
venv_python = _python_executable_from_env_root(current_root / ".venv")
if venv_python:
return [venv_python, "-m", "flocks.cli.main"]
else:
venv_flocks = _flocks_executable_from_venv(current_root / ".venv")
if venv_flocks:
return [venv_flocks]

launcher = which("flocks") or which("flocks.exe") or which("flocks.cmd")
if launcher and not launcher.startswith("/mnt/"):
return [launcher]

argv0 = sys.argv[0]
if argv0:
argv0_path = Path(argv0)
if argv0_path.exists() and argv0_path.name.lower().startswith("flocks"):
return [str(argv0_path.resolve())]

return resolve_python_subprocess_command(root) + ["-m", "flocks.cli.main"]


Expand Down Expand Up @@ -632,6 +655,8 @@ def start_backend(config: ServiceConfig, console) -> None:
if runtime_record is not None:
paths.backend_pid.unlink(missing_ok=True)

_run_legacy_task_migration(root, console)

command = resolve_flocks_cli_command(root) + [
"serve",
"--host",
Expand Down
8 changes: 8 additions & 0 deletions flocks/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ async def lifespan(app: FastAPI):
except Exception as e:
log.warning("updater.leftovers.cleanup_failed", {"error": str(e)})

try:
from flocks.updater.updater import _get_repo_root, _refresh_global_cli_entry

await asyncio.to_thread(_refresh_global_cli_entry, _get_repo_root())
log.info("cli.global_entry.refreshed")
except Exception as e:
log.warning("cli.global_entry.refresh_failed", {"error": str(e)})

try:
init_observability()
log.info("observability.initialized")
Expand Down
104 changes: 72 additions & 32 deletions flocks/updater/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -2008,6 +2008,11 @@ async def perform_update(
shutil.rmtree(tmp_dir, ignore_errors=True)
_write_version_marker(latest_tag.lstrip("v"))

try:
_refresh_global_cli_entry(install_root)
except Exception as exc:
log.warning("updater.refresh_cli.failed", {"error": str(exc)})

# ------------------------------------------------------------------ #
# Step 7 – restart in-place (skipped when restart=False, e.g. CLI)
# Send the "restarting" event while the proxy is still alive, then
Expand Down Expand Up @@ -2101,15 +2106,67 @@ async def perform_update(
return


def _build_restart_argv(install_root: Path | None = None) -> list[str]:
def _refresh_global_cli_entry(install_root: Path) -> None:
"""Ensure the global ``flocks`` command points to the project ``.venv``.

Handles migration from the legacy uv-tool-based global command to a
symlink (Unix) or .cmd wrapper (Windows). Safe to call repeatedly.
"""
Reconstruct the argv for os.execv so the process restarts correctly.
link_dir = Path.home() / ".local" / "bin"
link_dir.mkdir(parents=True, exist_ok=True)

Handles two edge cases:
1. __main__.py path → reconstruct ``-m module`` form
2. --reload flags → strip them to avoid a second reloader
if sys.platform == "win32":
venv_python = install_root / ".venv" / "Scripts" / "python.exe"
if not venv_python.exists():
return
wrapper = link_dir / "flocks.cmd"
content = f'@echo off\r\n"{venv_python}" -m flocks.cli.main %*'
try:
wrapper.write_text(content, encoding="oem")
except LookupError:
wrapper.write_text(content, encoding="utf-8")
stale_exe = link_dir / "flocks.exe"
if stale_exe.exists():
try:
stale_exe.unlink(missing_ok=True)
except OSError:
try:
stale_exe.rename(stale_exe.with_suffix(".exe.bak"))
except OSError:
pass
else:
target = install_root / ".venv" / "bin" / "flocks"
if not target.exists():
return
link = link_dir / "flocks"
link.unlink(missing_ok=True)
link.symlink_to(target)

uv = shutil.which("uv")
if uv:
try:
result = subprocess.run(
[uv, "tool", "list"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0 and re.search(r"^flocks ", result.stdout, re.MULTILINE):
subprocess.run(
[uv, "tool", "uninstall", "flocks"],
capture_output=True,
timeout=30,
)
except Exception:
pass


def _build_restart_argv(install_root: Path | None = None) -> list[str]:
"""Reconstruct the argv for ``os.execv`` so the process restarts correctly.

Always uses the project ``.venv`` Python to ensure the restarted process
runs in the same environment that ``uv sync`` just updated.
"""
argv0 = sys.argv[0]
rest = sys.argv[1:]

clean_rest: list[str] = []
Expand All @@ -2126,34 +2183,17 @@ def _build_restart_argv(install_root: Path | None = None) -> list[str]:
continue
clean_rest.append(arg)

repo_root = install_root or _get_repo_root()
if sys.platform == "win32":
repo_root = install_root or _get_repo_root()
venv_python = _windows_upgrade_python_path(repo_root)
if not venv_python.exists():
raise FileNotFoundError(f"Windows restart runtime is missing: {venv_python}")
log.info("updater.restart.force_venv", {"python": str(venv_python)})
return [str(venv_python), "-m", "flocks.cli.main"] + clean_rest

if argv0.endswith("__main__.py"):
pkg_dir = Path(argv0).parent
parts: list[str] = []
current = pkg_dir
while (current / "__init__.py").exists():
parts.insert(0, current.name)
current = current.parent

if parts:
module = ".".join(parts)
log.info(
"updater.restart.module_mode",
{
"module": module,
"reload_stripped": len(rest) - len(clean_rest),
},
)
return [sys.executable, "-m", module] + clean_rest
venv_python = repo_root / ".venv" / "Scripts" / "python.exe"
else:
venv_python = repo_root / ".venv" / "bin" / "python"

if not venv_python.exists():
raise FileNotFoundError(f"Restart runtime is missing: {venv_python}")

return [sys.executable, argv0] + clean_rest
log.info("updater.restart.force_venv", {"python": str(venv_python)})
return [str(venv_python), "-m", "flocks.cli.main"] + clean_rest


def _resolve_windows_restart_command(argv0: str, orig_argv: list[str]) -> list[str] | None:
Expand Down
7 changes: 3 additions & 4 deletions npm-wrapper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

npm wrapper for [Flocks](https://github.com/flocks-ai/flocks) — AI-Native SecOps platform.

Flocks is a Python package. This wrapper detects `uvx`, `pipx`, or a globally installed `flocks` binary and delegates to it.
Flocks is a Python package. This wrapper detects a globally installed `flocks` binary, `uvx`, or `pipx` and delegates to it.

## Quick start

Expand All @@ -25,11 +25,10 @@ Install `uv` (recommended):
curl -LsSf https://astral.sh/uv/install.sh | sh
```

Or install Flocks directly:
Or install Flocks directly via the install script:

```bash
uv tool install flocks # recommended
pipx install flocks # alternative
curl -fsSL https://raw.githubusercontent.com/AgentFlocks/Flocks/main/install.sh | bash
```

## Skill registry
Expand Down
33 changes: 16 additions & 17 deletions npm-wrapper/bin/flocks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
* launchers and delegates to the real `flocks` CLI.
*
* Install preference order:
* 1. uvx flocks — uv is the recommended Python launcher
* 2. pipx run flocks — pipx fallback
* 3. flocks — globally installed via pip
* 1. flocks — globally installed (symlink / wrapper → .venv)
* 2. uvx flocks — uv's tool runner (for PyPI-published packages)
* 3. pipx run flocks — pipx fallback
*
* Usage:
* npx @flocks-ai/flocks [command] [options]
Expand Down Expand Up @@ -37,33 +37,32 @@ function run(launcher, launcherArgs) {
process.exit(result.status ?? 1)
}

// 1. Try uvx (uv's tool runner — zero-install, like npx but for Python)
// 1. Try globally installed flocks (symlink / wrapper pointing to .venv)
if (hasCommand("flocks")) {
run("flocks", [])
}

// 2. Try uvx (uv's tool runner — for PyPI-published packages)
if (hasCommand("uvx")) {
run("uvx", ["flocks"])
}

// 2. Try pipx run
// 3. Try pipx run
if (hasCommand("pipx")) {
run("pipx", ["run", "flocks"])
}

// 3. Try globally installed flocks binary
if (hasCommand("flocks")) {
run("flocks", [])
}

// 4. Nothing found — guide the user
console.error(`
Error: Flocks requires Python (uv or pipx).
Error: Flocks requires Python.

Quick install options:
• Install uv (recommended):
curl -LsSf https://astral.sh/uv/install.sh | sh
Then retry: npx @flocks-ai/flocks
• Run the install script (recommended):
curl -fsSL https://raw.githubusercontent.com/AgentFlocks/Flocks/main/install.sh | bash

• Or install directly:
uv tool install flocks
pipx install flocks
• Or install uv and use npx:
curl -LsSf https://astral.sh/uv/install.sh | sh
npx @flocks-ai/flocks

See: https://github.com/flocks-ai/flocks
`)
Expand Down
64 changes: 51 additions & 13 deletions scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,10 @@ function Get-FlocksProcessIds {
}

$isMatch = [Regex]::IsMatch($commandLine, "flocks\.server\.app")
if (-not $isMatch -and $escapedProjectRoot) {
$escapedVenvDir = [Regex]::Escape((Join-Path $ProjectRoot ".venv"))
$isMatch = [Regex]::IsMatch($commandLine, $escapedVenvDir) -and [Regex]::IsMatch($commandLine, "flocks")
}
if (-not $isMatch -and $escapedProjectRoot) {
$isMatch = [Regex]::IsMatch($commandLine, $escapedProjectRoot) -and [Regex]::IsMatch($commandLine, "(uv tool|uv sync|npm(\.cmd)? run preview|vite preview)")
}
Expand Down Expand Up @@ -744,25 +748,59 @@ function Invoke-InstallerCommandWithLockRetry {
function Install-FlocksCli {
Write-Info "Installing the global flocks CLI..."

Push-Location $RootDir
try {
Invoke-InstallerCommandWithLockRetry `
-Description "Global flocks CLI installation" `
-FilePath "uv" `
-ArgumentList @("tool", "install", "--editable", $RootDir, "--force", "--default-index", $script:UvDefaultIndex) `
-WorkingDirectory $RootDir `
-StreamOutput
$linkDir = Join-Path $HOME ".local\bin"

if (Test-Command "uv") {
$savedEA = $ErrorActionPreference
try {
$ErrorActionPreference = "SilentlyContinue"
$toolList = (& uv tool list 2>&1) | Where-Object { $_ -is [string] -or $_ -isnot [System.Management.Automation.ErrorRecord] }
if ("$toolList" -match 'flocks') {
Write-Info "Removing legacy uv tool installation..."
& uv tool uninstall flocks 2>&1 | Out-Null
}
}
catch {
Write-Warning "Could not clean up legacy uv tool install. Continuing anyway."
}
finally {
$ErrorActionPreference = $savedEA
}
}
finally {
Pop-Location
$staleExe = Join-Path $linkDir "flocks.exe"
if (Test-Path $staleExe) {
Write-Info "Removing stale flocks.exe to avoid shadowing new wrapper..."
try {
Remove-Item -Force $staleExe -ErrorAction Stop
}
catch {
$backupExe = Join-Path $linkDir "flocks.exe.bak"
try {
Move-Item -Force $staleExe $backupExe -ErrorAction Stop
Write-Info "Could not delete flocks.exe (locked); renamed to flocks.exe.bak"
}
catch {
Write-Warning "Could not remove or rename flocks.exe — it may shadow the new flocks.cmd wrapper. Stop any running flocks process and re-run the installer."
}
}
}

$toolBin = (& uv tool dir --bin 2>$null).Trim()
if (-not [string]::IsNullOrWhiteSpace($toolBin) -and (Test-Path $toolBin)) {
Ensure-UserPathEntry $toolBin
$venvPython = Join-Path $RootDir ".venv\Scripts\python.exe"
if (-not (Test-Path $venvPython)) {
Fail "Expected .venv runtime not found: $venvPython - run 'uv sync' first."
}

if (-not (Test-Path $linkDir)) {
New-Item -ItemType Directory -Path $linkDir -Force | Out-Null
}

$wrapperPath = Join-Path $linkDir "flocks.cmd"
$wrapperContent = "@echo off`r`n`"$venvPython`" -m flocks.cli.main %*"
[System.IO.File]::WriteAllText($wrapperPath, $wrapperContent, [System.Text.Encoding]::Default)

Ensure-UserPathEntry $linkDir
Refresh-Path

if (-not (Test-Command "flocks")) {
Fail "The flocks CLI finished installing, but it is still not available. Check PATH and retry."
}
Expand Down
Loading
Loading