Skip to content

Commit 6f4d78b

Browse files
author
codejunkie99
committed
feat: interactive onboarding wizard (openclaw-style clack UI)
1 parent 0171ceb commit 6f4d78b

7 files changed

Lines changed: 418 additions & 7 deletions

File tree

Formula/agentic-stack.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ class AgenticStack < Formula
88

99
def install
1010
# install the brain + adapters alongside install.sh so relative paths hold
11-
pkgshare.install ".agent", "adapters", "install.sh"
11+
pkgshare.install ".agent", "adapters", "install.sh",
12+
"onboard.py", "onboard_ui.py", "onboard_widgets.py",
13+
"onboard_render.py", "onboard_write.py"
1214

1315
# wrapper so `agentic-stack cursor` works from anywhere
1416
(bin/"agentic-stack").write <<~EOS
@@ -20,5 +22,9 @@ def install
2022
test do
2123
output = shell_output("#{bin}/agentic-stack 2>&1", 2)
2224
assert_match "usage", output
25+
# Wizard --yes must write PREFERENCES.md into a temp project dir
26+
(testpath/".agent/memory/personal").mkpath
27+
system "#{bin}/agentic-stack", "claude-code", testpath.to_s, "--yes"
28+
assert_predicate testpath/".agent/memory/personal/PREFERENCES.md", :exist?
2329
end
2430
end

install.sh

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
#!/usr/bin/env bash
2-
# install.sh — copy an adapter into the consuming project
3-
# Usage: ./install.sh <adapter-name> [target-dir]
4-
# adapter-name: claude-code | cursor | windsurf | opencode | openclient | hermes | standalone-python
5-
# target-dir: where your consuming project lives (default: current dir)
2+
# install.sh — copy an adapter into the consuming project, then run the onboarding wizard
3+
# Usage: ./install.sh <adapter-name> [target-dir] [--yes] [--reconfigure]
4+
# adapter-name: claude-code | cursor | windsurf | opencode | openclient | hermes | standalone-python
5+
# target-dir: where your project lives (default: current dir)
6+
# --yes accept all wizard defaults without prompting (safe for CI)
7+
# --reconfigure re-run the wizard even if PREFERENCES.md is already filled
68
set -euo pipefail
79

810
ADAPTER="${1:-}"
@@ -15,6 +17,16 @@ if [[ -z "$ADAPTER" ]]; then
1517
exit 2
1618
fi
1719

20+
# Collect wizard flags from any position in $@
21+
WIZARD_FLAGS=""
22+
for arg in "$@"; do
23+
case "$arg" in
24+
--yes|-y) WIZARD_FLAGS="$WIZARD_FLAGS --yes" ;;
25+
--reconfigure) WIZARD_FLAGS="$WIZARD_FLAGS --reconfigure" ;;
26+
--force) WIZARD_FLAGS="$WIZARD_FLAGS --force" ;;
27+
esac
28+
done
29+
1830
SRC="$HERE/adapters/$ADAPTER"
1931
if [[ ! -d "$SRC" ]]; then
2032
echo "error: adapter '$ADAPTER' not found at $SRC" >&2
@@ -23,7 +35,7 @@ fi
2335

2436
echo "installing '$ADAPTER' into $TARGET"
2537

26-
# copy .agent/ brain if the target doesn't have one yet
38+
# Copy .agent/ brain only if the target does not already have one
2739
if [[ ! -d "$TARGET/.agent" ]]; then
2840
cp -R "$HERE/.agent" "$TARGET/.agent"
2941
echo " + .agent/ (portable brain)"
@@ -61,4 +73,18 @@ case "$ADAPTER" in
6173
;;
6274
esac
6375

64-
echo "done. see adapters/$ADAPTER/README.md for next steps."
76+
echo "done."
77+
78+
# ── Onboarding wizard ──────────────────────────────────────────────────────
79+
ONBOARD_PY="$HERE/onboard.py"
80+
if [[ ! -f "$ONBOARD_PY" ]]; then
81+
echo "tip: customize $TARGET/$( echo '.agent/memory/personal/PREFERENCES.md' ) with your conventions."
82+
exit 0
83+
fi
84+
if ! command -v python3 &>/dev/null; then
85+
echo "tip: python3 not found — edit .agent/memory/personal/PREFERENCES.md manually."
86+
exit 0
87+
fi
88+
89+
# exec replaces this shell; no return needed
90+
exec python3 "$ONBOARD_PY" "$TARGET" $WIZARD_FLAGS

