jeo-code 0.5.2 → 0.5.3
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.
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/commands/launch.ts +151 -24
- package/src/skills/catalog.ts +32 -0
package/README.ja.md
CHANGED
|
@@ -150,11 +150,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
150
150
|
## 変更履歴 (Changelog)
|
|
151
151
|
|
|
152
152
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
153
|
+
- **[0.5.3]** (2026-06-15) — `$` chains multiple skills in one line (all run, in order), plus multi-line prompt input — paste-merge and gated Shift+Enter.
|
|
153
154
|
- **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
|
|
154
155
|
- **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
|
|
155
156
|
- **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
|
|
156
157
|
- **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
|
|
157
|
-
- **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
|
|
158
158
|
|
|
159
159
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
160
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -150,11 +150,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
150
150
|
## 변경 이력 (Changelog)
|
|
151
151
|
|
|
152
152
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
153
|
+
- **[0.5.3]** (2026-06-15) — `$` chains multiple skills in one line (all run, in order), plus multi-line prompt input — paste-merge and gated Shift+Enter.
|
|
153
154
|
- **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
|
|
154
155
|
- **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
|
|
155
156
|
- **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
|
|
156
157
|
- **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
|
|
157
|
-
- **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
|
|
158
158
|
|
|
159
159
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
160
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -150,11 +150,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
|
|
|
150
150
|
## Changelog
|
|
151
151
|
|
|
152
152
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
153
|
+
- **[0.5.3]** (2026-06-15) — `$` chains multiple skills in one line (all run, in order), plus multi-line prompt input — paste-merge and gated Shift+Enter.
|
|
153
154
|
- **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
|
|
154
155
|
- **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
|
|
155
156
|
- **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
|
|
156
157
|
- **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
|
|
157
|
-
- **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
|
|
158
158
|
|
|
159
159
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
160
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -150,11 +150,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
150
150
|
## 更新日志 (Changelog)
|
|
151
151
|
|
|
152
152
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
153
|
+
- **[0.5.3]** (2026-06-15) — `$` chains multiple skills in one line (all run, in order), plus multi-line prompt input — paste-merge and gated Shift+Enter.
|
|
153
154
|
- **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
|
|
154
155
|
- **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
|
|
155
156
|
- **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
|
|
156
157
|
- **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
|
|
157
|
-
- **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
|
|
158
158
|
|
|
159
159
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
160
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/commands/launch.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { emitKeypressEvents } from "node:readline";
|
|
3
|
+
import { PassThrough } from "node:stream";
|
|
2
4
|
import { runAgentLoop, executorSystemPrompt, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, type AgentLoopEvents } from "../agent/engine";
|
|
3
5
|
import { initialDynamicStepLimit } from "../agent/step-budget";
|
|
4
6
|
import { memoryPromptSection, spawnDetachedDistill } from "../agent/memory";
|
|
@@ -11,7 +13,7 @@ import { runDeepInterviewEngine } from "./deep-interview";
|
|
|
11
13
|
import { runRalplanEngine } from "./ralplan";
|
|
12
14
|
import { runTeamEngine } from "./team";
|
|
13
15
|
import { runUltragoalEngine } from "./ultragoal";
|
|
14
|
-
import { skillsPromptSection, loadSkills, formatSkill, buildSkillTask, getSkillFrom, skillSlashAliases, workflowSkillsForPrompt, parseSkillInvocation, looksLikeSkillEcho, skillInvocationCard, type SkillDoc } from "../skills/catalog";
|
|
16
|
+
import { skillsPromptSection, loadSkills, formatSkill, buildSkillTask, getSkillFrom, skillSlashAliases, workflowSkillsForPrompt, parseSkillInvocation, parseSkillChain, looksLikeSkillEcho, skillInvocationCard, type SkillDoc, type SkillInvocation } from "../skills/catalog";
|
|
15
17
|
import { formatForgeBox } from "../tui/components/forge";
|
|
16
18
|
import { interactiveOAuthLogin } from "./auth";
|
|
17
19
|
import { logoutOAuth } from "../auth";
|
|
@@ -1594,13 +1596,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1594
1596
|
return;
|
|
1595
1597
|
}
|
|
1596
1598
|
}
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1599
|
+
// One skill run (bundle workflow → engine; regular skill → agent turn). Shared by the
|
|
1600
|
+
// single-invocation path and the `$a $b …` chain path so every `$` skill actually runs.
|
|
1601
|
+
const runOneSkillShot = async (inv: SkillInvocation): Promise<void> => {
|
|
1602
|
+
const isBundleWorkflow = ["deep-interview", "ralplan", "team", "ultragoal"].includes(inv.skill.name);
|
|
1600
1603
|
if (isBundleWorkflow) {
|
|
1601
1604
|
const startMsg: Message = {
|
|
1602
1605
|
role: "system",
|
|
1603
|
-
content: `[workflow:${
|
|
1606
|
+
content: `[workflow:${inv.skill.name}:start]${inv.intent ? ` intent: ${inv.intent}` : ""}`
|
|
1604
1607
|
};
|
|
1605
1608
|
history.push(startMsg);
|
|
1606
1609
|
if (sessionId) {
|
|
@@ -1623,18 +1626,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1623
1626
|
console.log(line);
|
|
1624
1627
|
}
|
|
1625
1628
|
},
|
|
1626
|
-
args:
|
|
1629
|
+
args: inv.skill.name === "deep-interview" ? (inv.intent ? inv.intent.split(/\s+/) : []) : undefined
|
|
1627
1630
|
};
|
|
1628
1631
|
|
|
1629
1632
|
let ok = false;
|
|
1630
1633
|
let reason: string | undefined;
|
|
1631
1634
|
try {
|
|
1632
1635
|
let res: { ok: boolean; reason?: string };
|
|
1633
|
-
if (
|
|
1636
|
+
if (inv.skill.name === "deep-interview") {
|
|
1634
1637
|
res = await runDeepInterviewEngine(opts);
|
|
1635
|
-
} else if (
|
|
1638
|
+
} else if (inv.skill.name === "ralplan") {
|
|
1636
1639
|
res = await runRalplanEngine(opts);
|
|
1637
|
-
} else if (
|
|
1640
|
+
} else if (inv.skill.name === "team") {
|
|
1638
1641
|
res = await runTeamEngine(opts);
|
|
1639
1642
|
} else {
|
|
1640
1643
|
res = await runUltragoalEngine(opts);
|
|
@@ -1650,9 +1653,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1650
1653
|
|
|
1651
1654
|
const endMsg: Message = {
|
|
1652
1655
|
role: "system",
|
|
1653
|
-
content: ok
|
|
1654
|
-
? `[workflow:${
|
|
1655
|
-
: `[workflow:${
|
|
1656
|
+
content: ok
|
|
1657
|
+
? `[workflow:${inv.skill.name}:finish]`
|
|
1658
|
+
: `[workflow:${inv.skill.name}:abort]${reason ? ` reason: ${reason}` : ""}`
|
|
1656
1659
|
};
|
|
1657
1660
|
if (sessionId) {
|
|
1658
1661
|
await appendMessage(sessionId, endMsg, cwd);
|
|
@@ -1662,12 +1665,32 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1662
1665
|
|
|
1663
1666
|
const useOneShotTui = shouldUseOneShotTui(flags.noTui);
|
|
1664
1667
|
if (!useOneShotTui) {
|
|
1665
|
-
console.log(`▶ Running skill: ${
|
|
1668
|
+
console.log(`▶ Running skill: ${inv.skill.name}${inv.intent ? ` — ${inv.intent}` : ""}`);
|
|
1666
1669
|
}
|
|
1667
|
-
const task = buildSkillTask(
|
|
1670
|
+
const task = buildSkillTask(inv.skill, inv.intent, inv.invokedAs);
|
|
1668
1671
|
const { reply, rendered, usage } = await runTurn(task, useOneShotTui);
|
|
1669
1672
|
if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
|
|
1670
1673
|
else if (usage) console.log(usage.trim());
|
|
1674
|
+
};
|
|
1675
|
+
|
|
1676
|
+
// `$a $b … [intent]` — run every resolved skill in order; a lone `$skill` is a chain of 1.
|
|
1677
|
+
const skillChain = parseSkillChain(messageContent, resolvedSkills);
|
|
1678
|
+
if (skillChain && skillChain.invocations.length) {
|
|
1679
|
+
if (skillChain.unresolved.length) {
|
|
1680
|
+
console.log(`(skipping unknown skill${skillChain.unresolved.length > 1 ? "s" : ""}: ${skillChain.unresolved.map(u => `$${u}`).join(", ")})`);
|
|
1681
|
+
}
|
|
1682
|
+
if (skillChain.invocations.length > 1) {
|
|
1683
|
+
console.log(`▶ Chaining ${skillChain.invocations.length} skills: ${skillChain.invocations.map(i => `$${i.skill.name}`).join(" → ")}`);
|
|
1684
|
+
}
|
|
1685
|
+
for (const inv of skillChain.invocations) {
|
|
1686
|
+
await runOneSkillShot(inv);
|
|
1687
|
+
}
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
// Slash / file-path / `/skill:` entrypoints (non-`$`) still resolve to a single skill.
|
|
1691
|
+
const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
|
|
1692
|
+
if (skillInvocation) {
|
|
1693
|
+
await runOneSkillShot(skillInvocation);
|
|
1671
1694
|
return;
|
|
1672
1695
|
}
|
|
1673
1696
|
try {
|
|
@@ -1891,8 +1914,50 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1891
1914
|
// scrollback before the boxed footer takes over.
|
|
1892
1915
|
let promptActive = false;
|
|
1893
1916
|
let pickerActive = false;
|
|
1917
|
+
// ── Multi-line input (JEO_MULTILINE=1, gated) ──────────────────────────────────
|
|
1918
|
+
// Shift+Enter inserts a newline instead of submitting. ghostty (and most terminals)
|
|
1919
|
+
// send Shift+Enter as a distinct escape sequence (\x1b[27;2;13~ legacy, \x1b[13;2u
|
|
1920
|
+
// kitty) that node:readline otherwise mangles into garbage. We route stdin through a
|
|
1921
|
+
// filter that rewrites those sequences to a private-use SENTINEL char BEFORE readline
|
|
1922
|
+
// sees them — readline inserts it as an ordinary character (no submit), and we render
|
|
1923
|
+
// it as a real line break and expand it to "\n" on submit. Default OFF: when disabled
|
|
1924
|
+
// the prompt reads process.stdin directly, exactly as before (zero risk).
|
|
1925
|
+
const SENTINEL = "\uE000";
|
|
1926
|
+
const SHIFT_ENTER_SEQS = ["\u001b[27;2;13~", "\u001b[13;2u"];
|
|
1927
|
+
const multilineInput = jeoEnv("MULTILINE") === "1" && !!process.stdin.isTTY;
|
|
1928
|
+
const expandSentinel = (s: string): string => (multilineInput ? s.split(SENTINEL).join("\n") : s);
|
|
1929
|
+
let keyFilter: PassThrough | undefined;
|
|
1930
|
+
if (multilineInput) {
|
|
1931
|
+
const kf = new PassThrough();
|
|
1932
|
+
(kf as unknown as { isTTY: boolean }).isTTY = true;
|
|
1933
|
+
(kf as unknown as { setRawMode: (m: boolean) => unknown }).setRawMode = (m: boolean) => {
|
|
1934
|
+
try { (process.stdin as { setRawMode?(r: boolean): void }).setRawMode?.(m); } catch { /* terminal gone */ }
|
|
1935
|
+
return kf;
|
|
1936
|
+
};
|
|
1937
|
+
Object.defineProperty(kf, "isRaw", { get: () => (process.stdin as { isRaw?: boolean }).isRaw });
|
|
1938
|
+
// Forward stdin → filter, rewriting Shift+Enter into the newline sentinel BEFORE
|
|
1939
|
+
// readline sees it. Three encodings are handled so it works across setups:
|
|
1940
|
+
// • a lone "\n" (0x0a) chunk — what ghostty's `keybind = shift+enter=text:\n`
|
|
1941
|
+
// sends; a normal byte that passes through tmux UNCHANGED (works even with
|
|
1942
|
+
// tmux extended-keys off). Enter sends "\r" in raw mode, so a lone "\n" is
|
|
1943
|
+
// unambiguously Shift+Enter. This is the reliable ghostty+tmux path.
|
|
1944
|
+
// • the xterm legacy "\x1b[27;2;13~" and kitty "\x1b[13;2u" sequences — direct
|
|
1945
|
+
// terminal (no tmux) or tmux with extended-keys on. (Sent atomically, so a
|
|
1946
|
+
// per-chunk replace suffices; no partial buffering that could swallow ESC.)
|
|
1947
|
+
process.stdin.on("data", (chunk: Buffer) => {
|
|
1948
|
+
let data = chunk.toString("utf8");
|
|
1949
|
+
if (data === "\n") data = SENTINEL;
|
|
1950
|
+
else for (const seq of SHIFT_ENTER_SEQS) if (data.includes(seq)) data = data.split(seq).join(SENTINEL);
|
|
1951
|
+
kf.write(data);
|
|
1952
|
+
});
|
|
1953
|
+
keyFilter = kf;
|
|
1954
|
+
// readline now decodes keypresses on `keyFilter`; keep process.stdin emitting
|
|
1955
|
+
// 'keypress' too so the footer-redraw / paste-marker / picker listeners (registered
|
|
1956
|
+
// on process.stdin below) still fire.
|
|
1957
|
+
emitKeypressEvents(process.stdin);
|
|
1958
|
+
}
|
|
1894
1959
|
const rl = createInterface({
|
|
1895
|
-
input: process.stdin,
|
|
1960
|
+
input: keyFilter ?? process.stdin,
|
|
1896
1961
|
// Single-box input: gate readline's output while the boxed footer is armed so its own
|
|
1897
1962
|
// `jeo>` prompt/echo is suppressed and ONLY our box shows. (Bun exposes no
|
|
1898
1963
|
// `_writeToOutput` to patch, so gating the shared output stream is the portable fix.)
|
|
@@ -1937,10 +2002,19 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1937
2002
|
// outside it keep the existing contracts (typed-line prefill fold on a TTY,
|
|
1938
2003
|
// in-order auto-serve for piped stdin).
|
|
1939
2004
|
let promptPasteActive = false;
|
|
2005
|
+
// PASTE-MERGE: a multi-line bracketed paste must arrive as ONE message, not split into
|
|
2006
|
+
// one command per line. Lines that fire WHILE a paste is open are buffered here instead
|
|
2007
|
+
// of run individually; promptInput joins them with the trailing residual on resolve.
|
|
2008
|
+
// `endWaiters` wake that merge the moment the 201~ paste-end marker arrives.
|
|
2009
|
+
const pasteMerge: { buf: string[]; endWaiters: Array<() => void> } = { buf: [], endWaiters: [] };
|
|
2010
|
+
let pasteLineFired = false; // the line that resolved rl.question came from inside a paste
|
|
1940
2011
|
if (process.stdin.isTTY) {
|
|
1941
2012
|
process.stdin.on("keypress", (_ch: string, key: { name?: string } | undefined) => {
|
|
1942
|
-
if (key?.name === "paste-start") promptPasteActive = true;
|
|
1943
|
-
else if (key?.name === "paste-end")
|
|
2013
|
+
if (key?.name === "paste-start") { promptPasteActive = true; pasteMerge.buf = []; }
|
|
2014
|
+
else if (key?.name === "paste-end") {
|
|
2015
|
+
promptPasteActive = false;
|
|
2016
|
+
for (const w of pasteMerge.endWaiters.splice(0)) w();
|
|
2017
|
+
}
|
|
1944
2018
|
});
|
|
1945
2019
|
// Enable bracketed paste for the REPL lifetime (restored on exit below):
|
|
1946
2020
|
// terminals only wrap pastes in the 200~/201~ markers once the app opts in.
|
|
@@ -1949,7 +2023,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1949
2023
|
}
|
|
1950
2024
|
rl.on("line", l => {
|
|
1951
2025
|
if (promptPasteActive) {
|
|
1952
|
-
|
|
2026
|
+
// Inside a bracketed paste: buffer the line, never run it on its own.
|
|
2027
|
+
pasteMerge.buf.push(l);
|
|
2028
|
+
pasteLineFired = true;
|
|
1953
2029
|
return;
|
|
1954
2030
|
}
|
|
1955
2031
|
if (!interactiveTurnActive) pendingStdinLines.push(l);
|
|
@@ -1989,8 +2065,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1989
2065
|
return "/exit";
|
|
1990
2066
|
}
|
|
1991
2067
|
promptActive = true;
|
|
2068
|
+
pasteLineFired = false;
|
|
1992
2069
|
try {
|
|
1993
|
-
|
|
2070
|
+
const value = await Promise.race([
|
|
1994
2071
|
rl.question(prompt),
|
|
1995
2072
|
new Promise<string>(resolve => {
|
|
1996
2073
|
notifyStdinClosed = () => {
|
|
@@ -1999,6 +2076,29 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1999
2076
|
};
|
|
2000
2077
|
}),
|
|
2001
2078
|
]);
|
|
2079
|
+
// PASTE-MERGE: if the resolving line came from inside a bracketed paste, the rest of
|
|
2080
|
+
// the paste (buffered in pasteMerge.buf) plus the trailing residual still in the line
|
|
2081
|
+
// buffer belong to the SAME message. Wait for paste-end, then join them with newlines
|
|
2082
|
+
// and return ONE multi-line input instead of running line 1 and queuing the rest.
|
|
2083
|
+
if (pasteLineFired) {
|
|
2084
|
+
if (promptPasteActive) {
|
|
2085
|
+
await new Promise<void>(resolve => {
|
|
2086
|
+
if (!promptPasteActive) { resolve(); return; }
|
|
2087
|
+
pasteMerge.endWaiters.push(resolve);
|
|
2088
|
+
setTimeout(resolve, 250); // safety: never hang if the 201~ marker is dropped
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
const residual = (rl as unknown as { line?: string }).line ?? "";
|
|
2092
|
+
// Clear the residual so it does NOT prefill (and re-submit) the next prompt.
|
|
2093
|
+
try { rl.write(null, { ctrl: true, name: "u" }); } catch { /* best-effort */ }
|
|
2094
|
+
try { (rl as unknown as { line: string; cursor: number }).line = ""; (rl as unknown as { cursor: number }).cursor = 0; } catch { /* best-effort */ }
|
|
2095
|
+
const parts = [...pasteMerge.buf];
|
|
2096
|
+
if (residual !== "") parts.push(residual);
|
|
2097
|
+
pasteMerge.buf = [];
|
|
2098
|
+
pasteLineFired = false;
|
|
2099
|
+
return expandSentinel(parts.join("\n"));
|
|
2100
|
+
}
|
|
2101
|
+
return expandSentinel(value);
|
|
2002
2102
|
} finally {
|
|
2003
2103
|
promptActive = false;
|
|
2004
2104
|
notifyStdinClosed = undefined;
|
|
@@ -2204,7 +2304,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2204
2304
|
const rli = rl as unknown as { line?: string; cursor?: number };
|
|
2205
2305
|
const caret = rli.line === line && typeof rli.cursor === "number" ? rli.cursor : line.length;
|
|
2206
2306
|
const { accent: boxAccent, shadow: boxShadow } = boxAccents(line);
|
|
2207
|
-
const frame = renderInputFrame(line, {
|
|
2307
|
+
const frame = renderInputFrame(expandSentinel(line), {
|
|
2208
2308
|
cols,
|
|
2209
2309
|
color: true,
|
|
2210
2310
|
unicode: true,
|
|
@@ -4129,6 +4229,25 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4129
4229
|
}
|
|
4130
4230
|
continue;
|
|
4131
4231
|
}
|
|
4232
|
+
// `$a $b ... [intent]` — run EVERY resolved skill in the leading run, in order, each
|
|
4233
|
+
// with the shared trailing intent. A lone `$skill` is just a chain of length 1.
|
|
4234
|
+
const dollarChain = input.startsWith("$") ? parseSkillChain(input, resolvedSkills) : null;
|
|
4235
|
+
if (dollarChain && dollarChain.invocations.length) {
|
|
4236
|
+
if (dollarChain.unresolved.length) {
|
|
4237
|
+
console.log(`(skipping unknown skill${dollarChain.unresolved.length > 1 ? "s" : ""}: ${dollarChain.unresolved.map(u => `$${u}`).join(", ")})`);
|
|
4238
|
+
}
|
|
4239
|
+
if (dollarChain.invocations.length > 1) {
|
|
4240
|
+
console.log(`▶ Chaining ${dollarChain.invocations.length} skills: ${dollarChain.invocations.map(i => `$${i.skill.name}`).join(" → ")}`);
|
|
4241
|
+
}
|
|
4242
|
+
for (const inv of dollarChain.invocations) {
|
|
4243
|
+
try {
|
|
4244
|
+
await runSkillInvocation(inv.skill, inv.intent, inv.invokedAs);
|
|
4245
|
+
} catch (err) {
|
|
4246
|
+
console.log(`! ${(err as Error).message}`);
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
continue;
|
|
4250
|
+
}
|
|
4132
4251
|
const aliasInvocation = parseSkillInvocation(input, resolvedSkills);
|
|
4133
4252
|
if (aliasInvocation?.invokedAs) {
|
|
4134
4253
|
try {
|
|
@@ -4139,11 +4258,19 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4139
4258
|
continue;
|
|
4140
4259
|
}
|
|
4141
4260
|
// Unresolved `$skill` → suggest precisely, never silently send the typo to the model.
|
|
4142
|
-
// `$exact`/`$prefix
|
|
4143
|
-
// EXCEPT `$UPPERCASE` env-var-style tokens (e.g. `$HOME`)
|
|
4261
|
+
// `$exact`/`$prefix`/chains already ran above; a leftover `$word` is a missed skill
|
|
4262
|
+
// attempt, EXCEPT `$UPPERCASE` env-var-style tokens (e.g. `$HOME`) which pass through.
|
|
4144
4263
|
if (input.startsWith("$")) {
|
|
4145
|
-
const
|
|
4146
|
-
|
|
4264
|
+
const unresolved = (dollarChain?.unresolved.length
|
|
4265
|
+
? dollarChain.unresolved
|
|
4266
|
+
: [(input.split(/\s+/, 1)[0] ?? "").slice(1)]
|
|
4267
|
+
).filter(t => t && !/^[A-Z_][A-Z0-9_]*$/.test(t));
|
|
4268
|
+
if (unresolved.length > 1) {
|
|
4269
|
+
console.log(`No skills: ${unresolved.map(u => `$${u}`).join(", ")}. (Type $ to autocomplete.)`);
|
|
4270
|
+
continue;
|
|
4271
|
+
}
|
|
4272
|
+
if (unresolved.length === 1) {
|
|
4273
|
+
const token = unresolved[0]!;
|
|
4147
4274
|
const lc = token.toLowerCase();
|
|
4148
4275
|
const prefix = resolvedSkills.filter(s => s.name.toLowerCase().startsWith(lc));
|
|
4149
4276
|
if (prefix.length) {
|
package/src/skills/catalog.ts
CHANGED
|
@@ -556,6 +556,38 @@ export function parseSkillInvocation(input: string, skills: SkillDoc[]): SkillIn
|
|
|
556
556
|
}
|
|
557
557
|
return skill ? { skill, intent: trimmed.slice(command.length).trim(), invokedAs: command } : null;
|
|
558
558
|
}
|
|
559
|
+
|
|
560
|
+
/** Parse a LEADING run of `$skill` tokens into an ordered chain that shares the trailing
|
|
561
|
+
* text as one intent: `$ralplan $team build auth` → [ralplan, team] each with intent
|
|
562
|
+
* "build auth". This is what lets `$` invoke several skills in one line — they all run,
|
|
563
|
+
* in order. Scanning stops at the first non-`$` token, OR a `$UPPERCASE` env-var-style
|
|
564
|
+
* token (e.g. `$HOME`), which is left in the intent so shell-style references pass through.
|
|
565
|
+
* Each `$name` resolves by exact name then unique prefix; names that resolve to nothing go
|
|
566
|
+
* into `unresolved` (so the REPL can report every typo, not just the first). Returns null
|
|
567
|
+
* only when the input opens with no parseable `$skill` token at all. */
|
|
568
|
+
export function parseSkillChain(
|
|
569
|
+
input: string,
|
|
570
|
+
skills: SkillDoc[],
|
|
571
|
+
): { invocations: SkillInvocation[]; unresolved: string[] } | null {
|
|
572
|
+
const trimmed = input.trim();
|
|
573
|
+
if (!trimmed.startsWith("$")) return null;
|
|
574
|
+
const tokens = trimmed.split(/\s+/);
|
|
575
|
+
const invocations: SkillInvocation[] = [];
|
|
576
|
+
const unresolved: string[] = [];
|
|
577
|
+
let i = 0;
|
|
578
|
+
for (; i < tokens.length; i++) {
|
|
579
|
+
const tok = tokens[i] ?? "";
|
|
580
|
+
if (!tok.startsWith("$") || tok.length < 2) break;
|
|
581
|
+
const name = tok.slice(1);
|
|
582
|
+
if (/^[A-Z_][A-Z0-9_]*$/.test(name)) break; // env-var-style → boundary; keep in intent
|
|
583
|
+
const skill = getSkillFrom(skills, name) ?? uniquePrefixSkill(skills, name);
|
|
584
|
+
if (skill) invocations.push({ skill, intent: "", invokedAs: tok });
|
|
585
|
+
else unresolved.push(name);
|
|
586
|
+
}
|
|
587
|
+
if (invocations.length === 0 && unresolved.length === 0) return null;
|
|
588
|
+
const intent = tokens.slice(i).join(" ").trim();
|
|
589
|
+
return { invocations: invocations.map(inv => ({ ...inv, intent })), unresolved };
|
|
590
|
+
}
|
|
559
591
|
export function looksLikeSkillEcho(reply: string, skills: SkillDoc[]): boolean {
|
|
560
592
|
if (reply.length < 80) {
|
|
561
593
|
return false;
|