from __future__ import annotations
import json
import os
import subprocess
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BIN = ROOT / "target" / "debug" / "asobi"
def run(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
[str(BIN), *args],
cwd=ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
if result.returncode != 0:
raise AssertionError(
f"command failed: asobi {' '.join(args)}\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}"
)
return result
def run_expect_failure(
args: list[str], env: dict[str, str]
) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
[str(BIN), *args],
cwd=ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
if result.returncode == 0:
raise AssertionError(
f"command unexpectedly succeeded: asobi {' '.join(args)}\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}"
)
return result
def graph(args: list[str], env: dict[str, str]) -> dict:
return json.loads(run(args, env).stdout)
def entity_names(payload: dict) -> set[str]:
return {entity["name"] for entity in payload["entities"]}
def observations(payload: dict, name: str) -> list[str]:
for entity in payload["entities"]:
if entity["name"] == name:
return entity["observations"]
return []
def truths(payload: dict, name: str) -> dict[str, str]:
for entity in payload["entities"]:
if entity["name"] == name:
return entity["truths"]
return {}
def main() -> None:
subprocess.run(["cargo", "build"], cwd=ROOT, check=True)
with tempfile.TemporaryDirectory(prefix="asobi-cli-") as tmp:
env = os.environ.copy()
env["ASOBI_DATABASE_URL"] = str(Path(tmp) / "asobi.db")
run(["new", "project-a", "project"], env)
run(["new", "project-a:session", "session"], env)
run(["new", "UserPreferences", "preference"], env)
run(
[
"obs",
"project-a",
"Uses libSQL with FTS5 porter stemming for graph recall.",
],
env,
)
run(
[
"obs",
"project-a:session",
"status: IN_PROGRESS; next: verify CLI handoff",
],
env,
)
run(
[
"obs",
"UserPreferences",
"Prefer narrow graph commands over document-tier startup.",
],
env,
)
run(["link", "project-a", "UserPreferences", "follows"], env)
opened = graph(["show", "project-a", "UserPreferences"], env)
names = entity_names(opened)
assert names == {"project-a", "UserPreferences"}
assert opened["relations"] == [
{
"from": "project-a",
"to": "UserPreferences",
"relationType": "follows",
}
]
stemmed = graph(["search", "stem"], env)
assert "project-a" in entity_names(stemmed)
for idx in range(5):
run(["new", f"limit-{idx}", "project"], env)
run(["obs", f"limit-{idx}", "limitterm"], env)
limited = graph(["search", "limitterm", "--limit", "3"], env)
assert len(limited["entities"]) == 3
name_fallback = graph(["search", "UserPreferences"], env)
assert "UserPreferences" in entity_names(name_fallback)
invalid_fts = graph(["search", "AND AND"], env)
assert invalid_fts["entities"] == []
suspicious_name = "cli-日本語-'; DROP TABLE mcp_entities; --"
normalized_suspicious_name = "cli-DROP-TABLE-mcp_entities"
suspicious_observation = "quote:' newline:\n control:\x07 percent:%"
run(["new", suspicious_name, "project"], env)
run(["obs", suspicious_name, suspicious_observation], env)
suspicious = graph(["show", suspicious_name], env)
assert entity_names(suspicious) == {normalized_suspicious_name}
assert observations(suspicious, normalized_suspicious_name) == [
suspicious_observation
]
injected = graph(["search", "drop"], env)
assert normalized_suspicious_name in entity_names(injected)
still_there = graph(["graph"], env)
assert {
"project-a",
"project-a:session",
"UserPreferences",
normalized_suspicious_name,
}.issubset(entity_names(still_there))
run(
[
"rm-obs",
"project-a:session",
"status: IN_PROGRESS; next: verify CLI handoff",
],
env,
)
run(["obs", "project-a:session", "status: DONE"], env)
session = graph(["show", "project-a:session"], env)
assert observations(session, "project-a:session") == ["status: DONE"]
run(["truth", "project-a", "language", "rust"], env)
run(["truth", "project-a", "edition", "2021"], env)
run(["truth", "project-a", "edition", "2024"], env) with_truths = graph(["show", "project-a"], env)
assert truths(with_truths, "project-a") == {
"language": "rust",
"edition": "2024",
}
run(["rm-truth", "project-a", "language"], env)
after_truth_delete = graph(["show", "project-a"], env)
assert truths(after_truth_delete, "project-a") == {"edition": "2024"}
run(["rm", "UserPreferences"], env)
after_delete = graph(["show", "project-a", "UserPreferences"], env)
assert entity_names(after_delete) == {"project-a"}
assert after_delete["relations"] == []
stats = run(["stats"], env).stdout
assert "Knowledge Graph Statistics" in stats
export_file = str(Path(tmp) / "backup.json")
run(["export", "--output", export_file], env)
assert Path(export_file).exists()
run(["reset", "--force"], env)
empty_graph = graph(["graph"], env)
assert empty_graph["entities"] == []
assert empty_graph["relations"] == []
run(["import", export_file], env)
restored_graph = graph(["graph"], env)
assert "project-a" in entity_names(restored_graph)
with tempfile.TemporaryDirectory(prefix="asobi-corrupt-") as tmp:
db_path = Path(tmp) / "corrupt.db"
db_path.write_bytes(b"not a sqlite database")
env = os.environ.copy()
env["ASOBI_DATABASE_URL"] = str(db_path)
failed = run_expect_failure(["graph"], env)
assert "database" in failed.stderr.lower()
batch_and_json_checks()
skills_checks()
agent_feature_checks()
print("CLI graph integration checks passed")
def batch_and_json_checks() -> None:
with tempfile.TemporaryDirectory(prefix="asobi-batch-") as tmp:
env = os.environ.copy()
env["ASOBI_DATABASE_URL"] = str(Path(tmp) / "asobi.db")
run(
["new", "alpha", "task", "beta", "concept", "gamma", "ref"],
env,
)
assert entity_names(graph(["graph"], env)) == {"alpha", "beta", "gamma"}
bad_pairs = run_expect_failure(["new", "x", "task", "y"], env)
assert "pair" in bad_pairs.stderr.lower()
run(
["link", "alpha", "beta", "uses", "alpha", "gamma", "blocks"],
env,
)
rels = graph(["graph"], env)["relations"]
assert {(r["from"], r["to"], r["relationType"]) for r in rels} == {
("alpha", "beta", "uses"),
("alpha", "gamma", "blocks"),
}
bad_triples = run_expect_failure(["link", "a", "b", "uses", "c"], env)
assert "triple" in bad_triples.stderr.lower()
echoed = json.loads(run(["new", "delta", "task", "--json"], env).stdout)
assert "delta" in entity_names(echoed)
obs_echo = json.loads(run(["obs", "delta", "first obs", "--json"], env).stdout)
assert observations(obs_echo, "delta") == ["first obs"]
rel_echo = json.loads(
run(["link", "delta", "alpha", "uses", "--json"], env).stdout
)
assert {
"from": "delta",
"to": "alpha",
"relationType": "uses",
} in rel_echo["relations"]
truth_echo = json.loads(
run(["truth", "delta", "status", "READY", "--json"], env).stdout
)
assert truths(truth_echo, "delta") == {"status": "READY"}
del_echo = json.loads(run(["rm", "gamma", "--json"], env).stdout)
assert del_echo == {"deleted": ["gamma"]}
def skills_checks() -> None:
with tempfile.TemporaryDirectory(prefix="asobi-skills-") as tmp:
root = Path(tmp)
env = os.environ.copy()
env["ASOBI_HOME"] = str(root / "home")
env["ASOBI_DATABASE_URL"] = str(root / "asobi.db")
src = root / "src-skills"
(src / "nested").mkdir(parents=True)
(src / "alpha.md").write_text(
"---\nname: alpha\ndescription: Alpha skill\n---\nAlpha body here\n"
)
(src / "nested" / "SKILL.md").write_text(
"---\ndescription: Nested skill\n---\nNested body\n"
)
run(["skills", "install", str(src), "--all"], env)
listed = run(["skills"], env).stdout
assert "Installed Skills:" in listed
assert "alpha" in listed
assert "nested" in listed
assert "Alpha skill" in listed
shown = run(["skills", "show", "alpha"], env).stdout
assert "Alpha body here" in shown
run(["skills", "remove", str(src)], env)
assert "No skills installed." in run(["skills"], env).stdout
run(["skills", "install", str(src), "--select", "alpha"], env)
selected = run(["skills"], env).stdout
assert "alpha" in selected
assert "nested" not in selected
run(["skills", "remove", str(src)], env)
bad_select = run_expect_failure(
["skills", "install", str(src), "--select", "ghost"], env
)
assert "not found" in bad_select.stderr.lower()
missing = run_expect_failure(
["skills", "install", str(root / "does-not-exist"), "--all"], env
)
assert "does not exist" in missing.stderr.lower()
unreachable = run_expect_failure(
["skills", "install", f"file://{root}/no-such-repo.git", "--all"], env
)
assert "clone" in unreachable.stderr.lower()
no_git_env = dict(env)
no_git_env["PATH"] = str(root / "empty-bin")
(root / "empty-bin").mkdir()
no_git = run_expect_failure(
["skills", "install", "https://example.com/owner/repo.git", "--all"],
no_git_env,
)
assert "git" in no_git.stderr.lower()
def agent_feature_checks() -> None:
with tempfile.TemporaryDirectory(prefix="asobi-agent-") as tmp:
env = os.environ.copy()
env["ASOBI_DATABASE_URL"] = str(Path(tmp) / "asobi.db")
run(["new", "alice", "person", "bob", "person"], env)
run(
["obs", "alice", "status: active", "next: code", "old info"],
env,
)
run(["link", "alice", "bob", "follows"], env)
shown = graph(["show", "alice", "--with-ids"], env)
detailed = shown["entities"][0]["observationsDetailed"]
assert detailed[0]["id"] == 1
assert detailed[0]["content"] == "status: active"
assert detailed[2]["id"] == 3
assert detailed[2]["content"] == "old info"
run(["rm-obs", "alice", "1", "--id"], env)
run(["update-obs", "alice", "3", "new info", "--id"], env)
shown = graph(["show", "alice", "--with-ids"], env)
detailed = shown["entities"][0]["observationsDetailed"]
contents = {o["content"] for o in detailed}
assert contents == {"next: code", "new info"}
expanded = graph(["show", "alice", "--expand", "follows"], env)
names = {e["name"] for e in expanded["entities"]}
assert names == {"alice", "bob"}
stats_out = run(["stats", "--per-entity"], env).stdout
assert "Entities by Observation Count:" in stats_out
assert "alice" in stats_out
stats_json = json.loads(run(["--json", "stats", "--per-entity"], env).stdout)
assert stats_json["entities"] == 2
assert stats_json["relations"] == 1
assert stats_json["entitiesDetailed"][0]["name"] == "alice"
failed = run_expect_failure(["--json", "import", "nonexistent_abc.json"], env)
err_json = json.loads(failed.stdout)
assert err_json["status"] == "failed"
assert "No such file or directory" in err_json["error"]
if __name__ == "__main__":
main()