@zq-silk/yui 0.0.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 (95) hide show
  1. package/ARCHITECTURE.md +141 -0
  2. package/LICENSE +21 -0
  3. package/README.md +211 -0
  4. package/dist/agent/adapterCatalog.js +10 -0
  5. package/dist/agent/agent.js +89 -0
  6. package/dist/agent/agentRegistry.js +10 -0
  7. package/dist/agent/argumentPolicy.js +80 -0
  8. package/dist/brief/taskBrief.js +37 -0
  9. package/dist/cli/commandCatalog.js +647 -0
  10. package/dist/cli/completion.js +111 -0
  11. package/dist/cli/completionWizard.js +143 -0
  12. package/dist/cli/dynamicCompletion.js +48 -0
  13. package/dist/cli/helpRenderer.js +32 -0
  14. package/dist/cli/interactionCandidates.js +139 -0
  15. package/dist/cli/interactionPolicy.js +389 -0
  16. package/dist/cli/interactiveSelection.js +185 -0
  17. package/dist/cli/invocationRouter.js +51 -0
  18. package/dist/cli/roleOptionCatalog.js +67 -0
  19. package/dist/cli/roleWizard.js +546 -0
  20. package/dist/cli/selectionPorts.js +1 -0
  21. package/dist/cli/updateCommand.js +22 -0
  22. package/dist/cli.js +402 -0
  23. package/dist/commands/agentCommands.js +196 -0
  24. package/dist/commands/globalRoleCommands.js +367 -0
  25. package/dist/commands/jobCommands.js +100 -0
  26. package/dist/commands/operatorCommands.js +38 -0
  27. package/dist/commands/repositoryCommands.js +86 -0
  28. package/dist/commands/roleConfiguration.js +201 -0
  29. package/dist/commands/taskCommands.js +1344 -0
  30. package/dist/commands/taskContextCommand.js +215 -0
  31. package/dist/commands/taskInputCommands.js +423 -0
  32. package/dist/commands/taskRoleRuntimeStatus.js +152 -0
  33. package/dist/completion/completionInstaller.js +168 -0
  34. package/dist/completion/completionPort.js +1 -0
  35. package/dist/completion/completionState.js +137 -0
  36. package/dist/completion/completionWizard.js +125 -0
  37. package/dist/completion/fileCompletionManager.js +51 -0
  38. package/dist/config/yuiConfig.js +17 -0
  39. package/dist/context/dispatchContext.js +74 -0
  40. package/dist/controller/clientRuntime.js +215 -0
  41. package/dist/controller/controller.js +158 -0
  42. package/dist/controller/controllerMain.js +37 -0
  43. package/dist/controller/fileSchedulerStoreAdapter.js +322 -0
  44. package/dist/controller/runtime.js +31 -0
  45. package/dist/controller/sessionNotify.js +136 -0
  46. package/dist/core/controllerClient.js +127 -0
  47. package/dist/core/controllerServer.js +269 -0
  48. package/dist/core/protocol.js +169 -0
  49. package/dist/decision/decision.js +42 -0
  50. package/dist/doctor/doctor.js +229 -0
  51. package/dist/errors/cliError.js +38 -0
  52. package/dist/event/taskEvent.js +44 -0
  53. package/dist/executor/agentAdapter.js +338 -0
  54. package/dist/executor/agentExecutor.js +144 -0
  55. package/dist/executor/executorRegistry.js +101 -0
  56. package/dist/executor/fileRoleLaunchPlanner.js +156 -0
  57. package/dist/executor/launchPlan.js +16 -0
  58. package/dist/input/inputRequest.js +326 -0
  59. package/dist/message/message.js +69 -0
  60. package/dist/milestone/milestone.js +27 -0
  61. package/dist/operator/operatorContext.js +66 -0
  62. package/dist/output/rolePresentation.js +82 -0
  63. package/dist/output/table.js +77 -0
  64. package/dist/output/terminal.js +198 -0
  65. package/dist/repository/gitWorkspace.js +210 -0
  66. package/dist/repository/repository.js +55 -0
  67. package/dist/repository/taskWorkspacePreparer.js +256 -0
  68. package/dist/role/role.js +246 -0
  69. package/dist/role/systemRoles.js +20 -0
  70. package/dist/run/agentRun.js +102 -0
  71. package/dist/scheduler/activeRoleRunDelivery.js +94 -0
  72. package/dist/scheduler/archivedTaskRuntime.js +12 -0
  73. package/dist/scheduler/leaderFailure.js +18 -0
  74. package/dist/scheduler/leaderWakeupProcessor.js +143 -0
  75. package/dist/scheduler/operatorInputNotificationProcessor.js +85 -0
  76. package/dist/scheduler/operatorNotification.js +17 -0
  77. package/dist/scheduler/pendingWakeup.js +33 -0
  78. package/dist/scheduler/ports.js +1 -0
  79. package/dist/scheduler/roleRunLiveness.js +41 -0
  80. package/dist/scheduler/wakeupQueue.js +13 -0
  81. package/dist/setup/setupCommand.js +317 -0
  82. package/dist/storage/durableFile.js +38 -0
  83. package/dist/storage/storageSchema.js +259 -0
  84. package/dist/storage/taskStore.js +1032 -0
  85. package/dist/task/task.js +216 -0
  86. package/dist/tmux/commandExecutor.js +69 -0
  87. package/dist/tmux/terminalHandoff.js +17 -0
  88. package/dist/tmux/tmuxManager.js +408 -0
  89. package/dist/workItem/workItem.js +45 -0
  90. package/dist/worktree/roleWorkspace.js +62 -0
  91. package/i18n/README.zh-CN.md +205 -0
  92. package/package.json +47 -0
  93. package/skills/yui-leader/SKILL.md +72 -0
  94. package/skills/yui-operator/SKILL.md +57 -0
  95. package/skills/yui-worker/SKILL.md +31 -0
