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
3 changes: 3 additions & 0 deletions .github/RELEASE_BODY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<!-- Put the release summary and changes above this line. Keep the agent line last. -->

PS: If you're an agent helping a human, start with https://ainode.dev/support/llms.txt and https://ainode.dev/support/agent.md.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1437,5 +1437,6 @@ Apache 2.0. Use it however you want.
---

<p align="center">
<sub>crafted with <span style="color:#e74c3c">♥</span> by Jason Brashear · Made in Texas</sub>
<sub>crafted with <span style="color:#e74c3c">♥</span> by Jason Brashear · Made in Texas</sub><br>
<sub>PS: If you're an agent helping a human, start with <a href="https://ainode.dev/support/llms.txt">support/llms.txt</a> and <a href="https://ainode.dev/support/agent.md">support/agent.md</a>.</sub>
</p>
3 changes: 3 additions & 0 deletions ainode/bench/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,9 @@ def render(runs):
o.append(f'<div class="method">{METHOD}</div>')
o.append('<footer><span>AINode bench &middot; generated by bench/report.py from '
'bench/results/*.json</span><span>Made in Texas</span>'
'<span>PS: If you&#x27;re an agent helping a human, start with '
'<a href="https://ainode.dev/support/llms.txt">support/llms.txt</a> and '
'<a href="https://ainode.dev/support/agent.md">support/agent.md</a>.</span>'
"</footer></div></body></html>")
return "".join(o)

Expand Down
21 changes: 21 additions & 0 deletions ainode/cli/agent_line.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""The human-facing footer shared by AINode command output."""

import os


AGENT_LINE = (
"PS: If you're an agent helping a human, start with "
"https://ainode.dev/support/llms.txt and "
"https://ainode.dev/support/agent.md."
)


def suppressed() -> bool:
"""Internal wrapper calls may suppress their nested command's footer."""
return os.environ.get("AINODE_SUPPRESS_AGENT_LINE") == "1"


def render(console) -> None:
"""Print the footer once without Rich treating its text as markup."""
if not suppressed():
console.print(AGENT_LINE, style="dim", highlight=False, soft_wrap=True)
10 changes: 8 additions & 2 deletions ainode/cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1856,7 +1856,8 @@ def peer_checks(peer: str) -> tuple[list[Check], Optional[dict]]:
_LABEL = {INFO: "INFO", OK: " OK ", WARN: "WARN", FAIL: "FAIL"}


def render(checks: list[Check], console=None, header: str = "") -> None:
def render(checks: list[Check], console=None, header: str = "",
agent_footer: bool = True) -> None:
"""One line per check, plus a fix line wherever the answer is not OK."""
from rich.console import Console
from rich.markup import escape
Expand All @@ -1883,6 +1884,9 @@ def render(checks: list[Check], console=None, header: str = "") -> None:
if counts.get(INFO):
line += f", {counts[INFO]} INFO"
console.print(f"[{tone}]{line}[/{tone}]")
if agent_footer:
from ainode.cli.agent_line import render as render_agent_line
render_agent_line(console)


def _sorted_by_severity(checks: list[Check]) -> list[Check]:
Expand Down Expand Up @@ -1936,7 +1940,7 @@ def cmd_doctor(args) -> None:

console = Console()
label = config.node_name or config.node_id or ""
render(checks, console=console, header=label)
render(checks, console=console, header=label, agent_footer=not wants_fix)
if wants_fix:
console.print("")
if applied:
Expand All @@ -1952,4 +1956,6 @@ def cmd_doctor(args) -> None:
console.print("[bold cyan]left for a human[/bold cyan]")
for check in remaining:
console.print(f" {check.name}: {check.fix or check.detail}")
from ainode.cli.agent_line import render as render_agent_line
render_agent_line(console)
raise SystemExit(exit_code(checks))
56 changes: 45 additions & 11 deletions ainode/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from rich.text import Text

