@fitlab-ai/agent-infra 0.7.6 → 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.
Files changed (173) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/bin/cli.ts +53 -18
  4. package/dist/bin/cli.js +56 -19
  5. package/dist/lib/decide.js +110 -0
  6. package/dist/lib/run/host.js +39 -0
  7. package/dist/lib/run/index.js +102 -0
  8. package/dist/lib/run/prompt.js +2 -0
  9. package/dist/lib/run/skills.js +29 -0
  10. package/dist/lib/run/tui.js +35 -0
  11. package/dist/lib/sandbox/capture.js +49 -0
  12. package/dist/lib/sandbox/commands/create.js +55 -6
  13. package/dist/lib/sandbox/commands/ls.js +4 -33
  14. package/dist/lib/sandbox/commands/show.js +67 -0
  15. package/dist/lib/sandbox/index.js +7 -0
  16. package/dist/lib/server/adapters/_contract.js +9 -0
  17. package/dist/lib/server/adapters/feishu/index.js +40 -0
  18. package/dist/lib/server/adapters/feishu/transport.js +95 -0
  19. package/dist/lib/server/auth.js +19 -0
  20. package/dist/lib/server/config.js +183 -0
  21. package/dist/lib/server/daemon.js +96 -0
  22. package/dist/lib/server/index.js +47 -0
  23. package/dist/lib/server/logger.js +34 -0
  24. package/dist/lib/server/plugin-loader.js +73 -0
  25. package/dist/lib/server/process-control.js +200 -0
  26. package/dist/lib/server/protocol.js +69 -0
  27. package/dist/lib/server/redact.js +12 -0
  28. package/dist/lib/server/runner.js +75 -0
  29. package/dist/lib/server/streamer.js +44 -0
  30. package/dist/lib/task/commands/decisions.js +258 -0
  31. package/dist/lib/task/commands/issue-body.js +94 -0
  32. package/dist/lib/task/commands/log.js +67 -9
  33. package/dist/lib/task/index.js +17 -0
  34. package/dist/lib/task/issue-form.js +66 -0
  35. package/dist/lib/task/ledger.js +66 -0
  36. package/dist/lib/task/sections.js +73 -0
  37. package/lib/decide.ts +124 -0
  38. package/lib/run/host.ts +47 -0
  39. package/lib/run/index.ts +124 -0
  40. package/lib/run/prompt.ts +1 -0
  41. package/lib/run/skills.ts +34 -0
  42. package/lib/run/tui.ts +40 -0
  43. package/lib/sandbox/capture.ts +84 -0
  44. package/lib/sandbox/commands/create.ts +61 -9
  45. package/lib/sandbox/commands/ls.ts +4 -36
  46. package/lib/sandbox/commands/show.ts +80 -0
  47. package/lib/sandbox/index.ts +7 -0
  48. package/lib/server/adapters/_contract.ts +42 -0
  49. package/lib/server/adapters/feishu/index.ts +47 -0
  50. package/lib/server/adapters/feishu/transport.ts +128 -0
  51. package/lib/server/auth.ts +28 -0
  52. package/lib/server/config.ts +228 -0
  53. package/lib/server/daemon.ts +110 -0
  54. package/lib/server/index.ts +50 -0
  55. package/lib/server/logger.ts +48 -0
  56. package/lib/server/plugin-loader.ts +84 -0
  57. package/lib/server/process-control.ts +225 -0
  58. package/lib/server/protocol.ts +79 -0
  59. package/lib/server/redact.ts +12 -0
  60. package/lib/server/runner.ts +95 -0
  61. package/lib/server/server.schema.json +127 -0
  62. package/lib/server/streamer.ts +57 -0
  63. package/lib/task/commands/decisions.ts +272 -0
  64. package/lib/task/commands/issue-body.ts +102 -0
  65. package/lib/task/commands/log.ts +73 -9
  66. package/lib/task/index.ts +17 -0
  67. package/lib/task/issue-form.ts +77 -0
  68. package/lib/task/ledger.ts +73 -0
  69. package/lib/task/sections.ts +73 -0
  70. package/package.json +9 -4
  71. package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
  72. package/templates/.agents/README.en.md +3 -1
  73. package/templates/.agents/README.zh-CN.md +3 -1
  74. package/templates/.agents/rules/create-issue.github.en.md +20 -29
  75. package/templates/.agents/rules/create-issue.github.zh-CN.md +20 -29
  76. package/templates/.agents/rules/issue-fields.github.en.md +1 -1
  77. package/templates/.agents/rules/issue-fields.github.zh-CN.md +1 -1
  78. package/templates/.agents/rules/issue-pr-commands.github.en.md +6 -0
  79. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +6 -0
  80. package/templates/.agents/rules/next-step-output.en.md +30 -1
  81. package/templates/.agents/rules/next-step-output.zh-CN.md +30 -1
  82. package/templates/.agents/rules/no-mid-flow-questions.en.md +7 -2
  83. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +7 -2
  84. package/templates/.agents/rules/review-handshake.en.md +5 -1
  85. package/templates/.agents/rules/review-handshake.zh-CN.md +5 -1
  86. package/templates/.agents/rules/task-management.en.md +3 -1
  87. package/templates/.agents/rules/task-management.zh-CN.md +3 -1
  88. package/templates/.agents/scripts/lib/post-review-commit.js +15 -18
  89. package/templates/.agents/scripts/validate-artifact.js +8 -0
  90. package/templates/.agents/skills/analyze-task/SKILL.en.md +18 -4
  91. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +18 -4
  92. package/templates/.agents/skills/analyze-task/config/verify.en.json +2 -1
  93. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +2 -1
  94. package/templates/.agents/skills/archive-tasks/SKILL.en.md +3 -1
  95. package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +3 -1
  96. package/templates/.agents/skills/block-task/SKILL.en.md +3 -1
  97. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +3 -1
  98. package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -1
  99. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -1
  100. package/templates/.agents/skills/check-task/SKILL.en.md +30 -81
  101. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +30 -80
  102. package/templates/.agents/skills/close-codescan/SKILL.en.md +3 -1
  103. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +3 -1
  104. package/templates/.agents/skills/close-dependabot/SKILL.en.md +3 -1
  105. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +3 -1
  106. package/templates/.agents/skills/code-task/SKILL.en.md +4 -2
  107. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +4 -2
  108. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +7 -2
  109. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +7 -2
  110. package/templates/.agents/skills/code-task/scripts/detect-mode.js +6 -14
  111. package/templates/.agents/skills/commit/SKILL.en.md +27 -5
  112. package/templates/.agents/skills/commit/SKILL.zh-CN.md +27 -5
  113. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -1
  114. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -1
  115. package/templates/.agents/skills/complete-task/config/verify.en.json +2 -1
  116. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -1
  117. package/templates/.agents/skills/create-pr/SKILL.en.md +3 -1
  118. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +3 -1
  119. package/templates/.agents/skills/create-release-note/SKILL.en.md +3 -1
  120. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +3 -1
  121. package/templates/.agents/skills/create-task/SKILL.en.md +4 -4
  122. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +4 -4
  123. package/templates/.agents/skills/import-codescan/SKILL.en.md +3 -4
  124. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +3 -4
  125. package/templates/.agents/skills/import-dependabot/SKILL.en.md +3 -2
  126. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +3 -2
  127. package/templates/.agents/skills/import-issue/SKILL.en.md +20 -4
  128. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +20 -4
  129. package/templates/.agents/skills/init-labels/SKILL.en.md +3 -1
  130. package/templates/.agents/skills/init-labels/SKILL.zh-CN.md +3 -1
  131. package/templates/.agents/skills/init-milestones/SKILL.en.md +3 -1
  132. package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +3 -1
  133. package/templates/.agents/skills/plan-task/SKILL.en.md +4 -3
  134. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -3
  135. package/templates/.agents/skills/post-release/SKILL.en.md +3 -1
  136. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +3 -1
  137. package/templates/.agents/skills/refine-title/SKILL.en.md +3 -1
  138. package/templates/.agents/skills/refine-title/SKILL.zh-CN.md +3 -1
  139. package/templates/.agents/skills/release/SKILL.en.md +3 -1
  140. package/templates/.agents/skills/release/SKILL.zh-CN.md +3 -1
  141. package/templates/.agents/skills/restore-task/SKILL.en.md +3 -1
  142. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +3 -1
  143. package/templates/.agents/skills/review-analysis/SKILL.en.md +6 -2
  144. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +6 -2
  145. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +1 -1
  146. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +1 -1
  147. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +1 -1
  148. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +1 -2
  149. package/templates/.agents/skills/review-code/SKILL.en.md +5 -2
  150. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +5 -2
  151. package/templates/.agents/skills/review-code/reference/output-templates.en.md +1 -1
  152. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +1 -1
  153. package/templates/.agents/skills/review-code/reference/report-template.en.md +1 -1
  154. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +1 -2
  155. package/templates/.agents/skills/review-plan/SKILL.en.md +6 -2
  156. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +6 -2
  157. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +1 -1
  158. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +1 -1
  159. package/templates/.agents/skills/review-plan/reference/report-template.en.md +1 -1
  160. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +1 -2
  161. package/templates/.agents/skills/test/SKILL.en.md +3 -1
  162. package/templates/.agents/skills/test/SKILL.zh-CN.md +1 -1
  163. package/templates/.agents/skills/test-integration/SKILL.en.md +3 -1
  164. package/templates/.agents/skills/test-integration/SKILL.zh-CN.md +3 -1
  165. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +3 -1
  166. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +3 -1
  167. package/templates/.agents/skills/upgrade-dependency/SKILL.en.md +3 -1
  168. package/templates/.agents/skills/upgrade-dependency/SKILL.zh-CN.md +3 -1
  169. package/templates/.agents/skills/watch-pr/SKILL.en.md +3 -1
  170. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +3 -1
  171. package/templates/.agents/templates/task.en.md +3 -3
  172. package/templates/.agents/templates/task.zh-CN.md +3 -3
  173. package/templates/.github/workflows/metadata-sync.yml +0 -18
