agentquad 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Codex agent installer:
3
+ * - 写 ~/.codex/config.toml 的 [mcp_servers.agentquad] 表(marker 注释包起来)
4
+ * - 装 ~/.codex/skills/agentquad-child/SKILL.md
5
+ *
6
+ * marker 实现:TOML 文件支持注释,用 `# <<< agentquad managed start ... # >>> end` 注释行
7
+ * 包裹一段以 newline 分隔的 toml block;卸载时按注释边界精确删。
8
+ */
9
+ import { existsSync, mkdirSync, readFileSync, copyFileSync, rmSync } from 'node:fs'
10
+ import { join, dirname } from 'node:path'
11
+ import { homedir } from 'node:os'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { writeFileAtomic } from './agent-installer-shared.js'
14
+
15
+ const SKILL_NAME = 'agentquad-child'
16
+ const MARKER_START = '# <<< agentquad managed start — do not edit by hand >>>'
17
+ const MARKER_END = '# <<< agentquad managed end >>>'
18
+
19
+ function defaultConfigTomlPath() {
20
+ return join(homedir(), '.codex', 'config.toml')
21
+ }
22
+
23
+ function defaultSkillsDir() {
24
+ return join(homedir(), '.codex', 'skills')
25
+ }
26
+
27
+ function defaultSkillTemplatePath() {
28
+ return fileURLToPath(new URL('./templates/agent-skills/agentquad-child.skill.md', import.meta.url))
29
+ }
30
+
31
+ function buildBlock({ port, version }) {
32
+ return [
33
+ MARKER_START,
34
+ `# agentquad-version: ${version}`,
35
+ `# agentquad-port: ${port}`,
36
+ `# agentquad-generated-at: ${new Date().toISOString()}`,
37
+ '[mcp_servers.agentquad]',
38
+ `url = "http://127.0.0.1:${port}/mcp"`,
39
+ 'transport = "http"',
40
+ MARKER_END,
41
+ '',
42
+ ].join('\n')
43
+ }
44
+
45
+ function stripExistingBlock(raw) {
46
+ const startIdx = raw.indexOf(MARKER_START)
47
+ if (startIdx === -1) return { raw, found: false }
48
+ const endIdx = raw.indexOf(MARKER_END, startIdx)
49
+ if (endIdx === -1) return { raw, found: false }
50
+ const afterEnd = raw.indexOf('\n', endIdx)
51
+ const head = startIdx === 0
52
+ ? ''
53
+ : raw.slice(0, startIdx).replace(/\n+$/, '\n')
54
+ const tail = afterEnd === -1 ? '' : raw.slice(afterEnd + 1)
55
+ return { raw: head + tail, found: true }
56
+ }
57
+
58
+ function parseExistingBlock(raw) {
59
+ const startIdx = raw.indexOf(MARKER_START)
60
+ if (startIdx === -1) return null
61
+ const endIdx = raw.indexOf(MARKER_END, startIdx)
62
+ if (endIdx === -1) return null
63
+ const block = raw.slice(startIdx, endIdx + MARKER_END.length)
64
+ const versionM = block.match(/# agentquad-version:\s*(\S+)/)
65
+ const portM = block.match(/# agentquad-port:\s*(\d+)/)
66
+ return {
67
+ version: versionM ? versionM[1] : null,
68
+ port: portM ? Number(portM[1]) : null,
69
+ }
70
+ }
71
+
72
+ export function installAgent({
73
+ configTomlPath = defaultConfigTomlPath(),
74
+ skillsDir = defaultSkillsDir(),
75
+ skillTemplatePath = defaultSkillTemplatePath(),
76
+ port,
77
+ version,
78
+ } = {}) {
79
+ if (!port) throw new Error('port_required')
80
+ if (!version) throw new Error('version_required')
81
+
82
+ const changes = []
83
+ const cur = existsSync(configTomlPath) ? readFileSync(configTomlPath, 'utf8') : ''
84
+ const existing = parseExistingBlock(cur)
85
+ const sameAll = existing && existing.version === version && existing.port === port
86
+
87
+ let next
88
+ if (sameAll) {
89
+ // idempotent — 不动文件,保留原 generatedAt
90
+ next = cur
91
+ } else {
92
+ const { raw: stripped } = stripExistingBlock(cur)
93
+ const block = buildBlock({ port, version })
94
+ const sep = stripped && !stripped.endsWith('\n') ? '\n' : ''
95
+ next = stripped + sep + block
96
+ changes.push('mcp_registered')
97
+ }
98
+
99
+ if (next !== cur) {
100
+ writeFileAtomic(configTomlPath, next)
101
+ }
102
+
103
+ // skill
104
+ const skillDir = join(skillsDir, SKILL_NAME)
105
+ const skillFile = join(skillDir, 'SKILL.md')
106
+ if (!existsSync(skillFile) || readFileSync(skillFile, 'utf8') !== readFileSync(skillTemplatePath, 'utf8')) {
107
+ mkdirSync(skillDir, { recursive: true })
108
+ copyFileSync(skillTemplatePath, skillFile)
109
+ changes.push('skill_installed')
110
+ }
111
+
112
+ return { ok: true, changes, configPath: configTomlPath, skillPath: skillFile }
113
+ }
114
+
115
+ export function uninstallAgent({
116
+ configTomlPath = defaultConfigTomlPath(),
117
+ skillsDir = defaultSkillsDir(),
118
+ } = {}) {
119
+ const removed = []
120
+ if (existsSync(configTomlPath)) {
121
+ const cur = readFileSync(configTomlPath, 'utf8')
122
+ const { raw, found } = stripExistingBlock(cur)
123
+ if (found) {
124
+ writeFileAtomic(configTomlPath, raw)
125
+ removed.push('mcp_block')
126
+ }
127
+ }
128
+ const skillDir = join(skillsDir, SKILL_NAME)
129
+ if (existsSync(skillDir)) {
130
+ rmSync(skillDir, { recursive: true, force: true })
131
+ removed.push('skill')
132
+ }
133
+ return { ok: true, removed }
134
+ }
135
+
136
+ export function inspectAgent({
137
+ configTomlPath = defaultConfigTomlPath(),
138
+ skillsDir = defaultSkillsDir(),
139
+ expectedPort = null,
140
+ } = {}) {
141
+ const out = {
142
+ target: 'codex',
143
+ mcpRegistered: false,
144
+ skillPresent: false,
145
+ drift: false,
146
+ configPath: configTomlPath,
147
+ expectedPort,
148
+ actualPort: null,
149
+ version: null,
150
+ }
151
+ if (existsSync(configTomlPath)) {
152
+ try {
153
+ const cur = readFileSync(configTomlPath, 'utf8')
154
+ const parsed = parseExistingBlock(cur)
155
+ if (parsed) {
156
+ out.mcpRegistered = true
157
+ out.actualPort = parsed.port
158
+ out.version = parsed.version
159
+ }
160
+ } catch { /* malformed/unreadable, treat as not registered */ }
161
+ }
162
+ if (existsSync(join(skillsDir, SKILL_NAME, 'SKILL.md'))) out.skillPresent = true
163
+ if (out.mcpRegistered && expectedPort && out.actualPort !== expectedPort) out.drift = true
164
+ return out
165
+ }
package/src/config.js CHANGED
@@ -336,6 +336,12 @@ function defaultConfig() {
336
336
  timeoutMs: 600_000,
337
337
  redact: true,
338
338
  },
339
+ agents: {
340
+ autoBootstrap: 'prompt', // 'prompt' | 'never' | 'silent'
341
+ bootstrapDismissed: false, // CLI 弹问时用户回 N 后置 true
342
+ enabled: { claude: true, codex: true, cursor: true },
343
+ warnPtyCount: 8, // doctor 软 warning 阈值
344
+ },
339
345
  };
340
346
  }