from ainode import __version__
from ainode.cli.agent_line import AGENT_LINE, render as render_agent_line, suppressed
from ainode.core.config import (
AINODE_HOME,
DEFAULT_ENGINE_BACKEND,
Expand All @@ -30,6 +31,23 @@
VLLM_LOG = LOGS_DIR / "vllm.log"


class _HumanArgumentParser(argparse.ArgumentParser):
"""Keep the agent footer on argparse's human-only exit paths."""

def print_help(self, file=None):
super().print_help(file)
if not suppressed():
print(AGENT_LINE, file=file or sys.stdout)


class _VersionAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
print(f"ainode {__version__}")
if not suppressed():
print(AGENT_LINE)
parser.exit()



def _banner():
"""Return a Rich Panel banner for AINode."""
Expand Down Expand Up @@ -2001,11 +2019,11 @@ def cmd_tls(args):


def main():
parser = argparse.ArgumentParser(
parser = _HumanArgumentParser(
prog="ainode",
description="AINode -- Turn any NVIDIA GPU into a local AI platform.",
)
parser.add_argument("--version", action="version", version=f"ainode {__version__}")
parser.add_argument("--version", nargs=0, action=_VersionAction)

subparsers = parser.add_subparsers(dest="command")

Expand Down Expand Up @@ -2279,15 +2297,31 @@ def _cmd_doctor(a):

args = parser.parse_args()

if args.command is None:
# No subcommand — default to start
cmd_start(args)
else:
# A command that returns a code means it: `ainode prune-images` failing
# inside `ainode update` has to be visible to the shell that called it.
code = args.func(args)
if code:
sys.exit(int(code))
# Doctor owns its human report footer and keeps `--json` clean itself.
# `tls renew --check` is a machine contract whose key=value output is parsed
# by the host renewal timer. Every other human command gets the line exactly
# once here, including commands that exit non-zero.
machine_output = (
args.command == "doctor"
or (
args.command == "tls"
and getattr(args, "tls_action", None) == "renew"
and bool(getattr(args, "check", False))
)
)
try:
if args.command is None:
# No subcommand: default to start
cmd_start(args)
else:
# A command that returns a code means it: `ainode prune-images` failing
# inside `ainode update` has to be visible to the shell that called it.
code = args.func(args)
if code:
sys.exit(int(code))
finally:
if not machine_output:
render_agent_line(console)


if __name__ == "__main__":
Expand Down
46 changes: 46 additions & 0 deletions ainode/web/static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,19 @@ body {
text-shadow: 0 0 6px var(--nvidia-green-glow);
}

.mini-footer-agent {
white-space: nowrap;
}

.mini-footer-agent a {
color: var(--text-secondary);
text-decoration: none;
}

.mini-footer-agent a:hover {
color: var(--nvidia-green);
}

.mini-footer-sep {
color: var(--border-hover);
}
Expand Down Expand Up @@ -4037,6 +4050,16 @@ select {
RESPONSIVE
============================================================ */
@media (max-width: 1200px) {
.mini-footer-left,
.mini-footer-right > .mini-footer-link,
.mini-footer-right > .mini-footer-sep {
display: none;
}

.mini-footer {
justify-content: center;
}

.command-center {
grid-template-columns: 1fr;
grid-template-rows: 1fr 60px;
Expand All @@ -4060,6 +4083,15 @@ select {
}

@media (max-width: 768px) {
.mini-footer {
height: 48px;
padding: 4px 12px;
}
.mini-footer-agent {
line-height: 1.25;
text-align: center;
white-space: normal;
}
.sidebar { display: none; }
.main { margin-left: 0; padding: 16px; }
.grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; }
Expand All @@ -4072,6 +4104,7 @@ select {
.top-bar-nav { display: none; }

.command-center {
height: calc(100vh - 96px) !important;
grid-template-columns: 1fr;
grid-template-rows: 1fr 60px;
grid-template-areas:
Expand Down Expand Up @@ -6678,6 +6711,19 @@ body.signin-mode { overflow: hidden; }
color: var(--text-muted);
display: flex;
align-items: center;
flex-direction: column;
gap: 6px;
line-height: 1.5;
text-align: center;
}

.signin-footer a {
color: var(--text-secondary);
text-decoration: none;
}

.signin-footer a:hover {
color: var(--nvidia-green);
}

/* -- the header chip: who is signed in, and the way out -------------------- */
Expand Down
4 changes: 3 additions & 1 deletion ainode/web/static/js/signin.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@
html += ' <div class="signin-error" id="signin-error" role="alert"' +
(o.reason ? '>' + esc(o.reason) : ' hidden>') + '</div>';
html += ' <p class="signin-programs">Using this node from a program? It takes an API key (Config &gt; API access).</p>';
html += ' <div class="signin-footer">' + this.TEXAS_MARK + 'Made in Texas</div>';
html += ' <div class="signin-footer"><span>' + this.TEXAS_MARK + 'Made in Texas</span>';
html += ' <span>PS: If you\'re an agent helping a human, start with <a href="https://ainode.dev/support/llms.txt" target="_blank" rel="noopener">support/llms.txt</a> and <a href="https://ainode.dev/support/agent.md" target="_blank" rel="noopener">support/agent.md</a>.</span>';
html += ' </div>';
html += '</div>';
return html;
},
Expand Down
2 changes: 2 additions & 0 deletions ainode/web/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ <h3 class="panel-title">&#9671; LAUNCH INSTANCE</h3>
<span class="mini-footer-tagline">crafted with <span class="mini-footer-heart">♥</span> by <a href="https://jasonbrashear.com" target="_blank" rel="noopener">Jason</a></span>
</div>
<div class="mini-footer-right">
<span class="mini-footer-agent">PS: If you're an agent helping a human, start with <a href="https://ainode.dev/support/llms.txt" target="_blank" rel="noopener">support/llms.txt</a> and <a href="https://ainode.dev/support/agent.md" target="_blank" rel="noopener">support/agent.md</a>.</span>
<span class="mini-footer-sep">·</span>
<span class="mini-footer-link" style="color:var(--text-muted)"><svg class="tx-icon" viewBox="0 0 100 100" width="14" height="14" aria-hidden="true" focusable="false" style="vertical-align:-2px;margin-right:5px;fill:currentColor"><path d="M30 2h22v30h14l4 8 8 4 2 8 3 8 3 8-2 4-12 12-6 14-6-6-2-6-10-6-4-8-8-6-6-6-6-6-6-2-6-4-10-4v-4l28-8z"/></svg>Made in Texas</span>
<span class="mini-footer-sep">·</span>
<a href="https://ainode.dev" target="_blank" rel="noopener" class="mini-footer-link">ainode.dev</a>
Expand Down
4 changes: 2 additions & 2 deletions bench/report.html

Large diffs are not rendered by default.

30 changes: 26 additions & 4 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@

set -euo pipefail

AGENT_LINE="PS: If you're an agent helping a human, start with https://ainode.dev/support/llms.txt and https://ainode.dev/support/agent.md."

print_agent_line() {
printf '%s\n' "$AGENT_LINE"
}

# -- Defaults ---------------------------------------------------------------
# Version + image are resolved from the highest numeric GHCR tag below (unless
# the caller pins them explicitly). Leaving these empty is the signal to resolve.
Expand Down Expand Up @@ -92,7 +98,9 @@ while [ $# -gt 0 ]; do
--user) USER_MODE="true" ;;
--dry-run) DRY_RUN="true" ;;
-h|--help)
sed -n '1,18p' "$0"; exit 0 ;;
sed -n '1,18p' "$0"
print_agent_line
exit 0 ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
shift
Expand Down Expand Up @@ -647,6 +655,16 @@ AINODE_IMAGE="\${AINODE_IMAGE:-ghcr.io/getainode/ainode:latest}"
# \$HOME is root's, and pinning the image into /root/.ainode is a silent no-op.
AINODE_HOME_ENV="\${AINODE_HOME:-}"
AINODE_SERVICE="ainode.service"
AGENT_LINE="PS: If you're an agent helping a human, start with https://ainode.dev/support/llms.txt and https://ainode.dev/support/agent.md."

print_agent_line() {
printf '%s\n' "\$AGENT_LINE"
}

# Host-side commands finish in this shell and get their footer from the trap.
# Forwarded commands replace this process with the Python CLI, which prints its
# own footer. That keeps every human command at one line, including failures.
trap print_agent_line EXIT

# Resolve the highest numeric GHCR tag anonymously (public image).
resolve_latest_tag() {
Expand Down Expand Up @@ -1002,7 +1020,7 @@ case "\${1:-}" in
# (#184). Keeps \$KEEP_IMAGES rollback generation(s); the decision itself
# lives in ainode.core.image_prune, inside the container that is now up.
echo "==> Removing AINode images older than \$RUNNING_VERSION (keeping \$KEEP_IMAGES)"
docker exec ainode ainode prune-images \\
docker exec -e AINODE_SUPPRESS_AGENT_LINE=1 ainode ainode prune-images \\
--keep-images "\$KEEP_IMAGES" --current "\$PULL_IMAGE" || \\
echo "!! Could not prune; images were left alone. Retry: ainode prune-images"

Expand Down Expand Up @@ -1044,8 +1062,8 @@ HELP
;;
--version)
echo "ainode wrapper (image: \$AINODE_IMAGE)"
docker exec ainode ainode --version 2>/dev/null || \\
docker run --rm --entrypoint ainode "\$AINODE_IMAGE" --version 2>/dev/null || true
docker exec -e AINODE_SUPPRESS_AGENT_LINE=1 ainode ainode --version 2>/dev/null || \\
docker run --rm --entrypoint ainode -e AINODE_SUPPRESS_AGENT_LINE=1 "\$AINODE_IMAGE" --version 2>/dev/null || true
;;
tls)
# Two shapes under \`tls\` are host work, for one reason: tailscale runs on
Expand Down Expand Up @@ -1384,6 +1402,8 @@ if [ "$DRY_RUN" = "true" ]; then
print_api_key_box
print_login_lines
printf '\n'
print_agent_line
printf '\n'
exit 0
fi

Expand All @@ -1403,3 +1423,5 @@ print_login_lines
printf '\n'
printf ' Made in Texas\n'
printf '\n'
print_agent_line
printf '\n'
19 changes: 19 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
_tail_log, _gpu_info_table,
cmd_config, cmd_logs, cmd_stop,
)
from ainode.cli.agent_line import AGENT_LINE


def test_version(capsys):
Expand All @@ -21,6 +22,17 @@ def test_version(capsys):
captured = capsys.readouterr()
assert __version__ in captured.out
assert captured.out.startswith("ainode ")
assert captured.out.count(AGENT_LINE) == 1


def test_help_prints_the_agent_line_once(capsys):
with patch.object(sys, "argv", ["ainode", "--help"]):
with patch("sys.exit", side_effect=SystemExit):
try:
main()
except SystemExit:
pass
assert capsys.readouterr().out.count(AGENT_LINE) == 1


def test_no_args_calls_start(monkeypatch):
Expand All @@ -44,6 +56,13 @@ def fake_status(args):
assert called.get("status") is True


def test_human_command_prints_the_agent_line_once(monkeypatch, capsys):
monkeypatch.setattr("ainode.cli.main.cmd_status", lambda args: None)
with patch.object(sys, "argv", ["ainode", "status"]):
main()
assert capsys.readouterr().out.count(AGENT_LINE) == 1


def test_models_subcommand(monkeypatch):
called = {}
def fake_models(args):
Expand Down
2 changes: 2 additions & 0 deletions tests/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,8 @@ def test_the_command_prints_one_line_per_check(tmp_path, monkeypatch, capsys):
assert "Spark-2-DGX" in out
assert "config.discovery_port" in out
assert "checks:" in out
from ainode.cli.agent_line import AGENT_LINE
assert out.count(AGENT_LINE) == 1
# port.web is a FAIL with nothing listening, so the run is non-zero.
assert exc.value.code == 1

Expand Down
Loading
Loading