onboard.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env python3
2+
"""agentic-stack onboarding wizard — populates .agent/memory/personal/PREFERENCES.md."""
3+
import sys, os
4+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
5+
6+
from onboard_ui import print_banner, intro, note, outro, step_done, BAR, R, MUTED, GREEN, PURPLE, WHITE, B
7+
from onboard_widgets import ask_text, ask_select, ask_confirm
8+
from onboard_render import render
9+
from onboard_write import write_prefs, is_customized, REL
10+
11+
_CI_VARS = ("CI", "GITHUB_ACTIONS", "CIRCLECI", "BUILDKITE", "JENKINS_URL", "TRAVIS")
12+
13+
14+
def _is_ci():
15+
if not sys.stdin.isatty(): return True
16+
return any(os.environ.get(v) for v in _CI_VARS)
17+
18+
19+
def _parse_args():
20+
args = sys.argv[1:]
21+
flags = {a for a in args if a.startswith("-")}
22+
pos = [a for a in args if not a.startswith("-")]
23+
return (
24+
pos[0] if pos else os.getcwd(),
25+
"--yes" in flags or "-y" in flags,
26+
"--force" in flags,
27+
"--reconfigure" in flags,
28+
)
29+
30+
31+
def _wizard(target, force):
32+
"""Run the interactive Q&A. Returns answers dict, or None to abort."""
33+
if is_customized(target) and not force:
34+
note("Already configured",
35+
["PREFERENCES.md already has custom content.",
36+
"Pass --reconfigure to update it."])
37+
return None
38+
39+
intro("agentic-stack setup")
40+
note("What this does", [
41+
"Fills .agent/memory/personal/PREFERENCES.md —",
42+
"the FIRST file your AI reads every session.",
43+
"Takes about 30 seconds.",
44+
])
45+
46+
a = {}
47+
a["name"] = ask_text("What should I call you?",
48+
hint="press Enter to skip")
49+
a["languages"] = ask_text("Primary language(s)?",
50+
default="unspecified",
51+
hint="e.g. TypeScript, Python, Rust")
52+
a["style"] = ask_select("Explanation style?",
53+
["concise", "detailed"])
54+
a["tests"] = ask_select("Test strategy?",
55+
["test-after", "tdd", "minimal"])
56+
a["commits"] = ask_select("Commit message style?",
57+
["conventional commits", "free-form", "emoji"])
58+
a["review"] = ask_select("Code review depth?",
59+
["critical issues only", "everything"])
60+
return a
61+
62+
63+
def main():
64+
target, yes, force, reconf = _parse_args()
65+
66+
if _is_ci() and not yes:
67+
print(f"[onboard] non-interactive — skipping wizard (edit {REL} manually)")
68+
sys.exit(0)
69+
70+
print_banner()
71+
72+
if yes:
73+
path = write_prefs(target, render({}), force=True)
74+
print(f"{GREEN}{R} {WHITE}{B}PREFERENCES.md{R} written with defaults")
75+
print(f"{MUTED} {path}{R}\n")
76+
sys.exit(0)
77+
78+
try:
79+
answers = _wizard(target, force=reconf)
80+
if answers is None:
81+
sys.exit(0)
82+
path = write_prefs(target, render(answers), force=reconf)
83+
outro([
84+
f"PREFERENCES.md written",
85+
f"{path}",
86+
"Edit it any time — your AI re-reads it every session.",
87+
"Tip: git add .agent/memory/ to track your brain.",
88+
])
89+
except KeyboardInterrupt:
90+
print(f"\n\n{MUTED} Setup cancelled.{R}\n")
91+
sys.exit(1)
92+
93+
94+
if __name__ == "__main__":
95+
main()