@@ -0,0 +1,73 @@
1
+ function escapeRegExp(value) {
2
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3
+ }
4
+ /**
5
+ * Return the body of the first `## {alias}` section (any alias matches), from
6
+ * the heading line to the next `## ` heading or EOF. Lines are preserved
7
+ * verbatim (checkbox text is never normalized); only leading/trailing blank
8
+ * lines are trimmed. Returns '' when no alias heading is present.
9
+ */
10
+ function extractSection(content, aliases) {
11
+ const lines = content.split('\n');
12
+ let start = -1;
13
+ for (let i = 0; i < lines.length; i += 1) {
14
+ const line = lines[i].trim();
15
+ if (aliases.some((alias) => new RegExp(`^##\\s+${escapeRegExp(alias)}\\s*$`).test(line))) {
16
+ start = i + 1;
17
+ break;
18
+ }
19
+ }
20
+ if (start === -1)
21
+ return '';
22
+ let end = lines.length;
23
+ for (let i = start; i < lines.length; i += 1) {
24
+ if (/^##\s+/.test(lines[i])) {
25
+ end = i;
26
+ break;
27
+ }
28
+ }
29
+ return lines.slice(start, end).join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
30
+ }
31
+ /**
32
+ * Return the heading alias that actually appears as a `## {alias}` line, so a
33
+ * rendered section can mirror the source language. Falls back to the first
34
+ * alias when none is present.
35
+ */
36
+ function findSectionHeading(content, aliases) {
37
+ for (const alias of aliases) {
38
+ if (new RegExp(`^##\\s+${escapeRegExp(alias)}\\s*$`, 'm').test(content))
39
+ return alias;
40
+ }
41
+ return aliases[0];
42
+ }
43
+ /**
44
+ * Return the body of the first `### {headingPrefix}` sub-section, from the
45
+ * heading line (inclusive) to the next `### ` / `## ` heading or EOF. Used to
46
+ * pull a single `### HD-N` human-decision detail block out of an artifact. The
47
+ * prefix must be followed by a word boundary so `HD-1` does not match `HD-10`
48
+ * (e.g. `### HD-1`, `### HD-1:标题`, `### HD-1 [needs-human-decision]`). Leading
49
+ * and trailing blank lines are trimmed. Returns '' when no match is present.
50
+ */
51
+ function extractSubSection(content, headingPrefix) {
52
+ const lines = content.split('\n');
53
+ const headRe = new RegExp(`^###\\s+${escapeRegExp(headingPrefix)}(?![\\w-])`);
54
+ let start = -1;
55
+ for (let i = 0; i < lines.length; i += 1) {
56
+ if (headRe.test(lines[i].trim())) {
57
+ start = i;
58
+ break;
59
+ }
60
+ }
61
+ if (start === -1)
62
+ return '';
63
+ let end = lines.length;
64
+ for (let i = start + 1; i < lines.length; i += 1) {
65
+ if (/^###?\s+/.test(lines[i])) {
66
+ end = i;
67
+ break;
68
+ }
69
+ }
70
+ return lines.slice(start, end).join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
71
+ }
72
+ export { extractSection, findSectionHeading, extractSubSection };
73
+ //# sourceMappingURL=sections.js.map
package/lib/decide.ts ADDED
@@ -0,0 +1,124 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { VERSION } from './version.ts';
5
+ import { resolveTaskRef } from './task/resolve-ref.ts';
6
+
7
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
8
+
9
+ type DecideOptions = {
10
+ repoRoot?: string;
11
+ now?: () => string;
12
+ version?: string;
13
+ };
14
+
15
+ function detectRepoRoot(): string {
16
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
17
+ encoding: 'utf8',
18
+ stdio: ['pipe', 'pipe', 'pipe']
19
+ }).trim();
20
+ }
21
+
22
+ function defaultNow(): string {
23
+ return new Intl.DateTimeFormat('sv-SE', {
24
+ timeZoneName: 'longOffset',
25
+ year: 'numeric',
26
+ month: '2-digit',
27
+ day: '2-digit',
28
+ hour: '2-digit',
29
+ minute: '2-digit',
30
+ second: '2-digit',
31
+ hour12: false
32
+ })
33
+ .format(new Date())
34
+ .replace(' GMT', '');
35
+ }
36
+
37
+ function taskPath(repoRoot: string, ref: string): string {
38
+ if (!TASK_ID_RE.test(ref)) {
39
+ const resolved = resolveTaskRef(ref);
40
+ if (!resolved.ok) throw new Error(resolved.message);
41
+ if (!resolved.taskDir.includes(`${path.join('.agents', 'workspace', 'active')}${path.sep}`)) {
42
+ throw new Error(`task ${resolved.taskId} is not active`);
43
+ }
44
+ return resolved.taskMdPath;
45
+ }
46
+ const candidate = path.join(repoRoot, '.agents', 'workspace', 'active', ref, 'task.md');
47
+ if (!fs.existsSync(candidate)) throw new Error(`active task not found: ${ref}`);
48
+ return candidate;
49
+ }
50
+
51
+ function replaceFrontmatterField(content: string, field: string, value: string): string {
52
+ const re = new RegExp(`^${field}:.*$`, 'm');
53
+ if (re.test(content)) return content.replace(re, `${field}: ${value}`);
54
+ return content.replace(/^---\n/, `---\n${field}: ${value}\n`);
55
+ }
56
+
57
+ function replaceLedgerRow(content: string, hdId: string): { content: string; found: boolean; pending: boolean } {
58
+ const lines = content.split('\n');
59
+ let found = false;
60
+ let pending = false;
61
+ for (let i = 0; i < lines.length; i += 1) {
62
+ const line = lines[i] as string;
63
+ if (!line.trim().startsWith(`| ${hdId} |`)) continue;
64
+ found = true;
65
+ const cells = line.split('|').slice(1, -1).map((cell) => cell.trim());
66
+ if (cells[4] !== 'needs-human-decision') break;
67
+ pending = true;
68
+ cells[4] = 'human-decided';
69
+ cells[5] = `task.md#${hdId}`;
70
+ lines[i] = `| ${cells.join(' | ')} |`;
71
+ break;
72
+ }
73
+ return { content: lines.join('\n'), found, pending };
74
+ }
75
+
76
+ function appendUnderHeading(content: string, heading: string, block: string): string {
77
+ if (!content.includes(`${heading}\n`)) {
78
+ return `${content.trimEnd()}\n\n${heading}\n\n${block}\n`;
79
+ }
80
+ const idx = content.indexOf(`${heading}\n`) + heading.length + 1;
81
+ const before = content.slice(0, idx);
82
+ const after = content.slice(idx);
83
+ return `${before}\n${block}\n${after.replace(/^\n/, '')}`;
84
+ }
85
+
86
+ export async function decide(args: string[], options: DecideOptions = {}): Promise<number> {
87
+ const [taskRef, hdId, ...decisionParts] = args;
88
+ if (!taskRef || !hdId || decisionParts.length === 0) {
89
+ process.stderr.write('Usage: ai decide <task-ref> <HD-id> <decision>\n');
90
+ return 1;
91
+ }
92
+ try {
93
+ const repoRoot = options.repoRoot ?? detectRepoRoot();
94
+ const file = taskPath(repoRoot, taskRef);
95
+ let content = fs.readFileSync(file, 'utf8');
96
+ const replaced = replaceLedgerRow(content, hdId);
97
+ if (!replaced.found) throw new Error(`${hdId} not found in review ledger`);
98
+ if (!replaced.pending) throw new Error(`${hdId} is not needs-human-decision`);
99
+ content = replaced.content;
100
+ const now = (options.now ?? defaultNow)();
101
+ content = replaceFrontmatterField(content, 'updated_at', now);
102
+ content = replaceFrontmatterField(content, 'agent_infra_version', options.version ?? VERSION);
103
+ const decision = decisionParts.join(' ');
104
+ content = appendUnderHeading(
105
+ content,
106
+ '## 人工裁决',
107
+ `### ${hdId}\n\n- **裁决时间**:${now}\n- **裁决结果**:${decision}`
108
+ );
109
+ content = appendUnderHeading(
110
+ content,
111
+ '## 活动日志',
112
+ `- ${now} — **Human Decision** by human — ${hdId} decided`
113
+ );
114
+ fs.writeFileSync(file, content);
115
+ return 0;
116
+ } catch (error) {
117
+ process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
118
+ return 1;
119
+ }
120
+ }
121
+
122
+ export async function cmdDecide(args: string[]): Promise<void> {
123
+ process.exitCode = await decide(args);
124
+ }
@@ -0,0 +1,47 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export type RunProcessResult = {
6
+ exitCode: number | null;
7
+ signal?: NodeJS.Signals | null;
8
+ stdout?: string;
9
+ stderr?: string;
10
+ };
11
+
12
+ function resolveCommand(file: string): string {
13
+ if (process.platform !== 'win32' || path.extname(file)) {
14
+ return file;
15
+ }
16
+
17
+ const pathValue = process.env.Path || process.env.PATH || '';
18
+ const extensions = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
19
+ for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
20
+ for (const extension of extensions) {
21
+ const lowerCandidate = path.join(dir, `${file}${extension.toLowerCase()}`);
22
+ if (fs.existsSync(lowerCandidate)) return lowerCandidate;
23
+ const upperCandidate = path.join(dir, `${file}${extension.toUpperCase()}`);
24
+ if (fs.existsSync(upperCandidate)) return upperCandidate;
25
+ }
26
+ }
27
+
28
+ return file;
29
+ }
30
+
31
+ function needsShell(file: string): boolean {
32
+ return process.platform === 'win32' && /\.(?:bat|cmd)$/i.test(file);
33
+ }
34
+
35
+ export async function runHostCommand(command: string[]): Promise<RunProcessResult> {
36
+ const [file, ...args] = command;
37
+ if (!file) throw new Error('run: missing command');
38
+ return new Promise((resolve, reject) => {
39
+ const resolvedFile = resolveCommand(file);
40
+ const child = spawn(resolvedFile, args, {
41
+ stdio: ['ignore', 'inherit', 'inherit'],
42
+ shell: needsShell(resolvedFile)
43
+ });
44
+ child.on('error', reject);
45
+ child.on('close', (exitCode, signal) => resolve({ exitCode, signal }));
46
+ });
47
+ }
@@ -0,0 +1,124 @@
1
+ import { loadServerConfig } from '../server/config.ts';
2
+ import { resolveTaskBranch } from '../sandbox/task-resolver.ts';
3
+ import { runInSandbox } from '../sandbox/capture.ts';
4
+ import { buildTuiCommand, renderPrompt, selectTui } from './tui.ts';
5
+ import { getSkillRunSpec } from './skills.ts';
6
+ import { runHostCommand, type RunProcessResult } from './host.ts';
7
+
8
+ export type ParsedRunArgs = {
9
+ skill: string;
10
+ taskRef: string | null;
11
+ args: string[];
12
+ tui: string | null;
13
+ };
14
+
15
+ export type SandboxRunRequest = {
16
+ taskRef: string;
17
+ branch: string;
18
+ command: string[];
19
+ };
20
+
21
+ export type RunSkillOptions = {
22
+ command?: Record<string, unknown>;
23
+ repoRoot?: string;
24
+ runHost?: (command: string[]) => Promise<RunProcessResult>;
25
+ runSandbox?: (request: SandboxRunRequest) => Promise<RunProcessResult>;
26
+ writeStdout?: (chunk: string) => void;
27
+ writeStderr?: (chunk: string) => void;
28
+ };
29
+
30
+ const USAGE = `Usage: ai run <skill> [task-ref] [args...] [--tui <name>]
31
+
32
+ Examples:
33
+ ai run create-task "describe the task" --tui codex
34
+ ai run code-task #7 --tui codex`;
35
+
36
+ function extractTui(args: string[]): { rest: string[]; tui: string | null } {
37
+ const rest: string[] = [];
38
+ let tui: string | null = null;
39
+ for (let i = 0; i < args.length; i += 1) {
40
+ const arg = args[i] as string;
41
+ if (arg === '--tui') {
42
+ const value = args[i + 1];
43
+ if (!value) throw new Error('--tui requires a value');
44
+ tui = value;
45
+ i += 1;
46
+ continue;
47
+ }
48
+ rest.push(arg);
49
+ }
50
+ return { rest, tui };
51
+ }
52
+
53
+ export function parseRunArgs(args: string[]): ParsedRunArgs {
54
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
55
+ throw new Error(USAGE);
56
+ }
57
+ const { rest, tui } = extractTui(args);
58
+ const [skill = '', maybeTaskRef, ...remaining] = rest;
59
+ const spec = getSkillRunSpec(skill);
60
+ if (!spec) throw new Error(`Unknown skill '${skill}'`);
61
+ if (spec.kind === 'create') {
62
+ const createArgs = rest.slice(1);
63
+ if (createArgs.length === 0) throw new Error('create-task requires a description');
64
+ return { skill, taskRef: null, args: createArgs, tui };
65
+ }
66
+ if (!maybeTaskRef) throw new Error(`${skill} requires a task-ref`);
67
+ return { skill, taskRef: maybeTaskRef, args: remaining, tui };
68
+ }
69
+
70
+ function assertAllowedByConfig(skill: string, commandConfig: Record<string, unknown>): void {
71
+ const allowed = commandConfig.allowedSkills;
72
+ if (!Array.isArray(allowed)) return;
73
+ if (!allowed.every((entry) => typeof entry === 'string')) {
74
+ throw new Error('command.allowedSkills must be an array of skill names');
75
+ }
76
+ if (!allowed.includes(skill)) {
77
+ throw new Error(`Skill '${skill}' is not allowed by command.allowedSkills`);
78
+ }
79
+ }
80
+
81
+ export async function runSkill(args: string[], options: RunSkillOptions = {}): Promise<number> {
82
+ const parsed = parseRunArgs(args);
83
+ const config = options.command ? null : loadServerConfig({ rootDir: options.repoRoot });
84
+ const commandConfig = options.command ?? config?.command ?? {};
85
+ assertAllowedByConfig(parsed.skill, commandConfig);
86
+ const tui = selectTui(parsed.skill, { cliTui: parsed.tui, command: commandConfig });
87
+ const promptArgs = parsed.taskRef === null ? parsed.args : [parsed.taskRef, ...parsed.args];
88
+ const prompt = renderPrompt({ tui, skill: parsed.skill, args: promptArgs });
89
+ const [file, argv] = buildTuiCommand(tui, prompt);
90
+ const command = [file, ...argv];
91
+
92
+ if (parsed.taskRef === null) {
93
+ const result = await (options.runHost ?? runHostCommand)(command);
94
+ return result.exitCode ?? (result.signal ? 1 : 0);
95
+ }
96
+
97
+ const repoRoot = options.repoRoot ?? config?.repoRoot ?? process.cwd();
98
+ const branch = resolveTaskBranch(parsed.taskRef, repoRoot);
99
+ const runSandbox = options.runSandbox ?? ((request: SandboxRunRequest) => runInSandbox(request));
100
+ const result = await runSandbox({ taskRef: parsed.taskRef, branch, command });
101
+ if (result.stdout) {
102
+ (options.writeStdout ?? ((chunk: string) => process.stdout.write(chunk)))(result.stdout);
103
+ }
104
+ if (result.stderr) {
105
+ (options.writeStderr ?? ((chunk: string) => process.stderr.write(chunk)))(result.stderr);
106
+ }
107
+ return result.exitCode ?? (result.signal ? 1 : 0);
108
+ }
109
+
110
+ export async function cmdRun(args: string[]): Promise<void> {
111
+ try {
112
+ const code = await runSkill(args);
113
+ process.exitCode = code;
114
+ } catch (error) {
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ if (message.startsWith('Usage:')) {
117
+ process.stdout.write(`${message}\n`);
118
+ process.exitCode = args.length === 0 ? 1 : 0;
119
+ } else {
120
+ process.stderr.write(`Error: ${message}\n`);
121
+ process.exitCode = 1;
122
+ }
123
+ }
124
+ }
@@ -0,0 +1 @@
1
+ export { renderPrompt } from './tui.ts';
@@ -0,0 +1,34 @@
1
+ export type SkillRunSpec =
2
+ | { kind: 'task'; skill: string; role: 'exec'; requiresSandbox: true }
3
+ | { kind: 'create'; skill: 'create-task'; role: 'exec'; requiresSandbox: false };
4
+
5
+ const TASK_SKILLS = new Set([
6
+ 'analyze-task',
7
+ 'block-task',
8
+ 'cancel-task',
9
+ 'code-task',
10
+ 'commit',
11
+ 'complete-task',
12
+ 'create-pr',
13
+ 'plan-task',
14
+ 'review-analysis',
15
+ 'review-code',
16
+ 'review-plan',
17
+ 'test',
18
+ 'test-integration',
19
+ 'watch-pr'
20
+ ]);
21
+
22
+ export function getSkillRunSpec(skill: string): SkillRunSpec | null {
23
+ if (skill === 'create-task') {
24
+ return { kind: 'create', skill: 'create-task', role: 'exec', requiresSandbox: false };
25
+ }
26
+ if (TASK_SKILLS.has(skill)) {
27
+ return { kind: 'task', skill, role: 'exec', requiresSandbox: true };
28
+ }
29
+ return null;
30
+ }
31
+
32
+ export function allowedSkillNames(): string[] {
33
+ return ['create-task', ...TASK_SKILLS].sort();
34
+ }
package/lib/run/tui.ts ADDED
@@ -0,0 +1,40 @@
1
+ export type TuiName = 'claude' | 'codex' | 'gemini' | 'opencode';
2
+
3
+ const TUI_NAMES = new Set(['claude', 'codex', 'gemini', 'opencode']);
4
+
5
+ export type CommandConfig = {
6
+ defaultTui?: unknown;
7
+ skillTuiDefaults?: unknown;
8
+ };
9
+
10
+ function isTuiName(value: unknown): value is TuiName {
11
+ return typeof value === 'string' && TUI_NAMES.has(value);
12
+ }
13
+
14
+ export function selectTui(
15
+ skill: string,
16
+ options: { cliTui?: string | null; command?: CommandConfig }
17
+ ): TuiName {
18
+ if (isTuiName(options.cliTui)) return options.cliTui;
19
+ const defaults = options.command?.skillTuiDefaults;
20
+ if (defaults && typeof defaults === 'object' && !Array.isArray(defaults)) {
21
+ const value = (defaults as Record<string, unknown>)[skill];
22
+ if (isTuiName(value)) return value;
23
+ }
24
+ if (isTuiName(options.command?.defaultTui)) return options.command.defaultTui;
25
+ return 'codex';
26
+ }
27
+
28
+ export function renderPrompt(params: { tui: TuiName; skill: string; args: string[] }): string {
29
+ const suffix = [params.skill, ...params.args].join(' ').trim();
30
+ if (params.tui === 'codex') return `$${suffix}`;
31
+ if (params.tui === 'gemini') return `/agent-infra:${suffix}`;
32
+ return `/${suffix}`;
33
+ }
34
+
35
+ export function buildTuiCommand(tui: TuiName, prompt: string): [string, string[]] {
36
+ if (tui === 'claude') return ['claude', ['--dangerously-skip-permissions', '--print', prompt]];
37
+ if (tui === 'gemini') return ['gemini', ['--approval-mode', 'yolo', '--prompt', prompt]];
38
+ if (tui === 'opencode') return ['opencode', ['run', '--dangerously-skip-permissions', prompt]];
39
+ return ['codex', ['exec', '--dangerously-bypass-approvals-and-sandbox', prompt]];
40
+ }
@@ -0,0 +1,84 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { loadConfig } from './config.ts';
3
+ import { containerNameCandidates, sandboxBranchLabel, sandboxLabel } from './constants.ts';
4
+ import { detectEngine } from './engine.ts';
5
+ import { hostTimezoneEnvFlags, terminalEnvFlags } from './commands/enter.ts';
6
+ import {
7
+ fetchSandboxRows,
8
+ selectSandboxContainer,
9
+ startSandboxContainer,
10
+ type SandboxRow
11
+ } from './commands/list-running.ts';
12
+
13
+ export type SandboxCaptureRequest = {
14
+ taskRef: string;
15
+ branch: string;
16
+ command: string[];
17
+ timeoutMs?: number;
18
+ };
19
+
20
+ export type SandboxCaptureResult = {
21
+ exitCode: number | null;
22
+ signal: NodeJS.Signals | null;
23
+ stdout: string;
24
+ stderr: string;
25
+ };
26
+
27
+ export type SandboxCaptureOptions = {
28
+ engine?: string;
29
+ repoRoot?: string;
30
+ containerCandidates?: string[];
31
+ rows?: SandboxRow[];
32
+ startContainer?: (name: string) => void;
33
+ spawn?: (file: string, args: string[]) => Promise<SandboxCaptureResult>;
34
+ };
35
+
36
+ async function spawnCapture(file: string, args: string[]): Promise<SandboxCaptureResult> {
37
+ return new Promise((resolve, reject) => {
38
+ const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'pipe'] });
39
+ let stdout = '';
40
+ let stderr = '';
41
+ child.stdout.setEncoding('utf8');
42
+ child.stderr.setEncoding('utf8');
43
+ child.stdout.on('data', (chunk) => {
44
+ stdout += chunk;
45
+ });
46
+ child.stderr.on('data', (chunk) => {
47
+ stderr += chunk;
48
+ });
49
+ child.on('error', reject);
50
+ child.on('close', (exitCode, signal) => resolve({ exitCode, signal, stdout, stderr }));
51
+ });
52
+ }
53
+
54
+ export async function runInSandbox(
55
+ request: SandboxCaptureRequest,
56
+ options: SandboxCaptureOptions = {}
57
+ ): Promise<SandboxCaptureResult> {
58
+ const config = options.engine ? null : loadConfig();
59
+ const engine = options.engine ?? detectEngine(config!);
60
+ const rows =
61
+ options.rows ??
62
+ (() => {
63
+ const fetched = fetchSandboxRows(engine, sandboxLabel(config!), sandboxBranchLabel(config!));
64
+ return [...fetched.running, ...fetched.nonRunning];
65
+ })();
66
+ const candidates = options.containerCandidates ?? containerNameCandidates(config!, request.branch);
67
+ const found = selectSandboxContainer(rows, candidates);
68
+ if (!found) {
69
+ throw new Error(
70
+ `Sandbox for ${request.branch} not found. Create it first with ai sandbox create ${request.taskRef}.`
71
+ );
72
+ }
73
+ if (!found.running) {
74
+ (options.startContainer ?? ((name: string) => startSandboxContainer(engine, name)))(found.name);
75
+ }
76
+ const dockerArgs = [
77
+ 'exec',
78
+ ...terminalEnvFlags(),
79
+ ...hostTimezoneEnvFlags(),
80
+ found.name,
81
+ ...request.command
82
+ ];
83
+ return (options.spawn ?? spawnCapture)('docker', dockerArgs);
84
+ }
@@ -740,6 +740,63 @@ function readHostJsonSafe(filePath: string): JsonObject | null {
740
740
  }
