@oalo-ai/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -2
  3. package/man/oalo.1 +1530 -0
  4. package/package.json +44 -4
  5. package/plugin/.claude-plugin/plugin.json +12 -0
  6. package/plugin/skills/oalo/SKILL.md +414 -0
  7. package/src/api.js +132 -0
  8. package/src/app-ref.js +11 -0
  9. package/src/args.js +67 -0
  10. package/src/bot-settings.json +468 -0
  11. package/src/channel.js +162 -0
  12. package/src/commands/apps.js +64 -0
  13. package/src/commands/bots.js +278 -0
  14. package/src/commands/call.js +179 -0
  15. package/src/commands/channels.js +110 -0
  16. package/src/commands/data.js +446 -0
  17. package/src/commands/db.js +58 -0
  18. package/src/commands/deploy.js +431 -0
  19. package/src/commands/dockerfile.js +81 -0
  20. package/src/commands/docs.js +70 -0
  21. package/src/commands/emit.js +124 -0
  22. package/src/commands/env.js +223 -0
  23. package/src/commands/graphs.js +570 -0
  24. package/src/commands/incidents.js +44 -0
  25. package/src/commands/link.js +150 -0
  26. package/src/commands/login.js +180 -0
  27. package/src/commands/logs.js +69 -0
  28. package/src/commands/mcp.js +125 -0
  29. package/src/commands/metrics.js +168 -0
  30. package/src/commands/products.js +169 -0
  31. package/src/commands/qa.js +98 -0
  32. package/src/commands/rehearse.js +100 -0
  33. package/src/commands/run.js +152 -0
  34. package/src/commands/secrets.js +68 -0
  35. package/src/commands/setup.js +96 -0
  36. package/src/commands/skill.js +142 -0
  37. package/src/commands/sources.js +377 -0
  38. package/src/commands/switches.js +156 -0
  39. package/src/commands/tail.js +84 -0
  40. package/src/commands/tokens.js +85 -0
  41. package/src/commands/typegen.js +505 -0
  42. package/src/commands/whoami.js +112 -0
  43. package/src/config.js +157 -0
  44. package/src/data.js +392 -0
  45. package/src/envfile.js +71 -0
  46. package/src/events.js +63 -0
  47. package/src/graphfiles.js +273 -0
  48. package/src/help.js +920 -0
  49. package/src/index.js +216 -0
  50. package/src/mcp/tools.js +773 -0
  51. package/src/server.js +73 -0
  52. package/src/workspace.js +44 -0