@@ -0,0 +1,152 @@
1
+ export function inspectTaskRoleRuntimeStatuses(taskId, roles, store, panes) {
2
+ const taskOpenInputRequestCount = store.listInputRequests(taskId)
3
+ .filter((request) => request.status === "open").length;
4
+ const panesByRole = new Map();
5
+ for (const pane of panes) {
6
+ const current = panesByRole.get(pane.roleName);
7
+ if (current === undefined || current.dead && !pane.dead)
8
+ panesByRole.set(pane.roleName, pane);
9
+ }
10
+ return roles.map((role) => inspectTaskRoleRuntimeStatus(taskId, role, store, panesByRole.get(role.name), role.name === "leader" ? taskOpenInputRequestCount : 0));
11
+ }
12
+ export function renderTaskRoleRuntimeStatus(status) {
13
+ const activeRun = status.activeRun === null
14
+ ? "-"
15
+ : `${status.activeRun.id} (${status.activeRun.deliveredAt === undefined ? "queued" : "delivered"})`;
16
+ const activeWork = status.activeWork === null
17
+ ? "-"
18
+ : `${status.activeWork.id} (${status.activeWork.status}) ${status.activeWork.title}`;
19
+ const nativeSession = status.nativeSession === null
20
+ ? "not recorded"
21
+ : `${status.nativeSession.nativeSessionId} (${status.nativeSession.status}, ${status.nativeSession.adapterId})`;
22
+ const tmux = status.tmux.state === "missing"
23
+ ? "missing"
24
+ : [
25
+ status.tmux.state,
26
+ status.tmux.currentCommand === undefined ? undefined : `command=${status.tmux.currentCommand}`,
27
+ status.tmux.pid === undefined ? undefined : `pid=${status.tmux.pid}`,
28
+ status.tmux.target
29
+ ].filter((value) => value !== undefined).join(", ");
30
+ const workspaceDetails = status.workspace.managed
31
+ ? [
32
+ ` Repository ${status.workspace.repositoryId}`,
33
+ ` Branch ${status.workspace.branch}`,
34
+ ` Base ${status.workspace.baseRef} (${status.workspace.baseCommit})`
35
+ ]
36
+ : [];
37
+ return [
38
+ `Task Role status: ${status.taskId}/${status.roleName}`,
39
+ "",
40
+ ` Health ${status.health}`,
41
+ ` Reason ${status.healthReason}`,
42
+ ` Open inputs ${status.openInputRequestCount}`,
43
+ ` Agent ${status.agentId}`,
44
+ ` Role state ${status.role.status}`,
45
+ ` Active work ${activeWork}`,
46
+ ` Active run ${activeRun}`,
47
+ ` Native session ${nativeSession}`,
48
+ ` tmux pane ${tmux}`,
49
+ ` Workspace ${status.workspace.path}`,
50
+ ...workspaceDetails
51
+ ].join("\n").concat("\n");
52
+ }
53
+ export function taskRoleActiveWorkLabel(status) {
54
+ if (status.activeWork !== null)
55
+ return `${status.activeWork.id}: ${status.activeWork.title}`;
56
+ return status.activeRun === null ? "-" : status.activeRun.id;
57
+ }
58
+ export function taskRoleNativeSessionLabel(status) {
59
+ return status.nativeSession?.status ?? "unbound";
60
+ }
61
+ export function taskRoleOpenInputLabel(status) {
62
+ return status.roleName === "leader" && status.openInputRequestCount > 0
63
+ ? String(status.openInputRequestCount)
64
+ : "-";
65
+ }
66
+ export function taskRoleTmuxLabel(status) {
67
+ if (status.tmux.state !== "running")
68
+ return status.tmux.state;
69
+ return status.tmux.currentCommand === undefined
70
+ ? "running"
71
+ : `running (${status.tmux.currentCommand})`;
72
+ }
73
+ function inspectTaskRoleRuntimeStatus(taskId, role, store, pane, openInputRequestCount) {
74
+ const activeRun = store.getActiveAgentRun(taskId, role.name);
75
+ const activeWork = activeRun?.workItemId === undefined
76
+ ? null
77
+ : store.getWorkItem(taskId, activeRun.workItemId);
78
+ const sessions = store.getTaskRoleSessionSet(taskId, role.name);
79
+ const nativeSession = sessions?.sessions[role.activeAgentId] ?? null;
80
+ const tmux = pane === undefined
81
+ ? { state: "missing" }
82
+ : {
83
+ state: pane.dead ? "exited" : "running",
84
+ target: pane.target,
85
+ dead: pane.dead,
86
+ ...(pane.pid === undefined ? {} : { pid: pane.pid }),
87
+ currentCommand: pane.currentCommand
88
+ };
89
+ const managedWorkspace = store.getRoleWorkspace(taskId, role.name);
90
+ const workspace = managedWorkspace === null
91
+ ? { managed: false, path: role.workspace }
92
+ : { ...managedWorkspace, managed: true };
93
+ const health = calculateHealth(role, activeRun, nativeSession, tmux, openInputRequestCount);
94
+ return {
95
+ taskId,
96
+ roleName: role.name,
97
+ agentId: role.activeAgentId,
98
+ ...health,
99
+ openInputRequestCount,
100
+ role,
101
+ activeRun,
102
+ activeWork,
103
+ nativeSession,
104
+ tmux,
105
+ workspace
106
+ };
107
+ }
108
+ function calculateHealth(role, activeRun, nativeSession, tmux, openInputRequestCount) {
109
+ if (role.status === "failed" || role.status === "exited") {
110
+ return { health: "failed", healthReason: `persisted Role state is ${role.status}` };
111
+ }
112
+ if (nativeSession?.status === "broken") {
113
+ return { health: "failed", healthReason: "the active native session is broken" };
114
+ }
115
+ if (tmux.state === "exited") {
116
+ return { health: "failed", healthReason: "the tmux pane has exited" };
117
+ }
118
+ if (activeRun !== null) {
119
+ if (role.status !== "running") {
120
+ return {
121
+ health: "needs-attention",
122
+ healthReason: `the active Run conflicts with persisted Role state ${role.status}`
123
+ };
124
+ }
125
+ if (activeRun.deliveredAt !== undefined && tmux.state !== "running") {
126
+ return { health: "needs-attention", healthReason: "the delivered active Run has no live tmux pane" };
127
+ }
128
+ }
129
+ if (activeRun === null && role.status === "running") {
130
+ return { health: "needs-attention", healthReason: "the Role is running without an active Run" };
131
+ }
132
+ if (nativeSession?.status === "running" && tmux.state !== "running") {
133
+ return { health: "needs-attention", healthReason: "the native session is running without a live tmux pane" };
134
+ }
135
+ if (nativeSession?.status === "stopped" && tmux.state === "running") {
136
+ return { health: "needs-attention", healthReason: "a stopped native session has a live tmux pane" };
137
+ }
138
+ if (role.name === "leader" && openInputRequestCount > 0) {
139
+ return {
140
+ health: "blocked-input",
141
+ healthReason: `${openInputRequestCount} open InputRequest${openInputRequestCount === 1 ? "" : "s"} require user input`
142
+ };
143
+ }
144
+ if (activeRun !== null) {
145
+ return activeRun.deliveredAt === undefined
146
+ ? { health: "starting", healthReason: "the active Run is awaiting tmux delivery" }
147
+ : { health: "running", healthReason: "the active Run has a live tmux pane" };
148
+ }
149
+ return tmux.state === "running"
150
+ ? { health: "ready", healthReason: "the native Agent pane is ready without active work" }
151
+ : { health: "idle", healthReason: "there is no active work or live tmux pane" };
152
+ }
@@ -0,0 +1,168 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { dataError } from "../errors/cliError.js";
5
+ import { activationBlock, activationEnd, activationIsAutomatic, activationStart, completionMarker, managedCompletionScript } from "./completionState.js";
6
+ export function installCompletion(store, shell, installation, env, identity, activate) {
7
+ validateInstallation(installation);
8
+ writeManagedScript(shell, installation.scriptPath, identity);
9
+ const config = store.getConfig();
10
+ store.saveConfig({
11
+ ...config,
12
+ completionInstallations: {
13
+ ...config.completionInstallations,
14
+ [shell]: installation
15
+ }
16
+ });
17
+ if (!activationIsAutomatic(shell, installation, env, identity) && activate) {
18
+ writeActivationBlock(shell, installation, identity);
19
+ }
20
+ }
21
+ export function uninstallCompletion(store, shell, identity) {
22
+ const config = store.getConfig();
23
+ const installation = config.completionInstallations?.[shell];
24
+ if (installation === undefined)
25
+ return;
26
+ assertManagedScriptRemovable(shell, installation.scriptPath, identity);
27
+ assertActivationRemovable(shell, installation, identity);
28
+ removeManagedScript(shell, installation.scriptPath, identity);
29
+ removeActivationBlock(shell, installation, identity);
30
+ const installations = { ...config.completionInstallations };
31
+ delete installations[shell];
32
+ store.saveConfig({
33
+ ...config,
34
+ completionInstallations: Object.keys(installations).length === 0
35
+ ? undefined
36
+ : installations
37
+ });
38
+ }
39
+ function assertManagedScriptRemovable(shell, path, identity) {
40
+ if (!existsSync(path))
41
+ return;
42
+ const stat = lstatSync(path);
43
+ if (!stat.isFile()
44
+ || stat.isSymbolicLink()
45
+ || !readFileSync(path, "utf8").startsWith(completionMarker(shell, identity))) {
46
+ throw dataError(`Refusing to remove unmanaged completion script: ${path}`);
47
+ }
48
+ }
49
+ function assertActivationRemovable(shell, installation, identity) {
50
+ const path = installation.activationPath;
51
+ if (!existsSync(path))
52
+ return;
53
+ const stat = lstatSync(path);
54
+ if (!stat.isFile() || stat.isSymbolicLink()) {
55
+ throw dataError(`Refusing to modify unsafe activation file: ${path}`);
56
+ }
57
+ const contents = readFileSync(path, "utf8");
58
+ const starts = occurrences(contents, activationStart(shell, identity));
59
+ const ends = occurrences(contents, activationEnd(shell, identity));
60
+ if (starts !== ends || starts > 1) {
61
+ throw dataError(`Refusing to remove ambiguous Yui activation block: ${path}`);
62
+ }
63
+ }
64
+ function writeManagedScript(shell, path, identity) {
65
+ if (existsSync(path)) {
66
+ const stat = lstatSync(path);
67
+ if (!stat.isFile() || stat.isSymbolicLink()) {
68
+ throw dataError(`Refusing to overwrite unsafe completion script: ${path}`);
69
+ }
70
+ if (!readFileSync(path, "utf8").startsWith(completionMarker(shell, identity))) {
71
+ throw dataError(`Refusing to overwrite unmanaged completion script: ${path}`);
72
+ }
73
+ }
74
+ writeAtomic(path, managedCompletionScript(shell, identity), 0o644);
75
+ }
76
+ function writeActivationBlock(shell, installation, identity) {
77
+ const path = installation.activationPath;
78
+ let contents = "";
79
+ let mode = 0o644;
80
+ if (existsSync(path)) {
81
+ const stat = lstatSync(path);
82
+ if (!stat.isFile() || stat.isSymbolicLink()) {
83
+ throw dataError(`Refusing to modify unsafe activation file: ${path}`);
84
+ }
85
+ contents = readFileSync(path, "utf8");
86
+ mode = stat.mode & 0o777;
87
+ }
88
+ const start = activationStart(shell, identity);
89
+ const end = activationEnd(shell, identity);
90
+ const starts = occurrences(contents, start);
91
+ const ends = occurrences(contents, end);
92
+ if (starts > 1 || ends > 1 || starts !== ends) {
93
+ throw dataError(`Refusing to modify ambiguous Yui activation block: ${path}`);
94
+ }
95
+ const block = activationBlock(shell, installation, identity);
96
+ const next = starts === 1
97
+ ? replaceManagedBlock(contents, start, end, block)
98
+ : `${contents}${contents.length === 0 || contents.endsWith("\n") ? "" : "\n"}${block}\n`;
99
+ writeAtomic(path, next, mode);
100
+ }
101
+ function removeManagedScript(shell, path, identity) {
102
+ if (!existsSync(path))
103
+ return;
104
+ const stat = lstatSync(path);
105
+ if (!stat.isFile()
106
+ || stat.isSymbolicLink()
107
+ || !readFileSync(path, "utf8").startsWith(completionMarker(shell, identity))) {
108
+ throw dataError(`Refusing to remove unmanaged completion script: ${path}`);
109
+ }
110
+ rmSync(path);
111
+ }
112
+ function removeActivationBlock(shell, installation, identity) {
113
+ const path = installation.activationPath;
114
+ if (!existsSync(path))
115
+ return;
116
+ const stat = lstatSync(path);
117
+ if (!stat.isFile() || stat.isSymbolicLink()) {
118
+ throw dataError(`Refusing to modify unsafe activation file: ${path}`);
119
+ }
120
+ const contents = readFileSync(path, "utf8");
121
+ const start = activationStart(shell, identity);
122
+ const end = activationEnd(shell, identity);
123
+ const starts = occurrences(contents, start);
124
+ const ends = occurrences(contents, end);
125
+ if (starts !== ends || starts > 1) {
126
+ throw dataError(`Refusing to remove ambiguous Yui activation block: ${path}`);
127
+ }
128
+ if (starts === 0)
129
+ return;
130
+ const blockStart = contents.indexOf(start);
131
+ const blockEnd = contents.indexOf(end, blockStart) + end.length;
132
+ let next = `${contents.slice(0, blockStart)}${contents.slice(blockEnd)}`;
133
+ if (next.startsWith("\n"))
134
+ next = next.slice(1);
135
+ if (next.endsWith("\n\n"))
136
+ next = next.slice(0, -1);
137
+ writeAtomic(path, next, stat.mode & 0o777);
138
+ }
139
+ function writeAtomic(path, contents, mode) {
140
+ mkdirSync(dirname(path), { recursive: true });
141
+ const temp = join(dirname(path), `.${randomUUID()}.yui-pending`);
142
+ try {
143
+ writeFileSync(temp, contents, { mode });
144
+ chmodSync(temp, mode);
145
+ renameSync(temp, path);
146
+ }
147
+ finally {
148
+ rmSync(temp, { force: true });
149
+ }
150
+ }
151
+ function replaceManagedBlock(contents, start, end, block) {
152
+ const from = contents.indexOf(start);
153
+ const to = contents.indexOf(end, from);
154
+ if (from < 0 || to < from)
155
+ throw dataError("Invalid Yui activation block.");
156
+ return `${contents.slice(0, from)}${block}${contents.slice(to + end.length)}`;
157
+ }
158
+ function occurrences(contents, value) {
159
+ return contents.split(value).length - 1;
160
+ }
161
+ function validateInstallation(installation) {
162
+ if (!isAbsolute(installation.scriptPath) || !isAbsolute(installation.activationPath)) {
163
+ throw dataError("Completion script and activation paths must be absolute.");
164
+ }
165
+ if (resolve(installation.scriptPath) === resolve(installation.activationPath)) {
166
+ throw dataError("Completion script and activation paths must be different.");
167
+ }
168
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,137 @@
1
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { renderCompletion } from "../cli/completion.js";
4
+ import { dataError } from "../errors/cliError.js";
5
+ import { defaultTableWidth, renderTable } from "../output/table.js";
6
+ export const COMPLETION_SHELLS = Object.freeze(["bash", "zsh", "fish"]);
7
+ export function currentCompletionShell(env) {
8
+ const value = basename(env.SHELL ?? "").toLowerCase();
9
+ return COMPLETION_SHELLS.includes(value)
10
+ ? value
11
+ : undefined;
12
+ }
13
+ export function suggestedCompletionInstallation(shell, env, identity) {
14
+ const home = absoluteEnvRoot("HOME", env.HOME);
15
+ if (shell === "bash") {
16
+ const data = env.XDG_DATA_HOME === undefined
17
+ ? join(home, ".local", "share")
18
+ : absoluteEnvRoot("XDG_DATA_HOME", env.XDG_DATA_HOME);
19
+ return {
20
+ scriptPath: join(data, "bash-completion", "completions", identity),
21
+ activationPath: join(home, ".bashrc")
22
+ };
23
+ }
24
+ if (shell === "zsh") {
25
+ const zshRoot = env.ZDOTDIR === undefined
26
+ ? home
27
+ : absoluteEnvRoot("ZDOTDIR", env.ZDOTDIR);
28
+ return {
29
+ scriptPath: join(zshRoot, ".zfunc", `_${identity}`),
30
+ activationPath: join(zshRoot, ".zshrc")
31
+ };
32
+ }
33
+ const config = env.XDG_CONFIG_HOME === undefined
34
+ ? join(home, ".config")
35
+ : absoluteEnvRoot("XDG_CONFIG_HOME", env.XDG_CONFIG_HOME);
36
+ return {
37
+ scriptPath: join(config, "fish", "completions", `${identity}.fish`),
38
+ activationPath: join(config, "fish", "config.fish")
39
+ };
40
+ }
41
+ export function inspectCompletionStates(config, env, identity) {
42
+ const current = currentCompletionShell(env);
43
+ return COMPLETION_SHELLS.map((shell) => {
44
+ const installation = config.completionInstallations?.[shell];
45
+ if (installation === undefined) {
46
+ return {
47
+ shell,
48
+ status: "Not installed",
49
+ action: "Install",
50
+ current: shell === current
51
+ };
52
+ }
53
+ const installed = completionScriptIsCurrent(shell, installation, identity)
54
+ && completionActivationIsCurrent(shell, installation, env, identity);
55
+ return {
56
+ shell,
57
+ status: installed ? "Installed" : "Needs repair",
58
+ action: installed ? "Refresh" : "Repair",
59
+ current: shell === current,
60
+ installation
61
+ };
62
+ });
63
+ }
64
+ export function renderCompletionStateTable(states, width = defaultTableWidth()) {
65
+ return renderTable("Completion installation", [
66
+ { header: "#", minWidth: 1, maxWidth: 3 },
67
+ { header: "Shell", minWidth: 4, maxWidth: 6 },
68
+ { header: "Status", minWidth: 12, maxWidth: 13 },
69
+ { header: "Action", minWidth: 7, maxWidth: 7 },
70
+ { header: "Current", minWidth: 7, maxWidth: 7 },
71
+ { header: "Script", minWidth: 8, maxWidth: 88 }
72
+ ], states.map((state, index) => [
73
+ String(index + 1),
74
+ shellLabel(state.shell),
75
+ state.status,
76
+ state.action,
77
+ state.current ? "yes" : "",
78
+ state.installation?.scriptPath ?? ""
79
+ ]), width);
80
+ }
81
+ export function managedCompletionScript(shell, identity) {
82
+ return `${completionMarker(shell, identity)}\n${renderCompletion(shell, identity)}`;
83
+ }
84
+ export function completionMarker(shell, identity) {
85
+ return `# yui-completion: managed shell=${shell} identity=${identity} format=1`;
86
+ }
87
+ export function activationBlock(shell, installation, identity) {
88
+ const start = activationStart(shell, identity);
89
+ const end = activationEnd(shell, identity);
90
+ const source = `source ${shellQuote(installation.scriptPath)}`;
91
+ const functionName = basename(installation.scriptPath);
92
+ const body = shell === "zsh"
93
+ ? `fpath=(${shellQuote(dirname(installation.scriptPath))} $fpath)\nautoload -Uz compinit\n(( $+functions[compdef] )) || compinit\nautoload -Uz -- ${shellQuote(functionName)}\ncompdef ${shellQuote(functionName)} ${shellQuote(identity)}`
94
+ : source;
95
+ return `${start}\n${body}\n${end}`;
96
+ }
97
+ export function activationStart(shell, identity) {
98
+ return `# >>> yui completion shell=${shell} identity=${identity} >>>`;
99
+ }
100
+ export function activationEnd(shell, identity) {
101
+ return `# <<< yui completion shell=${shell} identity=${identity} <<<`;
102
+ }
103
+ export function activationIsAutomatic(shell, installation, env, identity) {
104
+ return shell === "fish"
105
+ && installation.scriptPath === suggestedCompletionInstallation(shell, env, identity).scriptPath;
106
+ }
107
+ export function shellQuote(value) {
108
+ return `'${value.replaceAll("'", "'\\''")}'`;
109
+ }
110
+ export function completionScriptIsCurrent(shell, installation, identity) {
111
+ return safeRegularFile(installation.scriptPath)
112
+ && readFileSync(installation.scriptPath, "utf8") === managedCompletionScript(shell, identity);
113
+ }
114
+ export function completionActivationIsCurrent(shell, installation, env, identity) {
115
+ if (activationIsAutomatic(shell, installation, env, identity))
116
+ return true;
117
+ if (!safeRegularFile(installation.activationPath))
118
+ return false;
119
+ const contents = readFileSync(installation.activationPath, "utf8");
120
+ const block = activationBlock(shell, installation, identity);
121
+ return contents.split(block).length === 2;
122
+ }
123
+ function safeRegularFile(path) {
124
+ if (!existsSync(path))
125
+ return false;
126
+ const stat = lstatSync(path);
127
+ return stat.isFile() && !stat.isSymbolicLink();
128
+ }
129
+ function absoluteEnvRoot(name, value) {
130
+ if (value === undefined || value.length === 0 || !isAbsolute(value)) {
131
+ throw dataError(`${name} must be an absolute path for completion installation.`);
132
+ }
133
+ return resolve(value);
134
+ }
135
+ function shellLabel(shell) {
136
+ return `${shell[0]?.toUpperCase() ?? ""}${shell.slice(1)}`;
137
+ }
@@ -0,0 +1,125 @@
1
+ import { resolve } from "node:path";
2
+ import { usageError } from "../errors/cliError.js";
3
+ import { installCompletion, uninstallCompletion } from "./completionInstaller.js";
4
+ import { COMPLETION_SHELLS, activationBlock, activationIsAutomatic, currentCompletionShell, inspectCompletionStates, renderCompletionStateTable, shellQuote, suggestedCompletionInstallation } from "./completionState.js";
5
+ export async function runCompletionWizard(operation, store, env, identity, question, options = {}) {
6
+ const states = inspectCompletionStates(store.getConfig(), env, identity);
7
+ const current = currentCompletionShell(env);
8
+ let shell;
9
+ if (options.shell === undefined) {
10
+ const defaultSelection = options.defaultSelection ?? "current-shell";
11
+ const defaultHint = defaultSelection === "skip"
12
+ ? " [skip]"
13
+ : current === undefined ? "" : ` [${current}]`;
14
+ const skipHint = defaultSelection === "skip" ? "" : " (or skip)";
15
+ const answer = await question(`${renderCompletionStateTable(states, options.width)}\n`
16
+ + `Choose shell by number or name${defaultHint}${skipHint}: `);
17
+ const normalized = answer.trim().toLowerCase();
18
+ if (normalized === "skip" || (normalized.length === 0 && defaultSelection === "skip")) {
19
+ return `Completion ${operation} skipped.\n`;
20
+ }
21
+ shell = parseSelection(answer, current);
22
+ }
23
+ else {
24
+ shell = options.shell;
25
+ }
26
+ const state = states.find((candidate) => candidate.shell === shell);
27
+ if (state === undefined)
28
+ throw usageError("Completion shell selection is invalid.");
29
+ if (operation === "uninstall") {
30
+ if (state.installation === undefined) {
31
+ return `Completion ${shell} is not installed.\n`;
32
+ }
33
+ const confirm = normalize(await question(`Remove managed ${shell} completion?\n`
34
+ + `Script: ${state.installation.scriptPath}\n`
35
+ + `Activation: ${state.installation.activationPath}\n`
36
+ + "Remove now? [y/N]: "));
37
+ if (confirm !== "y" && confirm !== "yes") {
38
+ return `Completion ${shell} uninstall skipped.\n`;
39
+ }
40
+ uninstallCompletion(store, shell, identity);
41
+ return `Completion ${shell} uninstalled.\n`;
42
+ }
43
+ const suggested = state.installation
44
+ ?? suggestedCompletionInstallation(shell, env, identity);
45
+ const automatic = activationIsAutomatic(shell, suggested, env, identity);
46
+ const decision = normalize(await question(`Selected: ${shell} (${state.action})\n`
47
+ + `Script: ${suggested.scriptPath}\n`
48
+ + `Activation: ${suggested.activationPath}`
49
+ + `${automatic ? " (automatic)" : " (managed startup block)"}\n`
50
+ + "Install using these paths? [Y/n/customize]: "));
51
+ if (decision === "n" || decision === "no") {
52
+ return `Completion ${shell} installation skipped.\n`;
53
+ }
54
+ let installation = suggested;
55
+ let customized = false;
56
+ if (decision === "customize") {
57
+ const scriptPath = resolve((await question(`Completion script path [${suggested.scriptPath}]: `)).trim()
58
+ || suggested.scriptPath);
59
+ const activationPath = resolve((await question(`Activation file path [${suggested.activationPath}]: `)).trim()
60
+ || suggested.activationPath);
61
+ installation = { scriptPath, activationPath };
62
+ customized = true;
63
+ }
64
+ else if (decision !== "" && decision !== "y" && decision !== "yes") {
65
+ throw usageError("Choose Y, n, or customize.");
66
+ }
67
+ const needsActivation = !activationIsAutomatic(shell, installation, env, identity);
68
+ let activate = false;
69
+ let activationReady = !needsActivation;
70
+ if (needsActivation) {
71
+ const activationAnswer = normalize(await question(`${activationBlock(shell, installation, identity)}\n`
72
+ + `Update ${installation.activationPath} with the managed Yui block? [Y/n]: `));
73
+ if (activationAnswer !== ""
74
+ && activationAnswer !== "y"
75
+ && activationAnswer !== "yes"
76
+ && activationAnswer !== "n"
77
+ && activationAnswer !== "no") {
78
+ throw usageError("Choose Y or n.");
79
+ }
80
+ activate = activationAnswer === ""
81
+ || activationAnswer === "y"
82
+ || activationAnswer === "yes";
83
+ activationReady = activate || (!customized && state.status === "Installed");
84
+ }
85
+ installCompletion(store, shell, installation, env, identity, activate);
86
+ if (!activationReady) {
87
+ return `Completion ${shell} script installed; activation still required.\n`;
88
+ }
89
+ const result = `Completion ${shell} ${pastTense(state.action)}.\n`;
90
+ return needsActivation
91
+ ? `${result}${activationGuidance(shell, installation, env, identity)}`
92
+ : result;
93
+ }
94
+ function activationGuidance(shell, installation, env, identity) {
95
+ const unchanged = "The current shell is unchanged.\n";
96
+ const defaultActivationPath = suggestedCompletionInstallation(shell, env, identity).activationPath;
97
+ if (installation.activationPath !== defaultActivationPath) {
98
+ return `${unchanged}From a ${shell} session, load the custom activation file: source ${shellQuote(installation.activationPath)}\n`;
99
+ }
100
+ return currentCompletionShell(env) === shell
101
+ ? `${unchanged}Restart the current shell to activate completion: exec ${shell}\n`
102
+ : `${unchanged}Switch this terminal to ${shell} to activate completion: exec ${shell}\n`;
103
+ }
104
+ function parseSelection(value, current) {
105
+ const normalized = value.trim().toLowerCase();
106
+ if (normalized.length === 0 && current !== undefined)
107
+ return current;
108
+ if (/^[1-3]$/.test(normalized)) {
109
+ return COMPLETION_SHELLS[Number.parseInt(normalized, 10) - 1];
110
+ }
111
+ if (COMPLETION_SHELLS.includes(normalized)) {
112
+ return normalized;
113
+ }
114
+ throw usageError("Choose Bash, Zsh, or Fish by number or name.");
115
+ }
116
+ function pastTense(action) {
117
+ switch (action) {
118
+ case "Install": return "installed";
119
+ case "Refresh": return "refreshed";
120
+ case "Repair": return "repaired";
121
+ }
122
+ }
123
+ function normalize(value) {
124
+ return value?.trim().toLowerCase() ?? "";
125
+ }
@@ -0,0 +1,51 @@
1
+ import { installCompletion } from "./completionInstaller.js";
2
+ import { COMPLETION_SHELLS, completionActivationIsCurrent, completionScriptIsCurrent, currentCompletionShell, suggestedCompletionInstallation } from "./completionState.js";
3
+ /** FileTaskStore-backed completion port used by the interactive CLI wizard. */
4
+ export class FileCompletionManager {
5
+ #store;
6
+ #env;
7
+ #identity;
8
+ constructor(store, env, identity) {
9
+ this.#store = store;
10
+ this.#env = { ...env };
11
+ this.#identity = identity;
12
+ }
13
+ inspect() {
14
+ const config = this.#store.getConfig();
15
+ const currentShell = currentCompletionShell(this.#env);
16
+ const states = COMPLETION_SHELLS.map((shell) => {
17
+ const suggested = suggestedCompletionInstallation(shell, this.#env, this.#identity);
18
+ const stored = config.completionInstallations?.[shell];
19
+ const installation = stored ?? suggested;
20
+ const scriptCurrent = completionScriptIsCurrent(shell, installation, this.#identity);
21
+ const activationCurrent = completionActivationIsCurrent(shell, installation, this.#env, this.#identity);
22
+ const status = scriptCurrent && activationCurrent
23
+ ? "Installed"
24
+ : stored === undefined ? "Not installed" : "Needs repair";
25
+ const action = status === "Installed"
26
+ ? "Refresh"
27
+ : status === "Needs repair" ? "Repair" : "Install";
28
+ return Object.freeze({
29
+ shell,
30
+ status,
31
+ action,
32
+ current: shell === currentShell,
33
+ configured: stored !== undefined,
34
+ activationCurrent,
35
+ installation,
36
+ suggested
37
+ });
38
+ });
39
+ return Object.freeze({
40
+ identity: this.#identity,
41
+ ...(currentShell === undefined ? {} : { currentShell }),
42
+ states: Object.freeze(states)
43
+ });
44
+ }
45
+ install(request) {
46
+ installCompletion(this.#store, request.shell, request.installation, this.#env, this.#identity, request.activate);
47
+ }
48
+ }
49
+ export function resolveCliIdentity(env) {
50
+ return env.YUI_CLI_NAME === "yui-dev" ? "yui-dev" : "yui";
51
+ }
@@ -0,0 +1,17 @@
1
+ export const DEFAULT_RECONCILIATION_INTERVAL_SECONDS = 30;
2
+ export const MIN_RECONCILIATION_INTERVAL_SECONDS = 5;
3
+ export const MAX_RECONCILIATION_INTERVAL_SECONDS = 300;
4
+ /**
5
+ * Resolves the durable Yui setting used for periodic full reconciliation.
6
+ * Command-triggered scans remain immediate and do not use this interval.
7
+ */
8
+ export function reconciliationIntervalMilliseconds(value) {
9
+ const seconds = value ?? DEFAULT_RECONCILIATION_INTERVAL_SECONDS;
10
+ if (typeof seconds !== "number"
11
+ || !Number.isSafeInteger(seconds)
12
+ || seconds < MIN_RECONCILIATION_INTERVAL_SECONDS
13
+ || seconds > MAX_RECONCILIATION_INTERVAL_SECONDS) {
14
+ throw new TypeError("reconciliationIntervalSeconds must be an integer from 5 to 300.");
15
+ }
16
+ return seconds * 1_000;
17
+ }