741
741
  }
742
742
 
743
+ const CLAUDE_SETTINGS_INHERIT_TOP_LEVEL_KEYS = [
744
+ 'model',
745
+ 'fallbackModel',
746
+ 'availableModels',
747
+ 'modelOverrides',
748
+ 'enforceAvailableModels',
749
+ 'advisorModel',
750
+ 'apiKeyHelper',
751
+ 'effortLevel'
752
+ ];
753
+
754
+ function isJsonObjectRecord(value: unknown): value is JsonObject {
755
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
756
+ }
757
+
758
+ function mergeMissingStringEnvFields(target: JsonObject, source: JsonObject): boolean {
759
+ if (!isJsonObjectRecord(source.env)) {
760
+ return false;
761
+ }
762
+ if (Object.hasOwn(target, 'env') && !isJsonObjectRecord(target.env)) {
763
+ return false;
764
+ }
765
+
766
+ let targetEnv = target.env as JsonObject | undefined;
767
+ let changed = false;
768
+ for (const [key, value] of Object.entries(source.env)) {
769
+ if (typeof value !== 'string' || value === '') {
770
+ continue;
771
+ }
772
+ if (!targetEnv) {
773
+ targetEnv = {};
774
+ target.env = targetEnv;
775
+ }
776
+ if (!Object.hasOwn(targetEnv, key)) {
777
+ targetEnv[key] = value;
778
+ changed = true;
779
+ }
780
+ }
781
+ return changed;
782
+ }
783
+
784
+ function mergeMissingTopLevelSettings(target: JsonObject, source: JsonObject): boolean {
785
+ let changed = false;
786
+ for (const key of CLAUDE_SETTINGS_INHERIT_TOP_LEVEL_KEYS) {
787
+ if (!Object.hasOwn(source, key) || Object.hasOwn(target, key)) {
788
+ continue;
789
+ }
790
+ const value = source[key];
791
+ if (value === null || value === undefined || value === '') {
792
+ continue;
793
+ }
794
+ target[key] = value;
795
+ changed = true;
796
+ }
797
+ return changed;
798
+ }
799
+
743
800
  export function ensureClaudeOnboarding(toolDir: string, hostHomeDir?: string): void {
744
801
  const claudeJsonPath = path.join(toolDir, '.claude.json');
745
802
  let data: JsonObject & {
@@ -810,7 +867,7 @@ export function ensureClaudeOnboarding(toolDir: string, hostHomeDir?: string): v
810
867
 
811
868
  export function ensureClaudeSettings(toolDir: string, hostHomeDir?: string): void {
812
869
  const settingsPath = path.join(toolDir, 'settings.json');
813
- let data: JsonObject & { skipDangerousModePermissionPrompt?: boolean; effortLevel?: string } = {};
870
+ let data: JsonObject & { skipDangerousModePermissionPrompt?: boolean } = {};
814
871
  if (fs.existsSync(settingsPath)) {
815
872
  try {
816
873
  data = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as typeof data;
@@ -825,14 +882,9 @@ export function ensureClaudeSettings(toolDir: string, hostHomeDir?: string): voi
825
882
  }
826
883
  if (hostHomeDir) {
827
884
  const hostSettings = readHostJsonSafe(path.join(hostHomeDir, '.claude', 'settings.json'));
828
- if (
829
- hostSettings
830
- && typeof hostSettings.effortLevel === 'string'
831
- && hostSettings.effortLevel !== ''
832
- && !Object.hasOwn(data, 'effortLevel')
833
- ) {
834
- data.effortLevel = hostSettings.effortLevel;
835
- changed = true;
885
+ if (hostSettings) {
886
+ changed = mergeMissingStringEnvFields(data, hostSettings) || changed;
887
+ changed = mergeMissingTopLevelSettings(data, hostSettings) || changed;
836
888
  }
837
889
  }
838
890
  if (changed) {