@@ -0,0 +1,152 @@
1
+ import { OaloChannel } from "../channel.js";
2
+ import { appIdFor } from "../app-ref.js";
3
+ import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
4
+ import { isInternal, NOISY, format, clock, dim } from "../events.js";
5
+
6
+ /**
7
+ * `oalo run` — make something happen, and see what it did.
8
+ *
9
+ * `tail` could already watch, but nothing could ACT, so testing a loop or a bot
10
+ * still meant opening the dashboard. Both underlying events already existed:
11
+ *
12
+ * loops:run_now {"name": ...} -> {"run_id": ...}
13
+ * chat:bots:test_message {"bot_id", "text"} -> replies stream back
14
+ *
15
+ * NOTE `run loop` fires the loop DIRECTLY. It does not exercise the loop's
16
+ * trigger binding, because triggers come from the event bus (a real form
17
+ * submission, a real inbound message) which nothing exposes for simulation yet.
18
+ * So this proves the loop's BODY works, not that its trigger is wired.
19
+ */
20
+ async function connect(flags) {
21
+ const cfg = resolveConfig(flags);
22
+ const { url, token } = controlPlane(cfg);
23
+
24
+ if (!url || !token) {
25
+ console.error(
26
+ `oalo run needs a CLI token.\n\n${HOW_TO_CONNECT}`,
27
+ );
28
+ return null;
29
+ }
30
+
31
+ return { ch: new OaloChannel({ url, token }), url, env: cfg.env };
32
+ }
33
+
34
+ /** Stream events for `seconds`, or until `done` says we have seen enough. */
35
+ function watch(ch, { seconds, done }) {
36
+ return new Promise((finish) => {
37
+ const timer = setTimeout(finish, seconds * 1000);
38
+
39
+ ch.onEvent((event, payload) => {
40
+ if (isInternal(event) || NOISY.test(event)) return;
41
+ const at = payload && typeof payload === "object" ? payload.at : null;
42
+ console.log(`${dim(clock(at))} ${event.padEnd(22)} ${format(event, payload)}`);
43
+ if (done && done(event, payload)) {
44
+ clearTimeout(timer);
45
+ finish();
46
+ }
47
+ });
48
+ });
49
+ }
50
+
51
+ export async function run(flags, rest) {
52
+ const what = rest[1];
53
+
54
+ if (!what || !["loop", "bot"].includes(what)) {
55
+ console.error("usage:\n oalo run loop <name> [--env dev|prod] [--watch]\n oalo run bot <id> \"<message>\" [--watch]");
56
+ process.exitCode = 2;
57
+ return;
58
+ }
59
+
60
+ const conn = await connect(flags);
61
+ if (!conn) {
62
+ process.exitCode = 2;
63
+ return;
64
+ }
65
+ const { ch, env } = conn;
66
+
67
+ const seconds = Number(flags.timeout) > 0 ? Number(flags.timeout) : 30;
68
+ const shouldWatch = flags.watch !== false && (flags.watch || what === "bot");
69
+
70
+ try {
71
+ await ch.connect();
72
+
73
+ if (what === "loop") {
74
+ const name = rest[2];
75
+ if (!name) {
76
+ console.error("which loop? oalo run loop <name>");
77
+ process.exitCode = 2;
78
+ return;
79
+ }
80
+
81
+ const params = { name, env: flags.env || env };
82
+ // A loop pinned to one app: by slug (`oalo apps`) or id.
83
+ if (typeof flags.app === "string") params.app_id = await appIdFor(ch, flags.app);
84
+
85
+ const reply = await ch.call("loops:run_now", params);
86
+ const runId = reply?.run_id ?? reply?.["run_id"];
87
+ console.log(`started ${name}${runId ? ` (run ${runId})` : ""}`);
88
+ console.error(dim("note: fires the loop directly; does not exercise its trigger"));
89
+
90
+ // Loop run events go to a PER-RUN topic ("loop_run:<id>") that the client
91
+ // must opt into. Without this the watch sits silent for its whole window
92
+ // and looks like the loop did nothing. That is the worst failure for a
93
+ // debugging tool, since it looks exactly like a real stall.
94
+ if (shouldWatch && runId != null) {
95
+ try {
96
+ await ch.call("loops:subscribe_run", { run_id: runId });
97
+ } catch (e) {
98
+ console.error(dim(`could not subscribe to run ${runId}: ${e.message}`));
99
+ }
100
+ }
101
+
102
+ if (shouldWatch) {
103
+ console.error(dim(`watching for ${seconds}s; ctrl-c stops`));
104
+ // Loop statuses are: running, waiting, success, error, cancelled.
105
+ // `waiting` is NOT a stall: the loop parked on a durable wait that may
106
+ // resume hours later, so say so and stop rather than burning the window.
107
+ let final = null;
108
+ await watch(ch, {
109
+ seconds,
110
+ done: (event, p) => {
111
+ if (event !== "loop_run_status") return false;
112
+ const status = String(p?.status || "");
113
+ final = status;
114
+
115
+ if (status === "waiting") {
116
+ console.error(dim("run is parked on a durable wait; it will resume on its own"));
117
+ return true;
118
+ }
119
+
120
+ return ["success", "error", "cancelled"].includes(status);
121
+ },
122
+ });
123
+ // A run that failed is a failed command, for scripts and agents (audit C2).
124
+ if (final === "error" || final === "cancelled") process.exitCode = 1;
125
+ }
126
+ return;
127
+ }
128
+
129
+ // bot
130
+ const botId = rest[2];
131
+ const text = rest[3];
132
+ if (!botId || !text) {
133
+ console.error('usage: oalo run bot <id> "<message>"');
134
+ process.exitCode = 2;
135
+ return;
136
+ }
137
+
138
+ // bot_id accepts an integer, a numeric string, or a public bot_ id — the
139
+ // server normalises all three at the channel boundary.
140
+ await ch.call("chat:bots:test_message", { bot_id: botId, text });
141
+ console.error(dim(`sent to ${botId}; watching for ${seconds}s; ctrl-c stops`));
142
+ await watch(ch, {
143
+ seconds,
144
+ done: (event) => event === "response:done",
145
+ });
146
+ } catch (e) {
147
+ console.error(`run failed: ${e.message}`);
148
+ process.exitCode = 1;
149
+ } finally {
150
+ ch.close();
151
+ }
152
+ }
@@ -0,0 +1,68 @@
1
+ import { OaloChannel } from "../channel.js";
2
+ import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
3
+ import { requireFeature } from "../server.js";
4
+ import { dim } from "../events.js";
5
+
6
+ /**
7
+ * `oalo secrets`: keys and tokens OALO found committed to an app's code (on
8
+ * import, and in each push or deploy's new commits). Only where each one is
9
+ * and what it looks like: no value is ever kept.
10
+ *
11
+ * oalo secrets [--app <slug>] [--all] [--json]
12
+ * oalo secrets dismiss <fingerprint> [--app <slug>]
13
+ *
14
+ * For each finding: rotate the key with its provider (it stays in the repo's
15
+ * history), set the new one with `oalo env set NAME --secret`, and read it
16
+ * from the environment in the code. Dismiss one that isn't a secret.
17
+ */
18
+ export async function secrets(flags, rest, opts = {}) {
19
+ const cfg = resolveConfig(flags);
20
+ const { url, token } = controlPlane(cfg);
21
+ if (!url || !token) return fail(`oalo secrets needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
22
+
23
+ const app = typeof flags.app === "string" ? flags.app : cfg.appId;
24
+ if (!app) return fail("which app? Pass --app <slug> (`oalo apps` lists them), or save it with `oalo link --app <slug>`.", 2);
25
+ const [action, fingerprint] = rest;
26
+ if (action && action !== "dismiss") return fail(`unknown: oalo secrets ${action}. Try \`oalo secrets\` or \`oalo secrets dismiss <fingerprint>\`.`, 2);
27
+ if (action === "dismiss" && !fingerprint) return fail("oalo secrets dismiss needs the finding's fingerprint (from `oalo secrets`).", 2);
28
+
29
+ if (!opts.channel) {
30
+ try {
31
+ await requireFeature(url, "secrets", "oalo secrets");
32
+ } catch (e) {
33
+ return fail(e.message, 1);
34
+ }
35
+ }
36
+
37
+ const ch = (opts.channel || ((o) => new OaloChannel(o)))({ url, token });
38
+ try {
39
+ await ch.connect();
40
+ if (action === "dismiss") {
41
+ const r = await ch.call("secrets:dismiss", { app, fingerprint });
42
+ console.log(`dismissed ${r.dismissed} for ${r.app}`);
43
+ return;
44
+ }
45
+
46
+ const r = await ch.call("secrets:list", { app, all: flags.all === true });
47
+ if (flags.json) return console.log(JSON.stringify(r.findings, null, 2));
48
+ if (!r.findings.length) return console.log(`no ${flags.all ? "" : "open "}findings for ${r.app}`);
49
+ for (const f of r.findings) {
50
+ const at = f.commit ? ` commit ${f.commit.slice(0, 8)}` : "";
51
+ const gone = f.dismissed_at ? dim(" (dismissed)") : "";
52
+ console.log(`${f.file}:${f.line} ${f.rule.replace(/-/g, " ")}${at}${gone}`);
53
+ console.log(dim(` ${f.fingerprint}`));
54
+ }
55
+ console.error(
56
+ dim(`\nFor each: rotate it with its provider (it stays in the repo's history), set the new one with \`oalo env set NAME --secret --app ${r.app}\`, and read it from the environment. \`oalo secrets dismiss <fingerprint>\` for one that isn't a secret.`),
57
+ );
58
+ } catch (e) {
59
+ fail(e.message, 1);
60
+ } finally {
61
+ ch.close();
62
+ }
63
+ }
64
+
65
+ function fail(message, code) {
66
+ console.error(`oalo secrets: ${message}`);
67
+ process.exitCode = code;
68
+ }
@@ -0,0 +1,96 @@
1
+ import { OaloChannel } from "../channel.js";
2
+ import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
3
+ import { dim } from "../events.js";
4
+
5
+ /**
6
+ * `oalo setup`: the setup agent on your Base makes this app run (a repo Oalo
7
+ * can't run on its own), on a branch, oalo/setup, that you then accept or
8
+ * reject. Oalo checks each attempt itself: the app is detected and its
9
+ * preview answers.
10
+ *
11
+ * oalo setup [--app <slug>] start one
12
+ * oalo setup status [--app <slug>] how it's going, and its report
13
+ * oalo setup accept [--app <slug>] make its commits the app's code
14
+ * oalo setup reject [--app <slug>] throw them away
15
+ * oalo setup auto on|off start one by itself when a dev deploy finds nothing runs
16
+ */
17
+ export async function setup(flags, rest, opts = {}) {
18
+ const sub = rest[1] || "start";
19
+ if (!["start", "status", "accept", "reject", "auto"].includes(sub)) return fail(`unknown setup command: ${sub}\n\n${USAGE}`, 2);
20
+ if (sub === "auto" && !["on", "off"].includes(rest[2])) return fail("usage: oalo setup auto on|off [--app <slug>]", 2);
21
+
22
+ const cfg = resolveConfig(flags);
23
+ const { url, token } = controlPlane(cfg);
24
+ if (!url || !token) return fail(`oalo setup needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
25
+ const app = typeof flags.app === "string" ? flags.app : cfg.appId;
26
+ if (!app) return fail("which app? Pass --app <slug> (`oalo apps` lists them), or save it with `oalo link --app <slug>`.", 2);
27
+
28
+ const ch = (opts.channel || ((o) => new OaloChannel(o)))({ url, token });
29
+ let r;
30
+ try {
31
+ await ch.connect();
32
+ r = await ch.call(`setup:${sub}`, sub === "auto" ? { app, on: rest[2] === "on" } : { app }, 60000);
33
+ } catch (e) {
34
+ return fail(e.message, 1);
35
+ } finally {
36
+ ch.close();
37
+ }
38
+ if (flags.json) return void console.log(JSON.stringify(r, null, 2));
39
+ print(sub, r, rest[2]);
40
+ }
41
+
42
+ const USAGE = [
43
+ "usage: oalo setup [--app <slug>]",
44
+ " oalo setup status | accept | reject [--app <slug>]",
45
+ " oalo setup auto on|off [--app <slug>]",
46
+ ].join("\n");
47
+
48
+ function print(sub, r, onOff) {
49
+ const app = r.app;
50
+ if (sub === "auto") {
51
+ console.log(
52
+ onOff === "on"
53
+ ? `${app}: a dev deploy that finds nothing runs starts the setup agent by itself.`
54
+ : `${app}: the setup agent starts only when you ask (oalo setup).`,
55
+ );
56
+ return;
57
+ }
58
+ if (sub === "accept") {
59
+ console.log(`${app}: the setup's commits are its code now (${String(r.commit || "").slice(0, 8)}), and its preview restarts.`);
60
+ console.log(dim(`In your own copy: oalo deploy --app ${app} --pull, then git merge oalo/${app}.`));
61
+ return;
62
+ }
63
+ if (sub === "reject") return void console.log(`${app}: the setup's commits are gone; the app is as it was.`);
64
+ if (sub === "start") {
65
+ console.log(
66
+ `${app}: the setup agent is on it (up to ${r.max_attempts} attempts, $${r.budget_usd} at most). Oalo checks each attempt: the app detected and its preview answering.`,
67
+ );
68
+ console.log(dim(`oalo setup status --app ${app} shows how it's going; deploys wait until it's done and reviewed.`));
69
+ return;
70
+ }
71
+
72
+ // status
73
+ if (r.status === "none") return void console.log(`${app}: no setup yet. oalo setup --app ${app} starts one.`);
74
+ const what =
75
+ {
76
+ queued: "waiting to start",
77
+ running: `working: attempt ${r.attempts + 1} of ${r.max_attempts}`,
78
+ ready: "done: it runs. Review it, then accept or reject",
79
+ failed: `stopped: ${r.error || "it didn't run"}`,
80
+ accepted: "accepted",
81
+ rejected: "rejected",
82
+ }[r.status] || r.status;
83
+ console.log(`${app}: ${what}`);
84
+ console.log(dim(` ${r.attempts} attempt${r.attempts === 1 ? "" : "s"}, $${r.cost_usd} of $${r.budget_usd}${r.auto ? ", starts by itself on a failing deploy" : ""}`));
85
+ for (const c of r.checks || []) {
86
+ console.log(dim(` attempt ${c.attempt}: ${c.ok ? `runs (${c.detected})` : `didn't run: ${String(c.why || "").split("\n")[0]}`}`));
87
+ }
88
+ if (r.report) console.log(`\n${r.report.trim()}`);
89
+ if (r.status === "ready") console.log(dim(`\nThe preview shows it now. oalo setup accept --app ${app} keeps it; oalo setup reject --app ${app} throws it away.`));
90
+ if (r.status === "failed") process.exitCode = 1;
91
+ }
92
+
93
+ function fail(message, code) {
94
+ console.error(message);
95
+ process.exitCode = code;
96
+ }
@@ -0,0 +1,142 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { createHash } from "node:crypto";
5
+ import { fileURLToPath } from "node:url";
6
+ import { CLI_VERSION } from "../server.js";
7
+ import { dim } from "../events.js";
8
+
9
+ /**
10
+ * `oalo skill`: teach the coding agent in this project how to work on OALO.
11
+ *
12
+ * oalo skill install .claude/skills/oalo/SKILL.md (Claude Code loads it)
13
+ * oalo skill --agents also add the same rules to AGENTS.md, for other agents
14
+ * oalo skill --print print the skill to stdout
15
+ *
16
+ * The skill ships inside this package (plugin/skills/oalo/SKILL.md), so it matches the
17
+ * CLI version that will run its commands. Re-running replaces the installed
18
+ * copy and the AGENTS.md section (between oalo markers), nothing else.
19
+ *
20
+ * `oalo link` installs it too, or refreshes it. The installed copy is stamped
21
+ * with the CLI version that wrote it, so a newer CLI can say, once, that it's
22
+ * behind (`skillNotice`).
23
+ */
24
+
25
+ // One copy, shared with the Claude Code plugin (plugin/), so the two cannot drift.
26
+ const SKILL = join(dirname(fileURLToPath(import.meta.url)), "../../plugin/skills/oalo/SKILL.md");
27
+ const START = "<!-- oalo:start (written by `oalo skill --agents`; re-run to update) -->";
28
+ const END = "<!-- oalo:end -->";
29
+ const STAMP = /<!-- oalo-cli ([0-9][0-9A-Za-z.+-]*) /;
30
+ // The skill changes between CLI versions too (the version stayed 0.1.0 through
31
+ // a week of skill changes), so the stamp also carries a hash of its content.
32
+ const HASH = /\(skill ([0-9a-f]+)\)/;
33
+
34
+ /** A short hash of the packaged skill: what an installed copy is compared by. */
35
+ export function skillHash() {
36
+ return createHash("sha256").update(readFileSync(SKILL, "utf8")).digest("hex").slice(0, 12);
37
+ }
38
+
39
+ const body = (md) => md.replace(/^---\n[\s\S]*?\n---\n+/, "");
40
+ const skillPath = (cwd) => join(cwd, ".claude", "skills", "oalo", "SKILL.md");
41
+
42
+ // The packaged skill with a stamp line after its front matter (the front matter
43
+ // must stay first for Claude Code to read it).
44
+ function stamped() {
45
+ const md = readFileSync(SKILL, "utf8");
46
+ const stamp = `<!-- oalo-cli ${CLI_VERSION} (skill ${skillHash()}) wrote this copy; \`oalo skill\` updates it. -->\n\n`;
47
+ const fm = /^---\n[\s\S]*?\n---\n+/.exec(md);
48
+ return fm ? md.slice(0, fm[0].length) + stamp + md.slice(fm[0].length) : stamp + md;
49
+ }
50
+
51
+ /**
52
+ * Write the skill into `cwd` (and the AGENTS.md section when `agents`, or when
53
+ * AGENTS.md already has one). `{path, agents}`: what was written.
54
+ */
55
+ export function installSkill(cwd = process.cwd(), { agents = false } = {}) {
56
+ const md = stamped();
57
+ const target = skillPath(cwd);
58
+ mkdirSync(dirname(target), { recursive: true });
59
+ writeFileSync(target, md);
60
+
61
+ const agentsPath = join(cwd, "AGENTS.md");
62
+ const current = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : "";
63
+ const at = current.indexOf(START);
64
+ const endAt = current.indexOf(END);
65
+ let agentsResult = null;
66
+
67
+ if (agents || at >= 0) {
68
+ const section = `${START}\n${body(md).trim()}\n${END}`;
69
+ const next =
70
+ at >= 0 && endAt > at
71
+ ? current.slice(0, at) + section + current.slice(endAt + END.length)
72
+ : `${current}${current && !current.endsWith("\n") ? "\n" : ""}${current ? "\n" : ""}${section}\n`;
73
+ writeFileSync(agentsPath, next);
74
+ agentsResult = at >= 0 ? "updated" : "added";
75
+ }
76
+
77
+ return { path: target.slice(cwd.length + 1), agents: agentsResult };
78
+ }
79
+
80
+ /**
81
+ * The CLI version that wrote the project's installed skill: a version string,
82
+ * "old" for a copy from before copies were stamped, or null when there's none.
83
+ */
84
+ export function installedSkillVersion(cwd = process.cwd()) {
85
+ const p = skillPath(cwd);
86
+ if (!existsSync(p)) return null;
87
+ const m = STAMP.exec(readFileSync(p, "utf8"));
88
+ return m ? m[1] : "old";
89
+ }
90
+
91
+ /** The content hash an installed copy was stamped with, or null. */
92
+ export function installedSkillHash(cwd = process.cwd()) {
93
+ const p = skillPath(cwd);
94
+ if (!existsSync(p)) return null;
95
+ const m = HASH.exec(readFileSync(p, "utf8"));
96
+ return m ? m[1] : null;
97
+ }
98
+
99
+ /**
100
+ * One line, at most once per project and CLI version, when the project's
101
+ * installed skill is from another CLI version or has other content. null otherwise. The notices
102
+ * already given are kept beside the credentials (OALO_HOME).
103
+ */
104
+ export function skillNotice(cwd = process.cwd()) {
105
+ const installed = installedSkillVersion(cwd);
106
+ if (!installed) return null;
107
+ const hash = skillHash();
108
+ const sameVersion = installed === CLI_VERSION;
109
+ if (sameVersion && installedSkillHash(cwd) === hash) return null;
110
+
111
+ const file = join(process.env.OALO_HOME || homedir(), ".oalo", "skill-notices.json");
112
+ let seen = {};
113
+ try {
114
+ seen = JSON.parse(readFileSync(file, "utf8"));
115
+ } catch {
116
+ /* none yet */
117
+ }
118
+ const key = `${cwd}@${CLI_VERSION}@${hash}`;
119
+ if (seen[key]) return null;
120
+
121
+ try {
122
+ mkdirSync(dirname(file), { recursive: true });
123
+ writeFileSync(file, JSON.stringify({ ...seen, [key]: true }, null, 2));
124
+ } catch {
125
+ /* a read-only home: say it every time rather than never */
126
+ }
127
+ if (sameVersion) return "This project's OALO skill (.claude/skills/oalo/SKILL.md) is older than the one this CLI carries. Run `oalo skill` to update it.";
128
+ const from = installed === "old" ? "an older CLI" : `CLI ${installed}`;
129
+ return `This project's OALO skill (.claude/skills/oalo/SKILL.md) is from ${from}; you have ${CLI_VERSION}. Run \`oalo skill\` to update it.`;
130
+ }
131
+
132
+ export async function skill(flags) {
133
+ if (flags.print) {
134
+ process.stdout.write(stamped());
135
+ return;
136
+ }
137
+
138
+ const r = installSkill(process.cwd(), { agents: !!flags.agents });
139
+ console.log(`installed ${r.path}`);
140
+ if (r.agents) console.log(`${r.agents} the OALO section in AGENTS.md`);
141
+ console.log(dim(`From CLI ${CLI_VERSION}. Run \`oalo skill\` again after updating the CLI.`));
142
+ }