341
347
 
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Cursor agent installer:
3
+ * - 写 ~/.cursor/mcp.json 的 mcpServers.agentquad(带 _agentquadManaged 旁路 marker)
4
+ * - 装 ~/.cursor/rules/agentquad.mdc
5
+ *
6
+ * Cursor 不是 AgentQuad spawn 的,没有运行时注入(C),只走 B + rule。
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, copyFileSync, unlinkSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { homedir } from 'node:os'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { buildMarker, isAgentquadManaged, writeJsonAtomic } from './agent-installer-shared.js'
13
+
14
+ const RULE_FILE = 'agentquad.mdc'
15
+
16
+ function defaultMcpJsonPath() {
17
+ return join(homedir(), '.cursor', 'mcp.json')
18
+ }
19
+
20
+ function defaultRulesDir() {
21
+ return join(homedir(), '.cursor', 'rules')
22
+ }
23
+
24
+ function defaultMdcTemplatePath() {
25
+ return fileURLToPath(new URL('./templates/agent-skills/agentquad-child.cursor.mdc', import.meta.url))
26
+ }
27
+
28
+ function readMcpJson(path) {
29
+ if (!existsSync(path)) return {}
30
+ const raw = readFileSync(path, 'utf8')
31
+ if (!raw.trim()) return {}
32
+ try { return JSON.parse(raw) } catch (e) { throw new Error(`malformed_cursor_mcp_json: ${e.message}`) }
33
+ }
34
+
35
+ export function installAgent({
36
+ mcpJsonPath = defaultMcpJsonPath(),
37
+ rulesDir = defaultRulesDir(),
38
+ mdcTemplatePath = defaultMdcTemplatePath(),
39
+ port,
40
+ version,
41
+ } = {}) {
42
+ if (!port) throw new Error('port_required')
43
+ if (!version) throw new Error('version_required')
44
+
45
+ const changes = []
46
+ const cur = readMcpJson(mcpJsonPath)
47
+ cur.mcpServers = cur.mcpServers || {}
48
+
49
+ const desired = { url: `http://127.0.0.1:${port}/mcp`, transport: 'http' }
50
+ const prev = cur.mcpServers.agentquad
51
+ const prevMarker = cur._agentquadManaged
52
+ const samePort = prev && prev.url === desired.url
53
+ const sameVersion = prevMarker && prevMarker.version === version
54
+
55
+ cur.mcpServers.agentquad = desired
56
+ if (samePort && sameVersion && isAgentquadManaged(cur)) {
57
+ cur._agentquadManaged = prevMarker
58
+ } else {
59
+ cur._agentquadManaged = buildMarker({ version, port })
60
+ changes.push('mcp_registered')
61
+ }
62
+ writeJsonAtomic(mcpJsonPath, cur)
63
+
64
+ const ruleFile = join(rulesDir, RULE_FILE)
65
+ if (!existsSync(ruleFile) || readFileSync(ruleFile, 'utf8') !== readFileSync(mdcTemplatePath, 'utf8')) {
66
+ mkdirSync(rulesDir, { recursive: true })
67
+ copyFileSync(mdcTemplatePath, ruleFile)
68
+ changes.push('rule_installed')
69
+ }
70
+
71
+ return { ok: true, changes, configPath: mcpJsonPath, rulePath: ruleFile }
72
+ }
73
+
74
+ export function uninstallAgent({
75
+ mcpJsonPath = defaultMcpJsonPath(),
76
+ rulesDir = defaultRulesDir(),
77
+ } = {}) {
78
+ const removed = []
79
+ if (existsSync(mcpJsonPath)) {
80
+ const cur = readMcpJson(mcpJsonPath)
81
+ if (cur.mcpServers?.agentquad) {
82
+ delete cur.mcpServers.agentquad
83
+ removed.push('mcp_entry')
84
+ }
85
+ if (cur._agentquadManaged) {
86
+ delete cur._agentquadManaged
87
+ removed.push('marker')
88
+ }
89
+ if (removed.length > 0) writeJsonAtomic(mcpJsonPath, cur)
90
+ }
91
+ const ruleFile = join(rulesDir, RULE_FILE)
92
+ if (existsSync(ruleFile)) {
93
+ unlinkSync(ruleFile)
94
+ removed.push('rule')
95
+ }
96
+ return { ok: true, removed }
97
+ }
98
+
99
+ export function inspectAgent({
100
+ mcpJsonPath = defaultMcpJsonPath(),
101
+ rulesDir = defaultRulesDir(),
102
+ expectedPort = null,
103
+ } = {}) {
104
+ const out = {
105
+ target: 'cursor',
106
+ mcpRegistered: false,
107
+ skillPresent: false, // rulePresent 对外仍叫 skillPresent,便于 dispatcher 统一展示
108
+ drift: false,
109
+ configPath: mcpJsonPath,
110
+ expectedPort,
111
+ actualPort: null,
112
+ version: null,
113
+ }
114
+ if (existsSync(mcpJsonPath)) {
115
+ try {
116
+ const cur = readMcpJson(mcpJsonPath)
117
+ if (cur.mcpServers?.agentquad?.url) {
118
+ out.mcpRegistered = true
119
+ const m = cur.mcpServers.agentquad.url.match(/:(\d+)\//)
120
+ if (m) out.actualPort = Number(m[1])
121
+ out.version = cur._agentquadManaged?.version || null
122
+ }
123
+ } catch { /* malformed */ }
124
+ }
125
+ if (existsSync(join(rulesDir, RULE_FILE))) out.skillPresent = true
126
+ if (out.mcpRegistered && expectedPort && out.actualPort !== expectedPort) out.drift = true
127
+ return out
128
+ }
@@ -1,5 +1,25 @@
1
1
  import * as Lark from '@larksuiteoapi/node-sdk'
2
2
  import { toLarkText } from './lark-markdown.js'
3
+ import { isMarkdownLike, toLarkPost } from './lark-post.js'
4
+
5
+ /**
6
+ * 决定一段文本走 text 还是 post 路径。
7
+ * - 'text' / 'post' 强制选定
8
+ * - 'auto'(默认):含块级 markdown 特征才升级到 post
9
+ * 任何非法值视为 'auto'。
10
+ */
11
+ function resolveFormat(format, text) {
12
+ if (format === 'text' || format === 'post') return format
13
+ return isMarkdownLike(text) ? 'post' : 'text'
14
+ }
15
+
16
+ function buildTextContent(text) {
17
+ return JSON.stringify({ text: toLarkText(String(text)) })
18
+ }
19
+
20
+ function buildPostContent(text) {
21
+ return JSON.stringify(toLarkPost(String(text)))
22
+ }
3
23
 
4
24
  function isBlank(value) {
5
25
  return value == null || String(value) === ''
@@ -44,17 +64,35 @@ export function createLarkApiClient({ appId, appSecret, clientFactory = defaultC
44
64
  return client
45
65
  }
46
66
 
47
- async function sendMessage({ chatId, text } = {}) {
67
+ async function sendMessage({ chatId, text, format = 'auto' } = {}) {
48
68
  if (!hasCredentials()) return { ok: false, reason: 'lark_credentials_missing' }
49
69
  if (isBlank(chatId)) return { ok: false, reason: 'chatId_required' }
50
70
  if (isBlank(text)) return { ok: false, reason: 'text_required' }
71
+ const useFormat = resolveFormat(format, String(text))
72
+ if (useFormat === 'post') {
73
+ try {
74
+ const response = await getClient().im.message.create({
75
+ params: { receive_id_type: 'chat_id' },
76
+ data: {
77
+ receive_id: String(chatId),
78
+ msg_type: 'post',
79
+ content: buildPostContent(text),
80
+ },
81
+ })
82
+ return { ok: true, payload: normalizePayload(response) }
83
+ } catch (e) {
84
+ // post 路径被飞书拒收(字段超限 / API 异常)→ 静默降级到 text,不丢消息
85
+ const detail = normalizeError(e)
86
+ logger.warn?.(`[lark-api] send post failed, falling back to text: ${detail}`)
87
+ }
88
+ }
51
89
  try {
52
90
  const response = await getClient().im.message.create({
53
91
  params: { receive_id_type: 'chat_id' },
54
92
  data: {
55
93
  receive_id: String(chatId),
56
94
  msg_type: 'text',
57
- content: JSON.stringify({ text: toLarkText(String(text)) }),
95
+ content: buildTextContent(text),
58
96
  },
59
97
  })
60
98
  return { ok: true, payload: normalizePayload(response) }
@@ -65,16 +103,33 @@ export function createLarkApiClient({ appId, appSecret, clientFactory = defaultC
65
103
  }
66
104
  }
67
105
 
68
- async function replyInThread({ rootMessageId, text } = {}) {
106
+ async function replyInThread({ rootMessageId, text, format = 'auto' } = {}) {
69
107
  if (!hasCredentials()) return { ok: false, reason: 'lark_credentials_missing' }
70
108
  if (isBlank(rootMessageId)) return { ok: false, reason: 'rootMessageId_required' }
71
109
  if (isBlank(text)) return { ok: false, reason: 'text_required' }
110
+ const useFormat = resolveFormat(format, String(text))
111
+ if (useFormat === 'post') {
112
+ try {
113
+ const response = await getClient().im.message.reply({
114
+ path: { message_id: String(rootMessageId) },
115
+ data: {
116
+ msg_type: 'post',
117
+ content: buildPostContent(text),
118
+ reply_in_thread: true,
119
+ },
120
+ })
121
+ return { ok: true, payload: normalizePayload(response) }
122
+ } catch (e) {
123
+ const detail = normalizeError(e)
124
+ logger.warn?.(`[lark-api] reply post failed, falling back to text: ${detail}`)
125
+ }
126
+ }
72
127
  try {
73
128
  const response = await getClient().im.message.reply({
74
129
  path: { message_id: String(rootMessageId) },
75
130
  data: {
76
131
  msg_type: 'text',
77
- content: JSON.stringify({ text: toLarkText(String(text)) }),
132
+ content: buildTextContent(text),
78
133
  reply_in_thread: true,
79
134
  },
80
135
  })
package/src/lark-bot.js CHANGED
@@ -183,18 +183,18 @@ export function createLarkBot({
183
183
  return apiClient
184
184
  }
185
185
 
186
- async function sendMessage({ chatId, text } = {}) {
186
+ async function sendMessage({ chatId, text, format } = {}) {
187
187
  if (isBlank(chatId)) return { ok: false, reason: 'chatId_required' }
188
188
  if (isBlank(text)) return { ok: false, reason: 'text_required' }
189
189
  if (!hasCredentials()) return { ok: false, reason: 'lark_credentials_missing' }
190
- return getApiClient().sendMessage({ chatId, text })
190
+ return getApiClient().sendMessage({ chatId, text, format })
191
191
  }
192
192
 
193
- async function replyInThread({ rootMessageId, text } = {}) {
193
+ async function replyInThread({ rootMessageId, text, format } = {}) {
194
194
  if (isBlank(rootMessageId)) return { ok: false, reason: 'rootMessageId_required' }
195
195
  if (isBlank(text)) return { ok: false, reason: 'text_required' }
196
196
  if (!hasCredentials()) return { ok: false, reason: 'lark_credentials_missing' }
197
- return getApiClient().replyInThread({ rootMessageId, text })
197
+ return getApiClient().replyInThread({ rootMessageId, text, format })
198
198
  }
199
199
 
200
200
  async function sendCard({ chatId, card } = {}) {
@@ -219,9 +219,9 @@ export function createLarkBot({
219
219
 
220
220
  // thread root 失效时(用户撤回 / 飞书 5xx)静默 drop。"撤回 root" = 用户明示
221
221
  // "不想看这个对话了",把消息泼到群主消息流是污染。reply 失败就让它失败。
222
- async function deliverReply({ chatId, rootMessageId, text } = {}) {
223
- if (!rootMessageId) return sendMessage({ chatId, text })
224
- return replyInThread({ rootMessageId, text })
222
+ async function deliverReply({ chatId, rootMessageId, text, format } = {}) {
223
+ if (!rootMessageId) return sendMessage({ chatId, text, format })
224
+ return replyInThread({ rootMessageId, text, format })
225
225
  }
226
226
 
227
227
  function clearPendingReplyRetry(replyContext, ev) {