✨ feat(vscode): 重写注释把原型存进 docs/prototype/<功能>/openpencil/
跟写注释技能 ECC v2.2.53 的原型约定:去掉临时草稿目录 spx-comment-<id> 和 */prototypes 的建目录规则,会话 id 交给 Claude Code 起。 放开 Edit/Write 到 docs/prototype/*/openpencil/ 下、mkdir -p 到 docs/prototype/*/openpencil/exports;Bash 规则的 * 能跨空格,另外拒掉建目录时多跟一个路径的写法。
This commit is contained in:
1 parent
5bfa89e691
commit
b7c29c60e1
3 files changed
+43
-66
No files matched your search
@@ -171,11 +171,10 @@ describe('失败了给人看的话', () => {
|
||||
})
|
||||
|
||||
describe('重写注释的命令行参数', () => {
|
||||
const SCRATCH = '/private/var/folders/T/spx-comment-s1'
|
||||
const allowed = 'Read,Grep,Glob,Bash(/Applications/OpenPencil.app/Contents/MacOS/op *),Bash(mkdir -p */prototypes),Edit(//private/var/folders/T/spx-comment-s1/**)'
|
||||
const allowed = 'Read,Grep,Glob,Bash(/Applications/OpenPencil.app/Contents/MacOS/op *),Bash(mkdir -p docs/prototype/*/openpencil/exports),Edit(/docs/prototype/*/openpencil/**)'
|
||||
|
||||
it('不读用户、项目的设置(--restricted),写注释的插件单独加载、能读;只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,别的一律拒、不加载 MCP;按 JSON Schema 交结果', () => {
|
||||
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: SCRATCH, profilePath: '/p/sonnet.json', session: { start: 's1' } })
|
||||
it('不读用户、项目的设置(--restricted),写注释的插件单独加载、能读;只放开读搜、OpenPencil 的 op、建原型的 exports 目录、改原型目录,别的一律拒、不加载 MCP;按 JSON Schema 交结果', () => {
|
||||
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', profilePath: '/p/sonnet.json' })
|
||||
expect(args).toEqual([
|
||||
'--settings',
|
||||
'/p/sonnet.json',
|
||||
@@ -184,17 +183,15 @@ describe('重写注释的命令行参数', () => {
|
||||
'/plugins/ecc',
|
||||
'--add-dir',
|
||||
'/plugins/ecc',
|
||||
'--add-dir',
|
||||
SCRATCH,
|
||||
'--tools',
|
||||
'Read,Grep,Glob,Bash,Edit,Write',
|
||||
'--allowedTools',
|
||||
allowed,
|
||||
'--disallowedTools',
|
||||
'Bash(mkdir -p docs/prototype/* *)',
|
||||
'--permission-mode',
|
||||
'dontAsk',
|
||||
'--strict-mcp-config',
|
||||
'--session-id',
|
||||
's1',
|
||||
'-p',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
@@ -205,16 +202,21 @@ describe('重写注释的命令行参数', () => {
|
||||
'--json-schema',
|
||||
COMMENT_SCHEMA,
|
||||
])
|
||||
expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions/)
|
||||
expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions|spx-comment|prototypes\)/)
|
||||
expect(JSON.parse(COMMENT_SCHEMA)).toEqual({ type: 'object', properties: { body: { type: 'string' } }, required: ['body'], additionalProperties: false })
|
||||
})
|
||||
|
||||
it('接着说时 --resume 那次会话、不带 --session-id;没有 profile 就不带 --settings', () => {
|
||||
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: SCRATCH, session: { resume: 's1' } })
|
||||
it('接着说时 --resume 那次会话,放在 -p 前面;没有 profile 就不带 --settings', () => {
|
||||
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', resume: 's1' })
|
||||
expect(args[0]).toBe('--restricted')
|
||||
expect(args).not.toContain('--session-id')
|
||||
expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 's1', '-p'])
|
||||
})
|
||||
|
||||
it('开新会话不带 --resume,会话 id 由 Claude Code 起', () => {
|
||||
const args = rewriteArgs({ pluginRoot: '/plugins/ecc' })
|
||||
expect(args).not.toContain('--resume')
|
||||
expect(args).not.toContain('--session-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('写注释的插件在哪', () => {
|
||||
@@ -245,25 +247,18 @@ describe('写注释的插件在哪', () => {
|
||||
describe('重写注释问一次', () => {
|
||||
const structured = (body: string, session = 's1'): string => line({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify({ body }), structured_output: { body }, session_id: session })
|
||||
|
||||
/** 假的 claude 再加上找插件、建草稿目录、起会话 id:都记下来 */
|
||||
/** 假的 claude 再加上找插件:问过哪个目录记下来 */
|
||||
function commentDeps(runs: { lines: string[], error?: Error }[]) {
|
||||
const { deps, calls } = fakeClaude(runs)
|
||||
const scratches: string[] = []
|
||||
const lookups: string[] = []
|
||||
let next = 0
|
||||
const commentDeps: CommentAskDeps = {
|
||||
...deps,
|
||||
pluginRoot: async (cwd) => {
|
||||
lookups.push(cwd)
|
||||
return '/plugins/ecc'
|
||||
},
|
||||
scratch: async (sessionId) => {
|
||||
scratches.push(sessionId)
|
||||
return `/tmp/spx-comment-${sessionId}`
|
||||
},
|
||||
newSessionId: () => `new-${++next}`,
|
||||
}
|
||||
return { deps: commentDeps, calls, scratches, lookups }
|
||||
return { deps: commentDeps, calls, lookups }
|
||||
}
|
||||
|
||||
function comment(overrides: Partial<CommentAsk> = {}): CommentAsk & { events: unknown[] } {
|
||||
@@ -271,35 +266,31 @@ describe('重写注释问一次', () => {
|
||||
return { prompt: '/ecc:writing-code-comments src/a.py:3-8', images: [], cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event), events, ...overrides }
|
||||
}
|
||||
|
||||
it('开新会话:自己起会话 id、按它建草稿目录,带上插件目录和超时;stdin 里是那句提示和贴的图;返回正文和会话 id', async () => {
|
||||
const { deps, calls, scratches, lookups } = commentDeps([{ lines: [structured('按名字找文件。', 'new-1')] }])
|
||||
it('开新会话:带上插件目录和超时;stdin 里是那句提示和贴的图;返回正文和 Claude Code 起的会话 id', async () => {
|
||||
const { deps, calls, lookups } = commentDeps([{ lines: [init('new-1'), structured('按名字找文件。', 'new-1')] }])
|
||||
const ask = comment({ profilePath: '/p/sonnet.json', images: [PNG] })
|
||||
expect(await askCommentBody(ask, deps)).toEqual({ body: '按名字找文件。', sessionId: 'new-1' })
|
||||
expect(lookups).toEqual(['/work'])
|
||||
expect(scratches).toEqual(['new-1'])
|
||||
expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: '/tmp/spx-comment-new-1', profilePath: '/p/sonnet.json', session: { start: 'new-1' } }))
|
||||
expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', profilePath: '/p/sonnet.json' }))
|
||||
expect(calls[0]).toMatchObject({ cwd: '/work', signal: ask.signal, timeoutMs: REWRITE_TIMEOUT_MS })
|
||||
expect(sent(calls[0])).toMatchObject({ text: '/ecc:writing-code-comments src/a.py:3-8', images: [{ source: { data: PNG.base64 } }] })
|
||||
})
|
||||
|
||||
it('接着说:--resume 这块最近一次重写的会话,用那个会话的草稿目录,发块现在在哪和用户的话', async () => {
|
||||
const { deps, calls, scratches } = commentDeps([{ lines: [structured('短一点。', 's0')] }])
|
||||
it('接着说:--resume 这块最近一次重写的会话,发块现在在哪和用户的话', async () => {
|
||||
const { deps, calls } = commentDeps([{ lines: [structured('短一点。', 's0')] }])
|
||||
expect(await askCommentBody(comment({ followUp: { sessionId: 's0', prompt: 'src/a.py:3-10\n\n短一点' } }), deps)).toEqual({ body: '短一点。', sessionId: 's0' })
|
||||
expect(scratches).toEqual(['s0'])
|
||||
expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: '/tmp/spx-comment-s0', session: { resume: 's0' } }))
|
||||
expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', resume: 's0' }))
|
||||
expect(sent(calls[0]).text).toBe('src/a.py:3-10\n\n短一点')
|
||||
})
|
||||
|
||||
it('那次会话找不到了:开新会话按技能命令重新来,图片照样带上', async () => {
|
||||
const { deps, calls, scratches } = commentDeps([
|
||||
const { deps, calls } = commentDeps([
|
||||
{ lines: [line({ type: 'result', subtype: 'error_during_execution', is_error: true, errors: ['No conversation found with session ID: s0'], session_id: 's0' })] },
|
||||
{ lines: [structured('重新写的。', 'new-1')] },
|
||||
])
|
||||
const ask = comment({ prompt: '/ecc:writing-code-comments src/a.py:3-10\n\n短一点', images: [PNG], followUp: { sessionId: 's0', prompt: 'src/a.py:3-10\n\n短一点' } })
|
||||
expect(await askCommentBody(ask, deps)).toEqual({ body: '重新写的。', sessionId: 'new-1' })
|
||||
expect(scratches).toEqual(['s0', 'new-1'])
|
||||
expect(calls[1].args).toContain('--session-id')
|
||||
expect(calls[1].args).not.toContain('--resume')
|
||||
expect(calls[1].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc' }))
|
||||
expect(sent(calls[1])).toMatchObject({ text: '/ecc:writing-code-comments src/a.py:3-10\n\n短一点', images: [{ source: { data: PNG.base64 } }] })
|
||||
})
|
||||
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
import type { ClaudeImage } from '../../cc/spawnClaude'
|
||||
import type { CommentEvent } from '../commentRewrite'
|
||||
import type { AnswerEvent } from './answerStream'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { buildStreamJsonLine, CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from '../../cc/spawnClaude'
|
||||
import { COMMENT_PLUGIN, COMMENT_SCHEMA, createCommentParser } from '../commentRewrite'
|
||||
import { createAnswerParser } from './answerStream'
|
||||
@@ -25,6 +21,9 @@ export const REWRITE_TIMEOUT_MS = 15 * 60_000
|
||||
/** OpenPencil 装在应用里的命令行(写注释的技能用它画前端组件的原型图) */
|
||||
export const OPENPENCIL_CLI = '/Applications/OpenPencil.app/Contents/MacOS/op'
|
||||
|
||||
/** 写注释的技能存原型的目录,`*` 是功能名(`my-apps`)。权限规则里写成 `Edit(/…)`,开头的 `/` 指 claude 的工作目录(仓库根目录),不是磁盘根目录。 */
|
||||
const PROTOTYPE_DIR = 'docs/prototype/*/openpencil'
|
||||
|
||||
/**
|
||||
* 一次提问的命令行参数;问题连同贴的图片从 stdin 按 stream-json 发(见 askClaude),没贴图也一样。只读靠三层:可用的内置工具只剩读、搜(`--tools`);这几个不用再批(`--allowedTools`),
|
||||
* 别的一律拒绝、不等人批(`--permission-mode dontAsk`,盖过 profile 里的 bypassPermissions);
|
||||
@@ -53,14 +52,15 @@ export function askArgs(options: { profilePath?: string, resume?: string } = {})
|
||||
|
||||
/**
|
||||
* 一次重写注释的命令行参数,按 COMMENT_SCHEMA 交结构化结果(`--json-schema`)。除了读、搜,只放开写注释的技能画原型图要的:
|
||||
* 运行 OpenPencil 的 op、建组件旁边的 `prototypes/` 目录、在草稿目录里写 build.js(`--allowedTools` 里的规则),别的一律拒绝、不等人批。
|
||||
* 运行 OpenPencil 的 op、建原型的 `exports/` 目录、改 [PROTOTYPE_DIR] 里的文件(`--allowedTools` 里的规则),别的一律拒绝、不等人批。
|
||||
* 规则里的 `*` 能跨空格,`mkdir -p docs/prototype/x/openpencil/exports src/y` 也对得上,所以建目录时多跟一个路径的另外拒掉(`--disallowedTools`)。
|
||||
* `--restricted` 不读用户、项目的设置文件,那里放行的命令(比如 `git -C`)进不来,文件工具也只认工作目录;
|
||||
* 写注释的插件因此要单独加载(`--plugin-dir`),它的目录和草稿目录加进工作目录(`--add-dir`),技能才读得到它的参考文档、写得了草稿。
|
||||
* session:新会话用 start 当会话 id(草稿目录按它起名),接着说时 resume 那次会话。
|
||||
* 写注释的插件因此要单独加载(`--plugin-dir`),它的目录加进工作目录(`--add-dir`),技能才读得到它的参考文档。
|
||||
* 有 resume 就接着那次会话说,没有就开新会话,会话 id 由 Claude Code 起。
|
||||
*/
|
||||
export function rewriteArgs(options: { pluginRoot: string, scratch: string, profilePath?: string, session: { start: string } | { resume: string } }): string[] {
|
||||
const { pluginRoot, scratch, profilePath, session } = options
|
||||
const allowed = [READ_ONLY_TOOLS, `Bash(${OPENPENCIL_CLI} *)`, 'Bash(mkdir -p */prototypes)', `Edit(/${scratch}/**)`].join(',')
|
||||
export function rewriteArgs(options: { pluginRoot: string, profilePath?: string, resume?: string }): string[] {
|
||||
const { pluginRoot, profilePath, resume } = options
|
||||
const allowed = [READ_ONLY_TOOLS, `Bash(${OPENPENCIL_CLI} *)`, `Bash(mkdir -p ${PROTOTYPE_DIR}/exports)`, `Edit(/${PROTOTYPE_DIR}/**)`].join(',')
|
||||
return [
|
||||
...(profilePath ? ['--settings', profilePath] : []),
|
||||
'--restricted',
|
||||
@@ -68,16 +68,16 @@ export function rewriteArgs(options: { pluginRoot: string, scratch: string, prof
|
||||
pluginRoot,
|
||||
'--add-dir',
|
||||
pluginRoot,
|
||||
'--add-dir',
|
||||
scratch,
|
||||
'--tools',
|
||||
`${READ_ONLY_TOOLS},Bash,Edit,Write`,
|
||||
'--allowedTools',
|
||||
allowed,
|
||||
'--disallowedTools',
|
||||
'Bash(mkdir -p docs/prototype/* *)',
|
||||
'--permission-mode',
|
||||
'dontAsk',
|
||||
'--strict-mcp-config',
|
||||
...('resume' in session ? ['--resume', session.resume] : ['--session-id', session.start]),
|
||||
...(resume ? ['--resume', resume] : []),
|
||||
'-p',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
@@ -186,24 +186,11 @@ export interface CommentAnswer {
|
||||
export interface CommentAskDeps extends AskDeps {
|
||||
/** 写注释的技能所在插件的目录,按 cwd 问(项目里装的插件也算) */
|
||||
pluginRoot: (cwd: string) => Promise<string>
|
||||
/** 这个会话画原型用的草稿目录,建好了返回 */
|
||||
scratch: (sessionId: string) => Promise<string>
|
||||
/** 新会话的 id */
|
||||
newSessionId: () => string
|
||||
}
|
||||
|
||||
/** 这个会话画原型的草稿目录:系统临时目录下的 `spx-comment-<会话 id>`,同一会话每轮都是它。用真实路径,权限规则按它比对。不删,留给系统清。 */
|
||||
async function commentScratch(sessionId: string): Promise<string> {
|
||||
const dir = path.join(await realpath(tmpdir()), `spx-comment-${sessionId}`)
|
||||
await mkdir(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
const COMMENT_DEPS: CommentAskDeps = {
|
||||
runLines: runClaudeLines,
|
||||
pluginRoot: cwd => commentPluginRoot(cwd),
|
||||
scratch: commentScratch,
|
||||
newSessionId: randomUUID,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,13 +199,12 @@ const COMMENT_DEPS: CommentAskDeps = {
|
||||
*/
|
||||
export async function askCommentBody(ask: CommentAsk, deps: CommentAskDeps = COMMENT_DEPS): Promise<CommentAnswer> {
|
||||
const pluginRoot = await deps.pluginRoot(ask.cwd)
|
||||
const once = async (prompt: string, session: { start: string } | { resume: string }): Promise<CommentAnswer> => {
|
||||
const scratch = await deps.scratch('resume' in session ? session.resume : session.start)
|
||||
const once = async (prompt: string, resume?: string): Promise<CommentAnswer> => {
|
||||
const parse = createCommentParser()
|
||||
let result: CommentAnswer | undefined
|
||||
let failure: string | undefined
|
||||
await deps.runLines({
|
||||
args: rewriteArgs({ pluginRoot, scratch, profilePath: ask.profilePath, session }),
|
||||
args: rewriteArgs({ pluginRoot, profilePath: ask.profilePath, resume }),
|
||||
input: buildStreamJsonLine(prompt, ask.images),
|
||||
cwd: ask.cwd,
|
||||
signal: ask.signal,
|
||||
@@ -242,14 +228,14 @@ export async function askCommentBody(ask: CommentAsk, deps: CommentAskDeps = COM
|
||||
}
|
||||
if (ask.followUp) {
|
||||
try {
|
||||
return await once(ask.followUp.prompt, { resume: ask.followUp.sessionId })
|
||||
return await once(ask.followUp.prompt, ask.followUp.sessionId)
|
||||
}
|
||||
catch (err) {
|
||||
if (!missingSession(err))
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return once(ask.prompt, { start: deps.newSessionId() })
|
||||
return once(ask.prompt)
|
||||
}
|
||||
|
||||
/** `claude plugin list --json` 里启用着的 name 插件的目录;没装、没启用、读不出来时没有。 */
|
||||
|
||||
Reference in new issue
Block a user