agent-trellis 0.7.0 → 0.8.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.
- package/README.md +97 -38
- package/dist/adapters/claude-code.js +3 -2
- package/dist/adapters/codex.js +3 -2
- package/dist/adapters/jsonMcp.d.ts +3 -1
- package/dist/adapters/jsonMcp.js +18 -4
- package/dist/adapters/kimi-code.d.ts +21 -0
- package/dist/adapters/kimi-code.js +107 -0
- package/dist/adapters/kiro.js +3 -2
- package/dist/adapters/mcpPlan.d.ts +6 -1
- package/dist/adapters/mcpPlan.js +106 -8
- package/dist/adapters/pi.js +22 -3
- package/dist/adapters/symlinkPlan.d.ts +7 -0
- package/dist/adapters/symlinkPlan.js +16 -7
- package/dist/cli.js +121 -21
- package/dist/commands/doctor.d.ts +7 -2
- package/dist/commands/doctor.js +38 -2
- package/dist/commands/init.d.ts +4 -2
- package/dist/commands/init.js +26 -13
- package/dist/commands/kimi.d.ts +16 -0
- package/dist/commands/kimi.js +64 -0
- package/dist/commands/manage.d.ts +39 -0
- package/dist/commands/manage.js +126 -0
- package/dist/commands/mcp.d.ts +22 -1
- package/dist/commands/mcp.js +65 -2
- package/dist/commands/mcpGateway.d.ts +6 -0
- package/dist/commands/mcpGateway.js +34 -14
- package/dist/commands/mcpImport.d.ts +27 -0
- package/dist/commands/mcpImport.js +336 -0
- package/dist/commands/memory.d.ts +5 -3
- package/dist/commands/memory.js +12 -8
- package/dist/commands/migrate.d.ts +16 -5
- package/dist/commands/migrate.js +90 -17
- package/dist/commands/onboard.d.ts +74 -2
- package/dist/commands/onboard.js +458 -71
- package/dist/commands/rollback.js +4 -1
- package/dist/commands/skill.d.ts +17 -0
- package/dist/commands/skill.js +47 -2
- package/dist/commands/sync.js +2 -0
- package/dist/core/canonical.d.ts +15 -7
- package/dist/core/canonical.js +101 -13
- package/dist/core/capabilitySelection.d.ts +45 -0
- package/dist/core/capabilitySelection.js +124 -0
- package/dist/core/types.d.ts +25 -2
- package/dist/core/types.js +7 -0
- package/dist/lib/backup.d.ts +2 -0
- package/dist/lib/backup.js +4 -0
- package/dist/lib/builtinSkills.d.ts +2 -0
- package/dist/lib/builtinSkills.js +4 -0
- package/dist/lib/cliMetadata.d.ts +1 -0
- package/dist/lib/cliMetadata.js +14 -0
- package/dist/lib/gatewayBackend.d.ts +28 -0
- package/dist/lib/gatewayBackend.js +72 -1
- package/dist/lib/installAgent.d.ts +7 -5
- package/dist/lib/installAgent.js +4 -3
- package/dist/lib/mcpConnect.js +5 -1
- package/dist/lib/mcpMigrateRead.d.ts +1 -0
- package/dist/lib/mcpMigrateRead.js +3 -0
- package/dist/lib/mcpRuntime.d.ts +46 -0
- package/dist/lib/mcpRuntime.js +185 -0
- package/dist/lib/mcpStatusProvider.d.ts +10 -0
- package/dist/lib/mcpStatusProvider.js +25 -0
- package/dist/lib/mcpToolRegistry.d.ts +18 -0
- package/dist/lib/mcpToolRegistry.js +107 -14
- package/dist/lib/memoryGraph.d.ts +1 -1
- package/dist/lib/memoryGraph.js +9 -3
- package/dist/lib/memoryProvider.d.ts +58 -0
- package/dist/lib/memoryProvider.js +175 -0
- package/dist/lib/memoryStatus.d.ts +17 -0
- package/dist/lib/memoryStatus.js +88 -0
- package/dist/lib/nativeMemory.d.ts +19 -0
- package/dist/lib/nativeMemory.js +129 -0
- package/dist/lib/runtimeControlProvider.d.ts +16 -0
- package/dist/lib/runtimeControlProvider.js +168 -0
- package/dist/lib/secretEnv.d.ts +2 -1
- package/dist/lib/secretEnv.js +6 -2
- package/dist/lib/skillProvider.d.ts +13 -0
- package/dist/lib/skillProvider.js +210 -0
- package/dist/lib/taskProvider.d.ts +7 -0
- package/dist/lib/taskProvider.js +43 -0
- package/dist/lib/taskStore.d.ts +46 -0
- package/dist/lib/taskStore.js +156 -0
- package/dist/lib/terminalPicker.d.ts +23 -49
- package/dist/lib/terminalPicker.js +53 -218
- package/dist/pi-bridge/bundle.js +475 -37
- package/dist/pi-bridge/index.js +42 -21
- package/dist/pi-bridge/schemaTranslate.d.ts +4 -8
- package/dist/pi-bridge/schemaTranslate.js +4 -8
- package/dist/probes/kimi-code.d.ts +12 -0
- package/dist/probes/kimi-code.js +55 -0
- package/dist/sdk.d.ts +1 -1
- package/docs/architecture.md +224 -26
- package/docs/assets/agent-trellis-architecture-horizontal.svg +80 -0
- package/docs/assets/agent-trellis-architecture-vertical.svg +82 -0
- package/docs/assets/trellis-mark.svg +12 -0
- package/docs/getting-started.md +328 -35
- package/docs/overview.md +131 -0
- package/docs/overview.zh-CN.md +100 -0
- package/docs/research.md +59 -0
- package/docs/roadmap.md +9 -5
- package/package.json +3 -2
- package/schema/builtin-skills/trellis-runtime/SKILL.md +117 -0
- package/schema/capability-selection.example.yaml +23 -0
- package/schema/scope.example.yaml +1 -1
- package/schema/servers.example.yaml +15 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AgentId } from "../core/types.js";
|
|
2
|
+
export type TaskStatus = "pending" | "claimed" | "in_progress" | "blocked" | "completed";
|
|
3
|
+
export interface TaskContext {
|
|
4
|
+
workspace?: string;
|
|
5
|
+
commit?: string;
|
|
6
|
+
references?: string[];
|
|
7
|
+
note?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface TaskHistoryEntry {
|
|
10
|
+
at: string;
|
|
11
|
+
agent: AgentId;
|
|
12
|
+
action: string;
|
|
13
|
+
status: TaskStatus;
|
|
14
|
+
note?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface TaskRecord {
|
|
17
|
+
id: string;
|
|
18
|
+
objective: string;
|
|
19
|
+
fromAgent: AgentId;
|
|
20
|
+
toAgent?: AgentId;
|
|
21
|
+
status: TaskStatus;
|
|
22
|
+
context?: TaskContext;
|
|
23
|
+
constraints?: string[];
|
|
24
|
+
result?: string;
|
|
25
|
+
claimedBy?: AgentId;
|
|
26
|
+
leaseUntil?: string;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
updatedAt: string;
|
|
29
|
+
history: TaskHistoryEntry[];
|
|
30
|
+
}
|
|
31
|
+
export declare function taskStorePath(homeDir: string): string;
|
|
32
|
+
export declare function listTasks(homeDir: string, filter?: {
|
|
33
|
+
status?: TaskStatus;
|
|
34
|
+
toAgent?: AgentId;
|
|
35
|
+
}): TaskRecord[];
|
|
36
|
+
export declare function readTask(homeDir: string, id: string): TaskRecord;
|
|
37
|
+
export declare function createTask(homeDir: string, input: {
|
|
38
|
+
objective: string;
|
|
39
|
+
fromAgent: AgentId;
|
|
40
|
+
toAgent?: AgentId;
|
|
41
|
+
context?: TaskContext;
|
|
42
|
+
constraints?: string[];
|
|
43
|
+
}): TaskRecord;
|
|
44
|
+
export declare function claimTask(homeDir: string, id: string, agent: AgentId, leaseMs?: number): TaskRecord;
|
|
45
|
+
export declare function updateTask(homeDir: string, id: string, agent: AgentId, status: TaskStatus, note?: string, result?: string): TaskRecord;
|
|
46
|
+
export declare function handoffTask(homeDir: string, id: string, agent: AgentId, toAgent: AgentId, note?: string): TaskRecord;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
const SECRET_LIKE = /(sk-|ghp_|glpat-|mcpr_|token\s*=|password\s*=|secret\s*=|authorization\s*:)/i;
|
|
5
|
+
export function taskStorePath(homeDir) {
|
|
6
|
+
return join(homeDir, ".trellis", "tasks", "tasks.json");
|
|
7
|
+
}
|
|
8
|
+
function readFile(path) {
|
|
9
|
+
if (!existsSync(path))
|
|
10
|
+
return { tasks: [] };
|
|
11
|
+
try {
|
|
12
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
13
|
+
return { tasks: Array.isArray(value.tasks) ? value.tasks : [] };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new Error(`could not parse task store ${path}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function writeFile(path, value) {
|
|
20
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
21
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
22
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
23
|
+
renameSync(temporary, path);
|
|
24
|
+
}
|
|
25
|
+
function assertSafeText(label, value) {
|
|
26
|
+
if (!value.trim())
|
|
27
|
+
throw new Error(`${label} must not be empty`);
|
|
28
|
+
if (SECRET_LIKE.test(value))
|
|
29
|
+
throw new Error(`${label} looks like it contains a credential or secret-like value`);
|
|
30
|
+
}
|
|
31
|
+
function assertSafeContext(context) {
|
|
32
|
+
if (!context)
|
|
33
|
+
return;
|
|
34
|
+
for (const [key, value] of Object.entries(context)) {
|
|
35
|
+
if (typeof value === "string")
|
|
36
|
+
assertSafeText(`context.${key}`, value);
|
|
37
|
+
if (Array.isArray(value))
|
|
38
|
+
value.forEach((item) => assertSafeText(`context.${key}`, item));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function now() {
|
|
42
|
+
return new Date().toISOString();
|
|
43
|
+
}
|
|
44
|
+
function assertAgent(value, label) {
|
|
45
|
+
if (value !== "claude-code" && value !== "codex" && value !== "kiro" && value !== "pi" && value !== "kimi-code") {
|
|
46
|
+
throw new Error(`${label} must be a supported Agent id`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function taskOrThrow(file, id) {
|
|
50
|
+
const task = file.tasks.find((candidate) => candidate.id === id);
|
|
51
|
+
if (!task)
|
|
52
|
+
throw new Error(`task "${id}" was not found`);
|
|
53
|
+
return task;
|
|
54
|
+
}
|
|
55
|
+
function addHistory(task, agent, action, status, note) {
|
|
56
|
+
task.status = status;
|
|
57
|
+
task.updatedAt = now();
|
|
58
|
+
task.history.push({ at: task.updatedAt, agent, action, status, ...(note ? { note } : {}) });
|
|
59
|
+
}
|
|
60
|
+
export function listTasks(homeDir, filter) {
|
|
61
|
+
const tasks = readFile(taskStorePath(homeDir)).tasks;
|
|
62
|
+
return tasks
|
|
63
|
+
.filter((task) => !filter?.status || task.status === filter.status)
|
|
64
|
+
.filter((task) => !filter?.toAgent || task.toAgent === filter.toAgent)
|
|
65
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
66
|
+
}
|
|
67
|
+
export function readTask(homeDir, id) {
|
|
68
|
+
return taskOrThrow(readFile(taskStorePath(homeDir)), id);
|
|
69
|
+
}
|
|
70
|
+
export function createTask(homeDir, input) {
|
|
71
|
+
assertSafeText("objective", input.objective);
|
|
72
|
+
assertAgent(input.fromAgent, "fromAgent");
|
|
73
|
+
if (input.toAgent)
|
|
74
|
+
assertAgent(input.toAgent, "toAgent");
|
|
75
|
+
assertSafeContext(input.context);
|
|
76
|
+
input.constraints?.forEach((value) => assertSafeText("constraint", value));
|
|
77
|
+
const timestamp = now();
|
|
78
|
+
const task = {
|
|
79
|
+
id: `task-${randomUUID()}`,
|
|
80
|
+
objective: input.objective,
|
|
81
|
+
fromAgent: input.fromAgent,
|
|
82
|
+
...(input.toAgent ? { toAgent: input.toAgent } : {}),
|
|
83
|
+
status: "pending",
|
|
84
|
+
...(input.context ? { context: input.context } : {}),
|
|
85
|
+
...(input.constraints?.length ? { constraints: input.constraints } : {}),
|
|
86
|
+
createdAt: timestamp,
|
|
87
|
+
updatedAt: timestamp,
|
|
88
|
+
history: [{ at: timestamp, agent: input.fromAgent, action: "created", status: "pending" }],
|
|
89
|
+
};
|
|
90
|
+
const file = readFile(taskStorePath(homeDir));
|
|
91
|
+
file.tasks.push(task);
|
|
92
|
+
writeFile(taskStorePath(homeDir), file);
|
|
93
|
+
return task;
|
|
94
|
+
}
|
|
95
|
+
export function claimTask(homeDir, id, agent, leaseMs = 30 * 60 * 1000) {
|
|
96
|
+
assertAgent(agent, "agent");
|
|
97
|
+
if (!Number.isFinite(leaseMs) || leaseMs < 1_000 || leaseMs > 24 * 60 * 60 * 1000)
|
|
98
|
+
throw new Error("leaseMs must be between 1000 and 86400000");
|
|
99
|
+
const path = taskStorePath(homeDir);
|
|
100
|
+
const file = readFile(path);
|
|
101
|
+
const task = taskOrThrow(file, id);
|
|
102
|
+
const timestamp = Date.now();
|
|
103
|
+
if (task.leaseUntil && new Date(task.leaseUntil).getTime() <= timestamp) {
|
|
104
|
+
task.claimedBy = undefined;
|
|
105
|
+
task.leaseUntil = undefined;
|
|
106
|
+
if (task.status === "claimed" || task.status === "in_progress")
|
|
107
|
+
task.status = "pending";
|
|
108
|
+
}
|
|
109
|
+
if (task.status !== "pending" || task.claimedBy)
|
|
110
|
+
throw new Error(`task "${id}" is already claimed or is not pending`);
|
|
111
|
+
task.claimedBy = agent;
|
|
112
|
+
task.leaseUntil = new Date(timestamp + leaseMs).toISOString();
|
|
113
|
+
addHistory(task, agent, "claimed", "claimed");
|
|
114
|
+
writeFile(path, file);
|
|
115
|
+
return task;
|
|
116
|
+
}
|
|
117
|
+
export function updateTask(homeDir, id, agent, status, note, result) {
|
|
118
|
+
assertAgent(agent, "agent");
|
|
119
|
+
if (!["pending", "claimed", "in_progress", "blocked", "completed"].includes(status))
|
|
120
|
+
throw new Error(`invalid task status "${status}"`);
|
|
121
|
+
if (note)
|
|
122
|
+
assertSafeText("note", note);
|
|
123
|
+
if (result)
|
|
124
|
+
assertSafeText("result", result);
|
|
125
|
+
const path = taskStorePath(homeDir);
|
|
126
|
+
const file = readFile(path);
|
|
127
|
+
const task = taskOrThrow(file, id);
|
|
128
|
+
if (task.claimedBy && task.claimedBy !== agent)
|
|
129
|
+
throw new Error(`task "${id}" is claimed by another Agent`);
|
|
130
|
+
if (status === "completed" && result)
|
|
131
|
+
task.result = result;
|
|
132
|
+
addHistory(task, agent, status === "completed" ? "completed" : "updated", status, note);
|
|
133
|
+
if (status === "completed") {
|
|
134
|
+
task.claimedBy = undefined;
|
|
135
|
+
task.leaseUntil = undefined;
|
|
136
|
+
}
|
|
137
|
+
writeFile(path, file);
|
|
138
|
+
return task;
|
|
139
|
+
}
|
|
140
|
+
export function handoffTask(homeDir, id, agent, toAgent, note) {
|
|
141
|
+
assertAgent(agent, "agent");
|
|
142
|
+
assertAgent(toAgent, "toAgent");
|
|
143
|
+
if (note)
|
|
144
|
+
assertSafeText("note", note);
|
|
145
|
+
const path = taskStorePath(homeDir);
|
|
146
|
+
const file = readFile(path);
|
|
147
|
+
const task = taskOrThrow(file, id);
|
|
148
|
+
if (task.claimedBy && task.claimedBy !== agent)
|
|
149
|
+
throw new Error(`task "${id}" is claimed by another Agent`);
|
|
150
|
+
task.toAgent = toAgent;
|
|
151
|
+
task.claimedBy = undefined;
|
|
152
|
+
task.leaseUntil = undefined;
|
|
153
|
+
addHistory(task, agent, "handed-off", "pending", note);
|
|
154
|
+
writeFile(path, file);
|
|
155
|
+
return task;
|
|
156
|
+
}
|
|
@@ -1,66 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (trellis-onboard-interactive-picker design.md D1) — the interaction
|
|
4
|
-
* surface `trellis onboard`'s two prompts need (at most four rows, no
|
|
5
|
-
* search, no pagination) doesn't need a full prompt library; this
|
|
6
|
-
* hand-rolls just enough raw-mode key handling and ANSI rendering for
|
|
7
|
-
* that bounded case, matching the project's existing narrow-dependency
|
|
8
|
-
* precedent (src/lib/envVarNames.ts, src/lib/secretEnv.ts).
|
|
2
|
+
* Styled interactive prompts used by `trellis onboard`.
|
|
9
3
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
4
|
+
* The onboarding orchestration depends on semantic values (an index or a set
|
|
5
|
+
* of indices), not on a particular terminal renderer. Clack owns raw-mode
|
|
6
|
+
* handling, color, wrapping, cursor cleanup, and cancellation; the injected
|
|
7
|
+
* streams keep the prompt loop testable without a real terminal.
|
|
14
8
|
*/
|
|
15
9
|
import type { Readable, Writable } from "node:stream";
|
|
16
10
|
export interface PickerStreams {
|
|
17
11
|
input: NodeJS.ReadStream | Readable;
|
|
18
12
|
output: NodeJS.WriteStream | Writable;
|
|
19
13
|
}
|
|
14
|
+
export interface PickerOption {
|
|
15
|
+
label: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
}
|
|
20
18
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* (design.md D2). A stream lacking `setRawMode` (piped input, some
|
|
24
|
-
* minimal TTYs) always falls back, never hangs or guesses.
|
|
19
|
+
* Clack is only started for a real interactive terminal. Piped input keeps
|
|
20
|
+
* the existing numbered fallback in onboard instead of hanging in raw mode.
|
|
25
21
|
*/
|
|
26
22
|
export declare function canUseInteractivePicker(streams?: PickerStreams): boolean;
|
|
27
|
-
/** Wrapping index navigation
|
|
23
|
+
/** Wrapping index navigation retained as a small public utility for callers/tests. */
|
|
28
24
|
export declare function nextIndex(current: number, delta: number, length: number): number;
|
|
29
|
-
/** Pure single-index toggle
|
|
25
|
+
/** Pure single-index toggle retained for callers/tests. */
|
|
30
26
|
export declare function toggled(checked: readonly boolean[], index: number): boolean[];
|
|
31
|
-
/** A row's *visible* width, excluding the color codes wrapped around it —
|
|
32
|
-
* they add characters that occupy zero terminal columns. Needed to
|
|
33
|
-
* compute how many physical lines a row actually wraps to (see
|
|
34
|
-
* `physicalLineCount`); counting `row.length` directly would overcount
|
|
35
|
-
* and under-clear on the next redraw. */
|
|
36
|
-
export declare function visibleWidth(row: string): number;
|
|
37
|
-
/**
|
|
38
|
-
* How many physical terminal lines one logical row occupies once the
|
|
39
|
-
* terminal wraps it — `Math.ceil(visibleWidth / columns)`, at least 1.
|
|
40
|
-
* `clearLines`/`moveCursorUp` need this, not `rows.length`: a picker
|
|
41
|
-
* item whose rendered text is longer than the terminal is wide (a long
|
|
42
|
-
* agent summary, a narrow terminal, or both) wraps onto more than one
|
|
43
|
-
* physical line, and erasing/moving by the *logical* row count instead
|
|
44
|
-
* leaves the wrapped remainder of the previous frame on screen — found
|
|
45
|
-
* via a real mirasim terminal session where a (much longer,
|
|
46
|
-
* pre-truncation) row's wrapped tail was never cleared, so every redraw
|
|
47
|
-
* appended a new, only-partially-overwritten copy instead of replacing
|
|
48
|
-
* the old one.
|
|
49
|
-
*/
|
|
50
|
-
export declare function physicalLineCount(row: string, columns: number): number;
|
|
51
|
-
export declare function totalPhysicalLines(rows: readonly string[], columns: number): number;
|
|
52
27
|
/**
|
|
53
|
-
* Single-select
|
|
54
|
-
*
|
|
55
|
-
* must have already confirmed `canUseInteractivePicker()` — this function
|
|
56
|
-
* does not re-check, and assumes `streams.input` genuinely supports raw
|
|
57
|
-
* mode.
|
|
28
|
+
* Single-select prompt. The returned number is the original item index, so
|
|
29
|
+
* callers do not depend on display labels or prompt-library values.
|
|
58
30
|
*/
|
|
59
|
-
export declare function runSingleSelectPicker(items: string[], streams?: PickerStreams): Promise<number | null>;
|
|
31
|
+
export declare function runSingleSelectPicker(items: readonly (string | PickerOption)[], streams?: PickerStreams, message?: string): Promise<number | null>;
|
|
60
32
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* confirm time, or `null` on Ctrl+C cancel. Same precondition as
|
|
64
|
-
* `runSingleSelectPicker`.
|
|
33
|
+
* Checkbox prompt. The returned indices preserve the existing onboard
|
|
34
|
+
* contract, while Clack handles checked state, wrapping, colors, and cleanup.
|
|
65
35
|
*/
|
|
66
|
-
export declare function runMultiSelectPicker(items: string[], initiallyChecked: readonly boolean[], streams?: PickerStreams): Promise<number[] | null>;
|
|
36
|
+
export declare function runMultiSelectPicker(items: string[], initiallyChecked: readonly boolean[], streams?: PickerStreams, message?: string): Promise<number[] | null>;
|
|
37
|
+
/** Searchable multiselect for large skill/MCP inventories. It returns the
|
|
38
|
+
* selected labels as stable names, preserving the semantic adapter contract
|
|
39
|
+
* while avoiding a wall of dozens of rows in the onboarding terminal. */
|
|
40
|
+
export declare function runSearchMultiSelectPicker(items: readonly string[], streams?: PickerStreams, message?: string): Promise<string[] | null>;
|
|
@@ -1,248 +1,83 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (trellis-onboard-interactive-picker design.md D1) — the interaction
|
|
4
|
-
* surface `trellis onboard`'s two prompts need (at most four rows, no
|
|
5
|
-
* search, no pagination) doesn't need a full prompt library; this
|
|
6
|
-
* hand-rolls just enough raw-mode key handling and ANSI rendering for
|
|
7
|
-
* that bounded case, matching the project's existing narrow-dependency
|
|
8
|
-
* precedent (src/lib/envVarNames.ts, src/lib/secretEnv.ts).
|
|
2
|
+
* Styled interactive prompts used by `trellis onboard`.
|
|
9
3
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
4
|
+
* The onboarding orchestration depends on semantic values (an index or a set
|
|
5
|
+
* of indices), not on a particular terminal renderer. Clack owns raw-mode
|
|
6
|
+
* handling, color, wrapping, cursor cleanup, and cancellation; the injected
|
|
7
|
+
* streams keep the prompt loop testable without a real terminal.
|
|
14
8
|
*/
|
|
9
|
+
import { CANCEL_SYMBOL, autocompleteMultiselect, isCancel, multiselect, select } from "@clack/prompts";
|
|
15
10
|
function defaultStreams() {
|
|
16
|
-
return { input: process.stdin, output: process.
|
|
11
|
+
return { input: process.stdin, output: process.stderr };
|
|
17
12
|
}
|
|
18
13
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* (design.md D2). A stream lacking `setRawMode` (piped input, some
|
|
22
|
-
* minimal TTYs) always falls back, never hangs or guesses.
|
|
14
|
+
* Clack is only started for a real interactive terminal. Piped input keeps
|
|
15
|
+
* the existing numbered fallback in onboard instead of hanging in raw mode.
|
|
23
16
|
*/
|
|
24
17
|
export function canUseInteractivePicker(streams = defaultStreams()) {
|
|
25
18
|
const input = streams.input;
|
|
26
19
|
const output = streams.output;
|
|
27
20
|
return Boolean(input.isTTY && output.isTTY && typeof input.setRawMode === "function");
|
|
28
21
|
}
|
|
29
|
-
/** Wrapping index navigation
|
|
22
|
+
/** Wrapping index navigation retained as a small public utility for callers/tests. */
|
|
30
23
|
export function nextIndex(current, delta, length) {
|
|
31
24
|
return ((current + delta) % length + length) % length;
|
|
32
25
|
}
|
|
33
|
-
/** Pure single-index toggle
|
|
26
|
+
/** Pure single-index toggle retained for callers/tests. */
|
|
34
27
|
export function toggled(checked, index) {
|
|
35
28
|
const next = [...checked];
|
|
36
29
|
next[index] = !next[index];
|
|
37
30
|
return next;
|
|
38
31
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const HIDE_CURSOR = `${ESC}[?25l`;
|
|
42
|
-
const SHOW_CURSOR = `${ESC}[?25h`;
|
|
43
|
-
const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*m/g;
|
|
44
|
-
/** A row's *visible* width, excluding the color codes wrapped around it —
|
|
45
|
-
* they add characters that occupy zero terminal columns. Needed to
|
|
46
|
-
* compute how many physical lines a row actually wraps to (see
|
|
47
|
-
* `physicalLineCount`); counting `row.length` directly would overcount
|
|
48
|
-
* and under-clear on the next redraw. */
|
|
49
|
-
export function visibleWidth(row) {
|
|
50
|
-
return row.replace(ANSI_ESCAPE_RE, "").length;
|
|
32
|
+
function wasCancelled(value) {
|
|
33
|
+
return value === CANCEL_SYMBOL || isCancel(value);
|
|
51
34
|
}
|
|
52
35
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* `clearLines`/`moveCursorUp` need this, not `rows.length`: a picker
|
|
56
|
-
* item whose rendered text is longer than the terminal is wide (a long
|
|
57
|
-
* agent summary, a narrow terminal, or both) wraps onto more than one
|
|
58
|
-
* physical line, and erasing/moving by the *logical* row count instead
|
|
59
|
-
* leaves the wrapped remainder of the previous frame on screen — found
|
|
60
|
-
* via a real mirasim terminal session where a (much longer,
|
|
61
|
-
* pre-truncation) row's wrapped tail was never cleared, so every redraw
|
|
62
|
-
* appended a new, only-partially-overwritten copy instead of replacing
|
|
63
|
-
* the old one.
|
|
36
|
+
* Single-select prompt. The returned number is the original item index, so
|
|
37
|
+
* callers do not depend on display labels or prompt-library values.
|
|
64
38
|
*/
|
|
65
|
-
export function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
* wrap-aware math above exercised (and testable) even without a real
|
|
77
|
-
* terminal, rather than silently reverting to "assume no row ever wraps"
|
|
78
|
-
* for every non-TTY stream. */
|
|
79
|
-
function columnsOf(output) {
|
|
80
|
-
return output.columns ?? 80;
|
|
81
|
-
}
|
|
82
|
-
/** Parses one raw input chunk into a single logical key — arrow escape
|
|
83
|
-
* sequences, j/k, space, enter, or Ctrl+C. Anything else is ignored. */
|
|
84
|
-
function parseKey(chunk) {
|
|
85
|
-
if (chunk === CTRL_C)
|
|
86
|
-
return "cancel";
|
|
87
|
-
if (chunk === "\r" || chunk === "\n")
|
|
88
|
-
return "confirm";
|
|
89
|
-
if (chunk === " ")
|
|
90
|
-
return "toggle";
|
|
91
|
-
if (chunk === `${ESC}[A` || chunk === "k")
|
|
92
|
-
return "up";
|
|
93
|
-
if (chunk === `${ESC}[B` || chunk === "j")
|
|
94
|
-
return "down";
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
function withRawMode(streams, body) {
|
|
98
|
-
const input = streams.input;
|
|
99
|
-
const output = streams.output;
|
|
100
|
-
const canSetRawMode = typeof input.setRawMode === "function";
|
|
101
|
-
if (canSetRawMode)
|
|
102
|
-
input.setRawMode(true);
|
|
103
|
-
input.resume();
|
|
104
|
-
input.setEncoding("utf-8");
|
|
105
|
-
output.write(HIDE_CURSOR);
|
|
106
|
-
const restore = () => {
|
|
107
|
-
output.write(SHOW_CURSOR);
|
|
108
|
-
if (canSetRawMode)
|
|
109
|
-
input.setRawMode(false);
|
|
110
|
-
input.pause();
|
|
111
|
-
};
|
|
112
|
-
// Second-layer safety net (design.md D6): a thrown error the caller's
|
|
113
|
-
// own try/finally doesn't get a chance to run for (process killed
|
|
114
|
-
// externally) must still not leave the terminal in raw mode/cursor
|
|
115
|
-
// hidden.
|
|
116
|
-
process.once("exit", restore);
|
|
117
|
-
return body().finally(() => {
|
|
118
|
-
restore();
|
|
119
|
-
process.removeListener("exit", restore);
|
|
39
|
+
export async function runSingleSelectPicker(items, streams = defaultStreams(), message = "请选择一个迁移来源") {
|
|
40
|
+
const result = await select({
|
|
41
|
+
message,
|
|
42
|
+
options: items.map((item, value) => ({
|
|
43
|
+
label: typeof item === "string" ? item : item.label,
|
|
44
|
+
value,
|
|
45
|
+
...(typeof item === "string" || item.disabled === undefined ? {} : { disabled: item.disabled }),
|
|
46
|
+
})),
|
|
47
|
+
input: streams.input,
|
|
48
|
+
output: streams.output,
|
|
49
|
+
showInstructions: true,
|
|
120
50
|
});
|
|
121
|
-
|
|
122
|
-
function moveCursorUp(output, lines) {
|
|
123
|
-
if (lines > 0)
|
|
124
|
-
output.write(`${ESC}[${lines}A`);
|
|
125
|
-
}
|
|
126
|
-
function clearLines(output, lines) {
|
|
127
|
-
for (let i = 0; i < lines; i++) {
|
|
128
|
-
output.write(`${ESC}[2K`);
|
|
129
|
-
if (i < lines - 1)
|
|
130
|
-
output.write(`${ESC}[1B`);
|
|
131
|
-
}
|
|
132
|
-
moveCursorUp(output, lines - 1);
|
|
51
|
+
return wasCancelled(result) ? null : result;
|
|
133
52
|
}
|
|
134
53
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* that frame's rows), not `rows.length` — see `physicalLineCount`'s doc
|
|
138
|
-
* comment for why a wrapped row makes those two numbers diverge.
|
|
54
|
+
* Checkbox prompt. The returned indices preserve the existing onboard
|
|
55
|
+
* contract, while Clack handles checked state, wrapping, colors, and cleanup.
|
|
139
56
|
*/
|
|
140
|
-
function
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
* A real foreground color (bold cyan), not just reverse video —
|
|
150
|
-
* differentiating the highlighted row must not depend on a host
|
|
151
|
-
* terminal correctly inverting fore/background, which a real mirasim
|
|
152
|
-
* terminal session showed no visible effect from at all. The `>`/` `
|
|
153
|
-
* marker stays regardless, as a plain-text fallback for a host that
|
|
154
|
-
* strips color entirely.
|
|
155
|
-
*/
|
|
156
|
-
function highlightRow(text, isHighlighted) {
|
|
157
|
-
return isHighlighted ? `> ${ESC}[1;36m${text}${ESC}[0m` : ` ${text}`;
|
|
158
|
-
}
|
|
159
|
-
/**
|
|
160
|
-
* Single-select: Up/Down/j/k moves the highlight, Enter confirms. Resolves
|
|
161
|
-
* the confirmed index, or `null` on Ctrl+C cancel. Caller (onboard.ts)
|
|
162
|
-
* must have already confirmed `canUseInteractivePicker()` — this function
|
|
163
|
-
* does not re-check, and assumes `streams.input` genuinely supports raw
|
|
164
|
-
* mode.
|
|
165
|
-
*/
|
|
166
|
-
export async function runSingleSelectPicker(items, streams = defaultStreams()) {
|
|
167
|
-
const { input, output } = streams;
|
|
168
|
-
return withRawMode(streams, () => {
|
|
169
|
-
return new Promise((resolve) => {
|
|
170
|
-
let highlighted = 0;
|
|
171
|
-
let physicalLines = 0;
|
|
172
|
-
const columns = columnsOf(output);
|
|
173
|
-
const draw = () => {
|
|
174
|
-
const rows = items.map((label, i) => highlightRow(label, i === highlighted));
|
|
175
|
-
renderRows(output, rows, physicalLines);
|
|
176
|
-
physicalLines = totalPhysicalLines(rows, columns);
|
|
177
|
-
};
|
|
178
|
-
draw();
|
|
179
|
-
const onData = (chunk) => {
|
|
180
|
-
const key = parseKey(chunk.toString("utf-8"));
|
|
181
|
-
if (key === "up") {
|
|
182
|
-
highlighted = nextIndex(highlighted, -1, items.length);
|
|
183
|
-
draw();
|
|
184
|
-
}
|
|
185
|
-
else if (key === "down") {
|
|
186
|
-
highlighted = nextIndex(highlighted, 1, items.length);
|
|
187
|
-
draw();
|
|
188
|
-
}
|
|
189
|
-
else if (key === "confirm") {
|
|
190
|
-
input.removeListener("data", onData);
|
|
191
|
-
resolve(highlighted);
|
|
192
|
-
}
|
|
193
|
-
else if (key === "cancel") {
|
|
194
|
-
input.removeListener("data", onData);
|
|
195
|
-
resolve(null);
|
|
196
|
-
}
|
|
197
|
-
};
|
|
198
|
-
input.on("data", onData);
|
|
199
|
-
});
|
|
57
|
+
export async function runMultiSelectPicker(items, initiallyChecked, streams = defaultStreams(), message = "请选择要启用的项目") {
|
|
58
|
+
const result = await multiselect({
|
|
59
|
+
message,
|
|
60
|
+
options: items.map((label, value) => ({ label, value })),
|
|
61
|
+
initialValues: items.flatMap((_, index) => (initiallyChecked[index] ? [index] : [])),
|
|
62
|
+
input: streams.input,
|
|
63
|
+
output: streams.output,
|
|
64
|
+
required: false,
|
|
65
|
+
showInstructions: true,
|
|
200
66
|
});
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const columns = columnsOf(output);
|
|
216
|
-
const draw = () => {
|
|
217
|
-
const rows = items.map((label, i) => highlightRow(`[${checked[i] ? "x" : " "}] ${label}`, i === highlighted));
|
|
218
|
-
renderRows(output, rows, physicalLines);
|
|
219
|
-
physicalLines = totalPhysicalLines(rows, columns);
|
|
220
|
-
};
|
|
221
|
-
draw();
|
|
222
|
-
const onData = (chunk) => {
|
|
223
|
-
const key = parseKey(chunk.toString("utf-8"));
|
|
224
|
-
if (key === "up") {
|
|
225
|
-
highlighted = nextIndex(highlighted, -1, items.length);
|
|
226
|
-
draw();
|
|
227
|
-
}
|
|
228
|
-
else if (key === "down") {
|
|
229
|
-
highlighted = nextIndex(highlighted, 1, items.length);
|
|
230
|
-
draw();
|
|
231
|
-
}
|
|
232
|
-
else if (key === "toggle") {
|
|
233
|
-
checked = toggled(checked, highlighted);
|
|
234
|
-
draw();
|
|
235
|
-
}
|
|
236
|
-
else if (key === "confirm") {
|
|
237
|
-
input.removeListener("data", onData);
|
|
238
|
-
resolve(checked.flatMap((isChecked, i) => (isChecked ? [i] : [])));
|
|
239
|
-
}
|
|
240
|
-
else if (key === "cancel") {
|
|
241
|
-
input.removeListener("data", onData);
|
|
242
|
-
resolve(null);
|
|
243
|
-
}
|
|
244
|
-
};
|
|
245
|
-
input.on("data", onData);
|
|
246
|
-
});
|
|
67
|
+
return wasCancelled(result) ? null : [...result].sort((left, right) => left - right);
|
|
68
|
+
}
|
|
69
|
+
/** Searchable multiselect for large skill/MCP inventories. It returns the
|
|
70
|
+
* selected labels as stable names, preserving the semantic adapter contract
|
|
71
|
+
* while avoiding a wall of dozens of rows in the onboarding terminal. */
|
|
72
|
+
export async function runSearchMultiSelectPicker(items, streams = defaultStreams(), message = "选择要迁移的项目") {
|
|
73
|
+
const result = await autocompleteMultiselect({
|
|
74
|
+
message,
|
|
75
|
+
placeholder: "输入关键词筛选",
|
|
76
|
+
options: items.map((value) => ({ label: value, value })),
|
|
77
|
+
initialValues: [...items],
|
|
78
|
+
input: streams.input,
|
|
79
|
+
output: streams.output,
|
|
80
|
+
required: false,
|
|
247
81
|
});
|
|
82
|
+
return wasCancelled(result) ? null : result;
|
|
248
83
|
}
|