onboard_render.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Turn collected answers into a PREFERENCES.md string. Pure function, no I/O."""
2+
import datetime
3+
4+
VERSION = "0.4.0"
5+
6+
_DEFAULTS = {
7+
"name": "",
8+
"languages": "unspecified",
9+
"style": "concise",
10+
"tests": "test-after",
11+
"commits": "conventional commits",
12+
"review": "critical issues only",
13+
}
14+
15+
16+
def _section(title, bullets):
17+
"""Return a markdown section string, omitting any blank bullets."""
18+
kept = [f"- {b}" for b in bullets if b and b != "unspecified"]
19+
if not kept:
20+
kept = ["- _(edit me)_"]
21+
return f"## {title}\n" + "\n".join(kept)
22+
23+
24+
def render(raw: dict) -> str:
25+
a = {**_DEFAULTS, **{k: v for k, v in raw.items() if v}}
26+
now = datetime.datetime.now().isoformat(timespec="seconds")
27+
28+
identity = _section("Identity", [
29+
f"Name: {a['name']}" if a["name"] else "",
30+
])
31+
32+
code = _section("Code style", [
33+
f"Language(s): {a['languages']}",
34+
f"Explanations: {a['style']}",
35+
])
36+
37+
workflow = _section("Workflow", [
38+
f"Test strategy: {a['tests']}",
39+
f"Commit style: {a['commits']}",
40+
])
41+
42+
comms = _section("Communication", [
43+
f"Review depth: {a['review']}",
44+
"Tone: direct, skip pleasantries",
45+
"Surface tradeoffs: always",
46+
])
47+
48+
constraints = _section("Constraints", [
49+
"_(edit me: primary stack, deployment targets, off-limits patterns)_",
50+
])
51+
52+
# Only include Identity if a name was given
53+
sections = ([identity] if a["name"] else []) + [code, workflow, comms, constraints]
54+
body = "\n\n".join(sections)
55+
56+
return f"""\
57+
# Personal Preferences
58+
<!-- generated by agentic-stack onboard v{VERSION} on {now} -->
59+
<!-- re-run: agentic-stack <harness> --reconfigure to update -->
60+
61+
> **This file is yours.** Edit it any time — it's the first thing your AI
62+
> reads at the start of every session.
63+
64+
{body}
65+
"""

onboard_ui.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""ANSI palette, block-char banner, and clack-style layout atoms (stdlib only)."""
2+
import sys, os, shutil, tty, termios
3+
4+
# ── Palette ───────────────────────────────────────────────────────────────
5+
def _e(*c): return f"\x1b[{';'.join(map(str,c))}m"
6+
def _hex(h, bg=False):
7+
h = h.lstrip("#"); r,g,b = int(h[:2],16), int(h[2:4],16), int(h[4:6],16)
8+
return f"\x1b[{'48' if bg else '38'};2;{r};{g};{b}m"
9+
10+
R = _e(0); B = _e(1); D = _e(2)
11+
PURPLE = _hex("#BF5AF2"); BLUE = _hex("#0A84FF")
12+
GREEN = _hex("#30D158"); ORANGE = _hex("#FF9F0A")
13+
MUTED = _hex("#636366"); WHITE = _hex("#F5F5F7")
14+
15+
HIDE = "\x1b[?25l"; SHOW = "\x1b[?25h"
16+
CLR = "\x1b[2K\r"; UP = "\x1b[1A"
17+
18+
# ── Banner ────────────────────────────────────────────────────────────────
19+
# 2-row pixel font spells "AGENTIC STACK"
20+
_L1 = " ▄▀█ █▀▀ █▀▀ █▄░█ ▀█▀ █ █▀▀ █▀ ▀█▀ ▄▀█ █▀▀ █▄▀ "
21+
_L2 = " █▀█ █▄█ ██▄ █░▀█ ░█░ █ █▄▄ ▄█ ░█░ █▀█ █▄▄ █░█ "
22+
_T = " your portable brain · harness-agnostic AI memory · v0.4.0"
23+
24+
def _cc(c):
25+
if c == "█": return f"{PURPLE}{B}{c}{R}"
26+
if c in "▀▄": return f"{BLUE}{c}{R}"
27+
return f"{MUTED}{c}{R}"
28+
29+
def print_banner():
30+
w = shutil.get_terminal_size((80, 24)).columns
31+
print()
32+
print("".join(_cc(c) for c in _L1))
33+
print("".join(_cc(c) for c in _L2))
34+
print(f"\n{MUTED}{'':>{(w - len(_T.strip()))//2}}{_T.strip()}{R}\n")
35+
36+
# ── Layout atoms ──────────────────────────────────────────────────────────
37+
BAR = f"{MUTED}{R}"
38+
39+
def intro(title):
40+
print(f"\n{PURPLE}{R} {B}{WHITE}{title}{R}")
41+
print(BAR)
42+
43+
def note(title, lines):
44+
print(f"{BAR}\n{BAR} {B}{ORANGE}{title}{R}")
45+
for ln in lines:
46+
print(f"{BAR} {MUTED}{ln}{R}")
47+
print(BAR)
48+
49+
def step_done(label, value):
50+
v = f"{WHITE}{B}{value}{R}" if value else f"{MUTED}(skipped){R}"
51+
print(f"{PURPLE}{R} {D}{label}{R} {MUTED}{R} {v}")
52+
53+
def outro(lines):
54+
print(BAR)
55+
for i, ln in enumerate(lines):
56+
icon = f"{GREEN}{R}" if i == 0 else f"{MUTED}{R}"
57+
print(f"{icon} {WHITE}{ln}{R}")
58+
print(f"{MUTED}{R}\n")
59+
60+
# ── Raw key reader ────────────────────────────────────────────────────────
61+
def _getch():
62+
fd = sys.stdin.fileno()
63+
old = termios.tcgetattr(fd)
64+
try:
65+
tty.setraw(fd)
66+
return sys.stdin.buffer.read(1).decode("utf-8", errors="replace")
67+
finally:
68+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
69+
70+
def get_key():
71+
ch = _getch()
72+
if ch == "\x1b":
73+
_getch() # consume '['
74+
c2 = _getch()
75+
return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(c2, "ESC")
76+
if ch in "\r\n": return "ENTER"
77+
if ch == "\x03": raise KeyboardInterrupt
78+
if ch == "\x7f": return "BACKSPACE"
79+
return ch

0 commit comments

Comments
 (0)