opencode-plugin-flow 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.0.1] - 2026-06-12
6
+
7
+ Slash commands and the reviewer agent are now synced as real files so OpenCode discovers them
8
+
9
+ In 3.0.0 the nine `/flow-*` commands and the `flow-reviewer` agent were injected only through the plugin config hook, and OpenCode did not surface them as slash commands. Plugin startup now also writes them to OpenCode's normal discovery paths: thin command markdown files to `~/.config/opencode/commands/<name>.md` and the reviewer agent to `~/.config/opencode/agents/flow-reviewer.md`, rendered from the same definitions the config hook injects so the two surfaces cannot drift. Restart OpenCode once after upgrading so the new files are discovered.
10
+
11
+ The sync follows the same ownership rules as skills: each file gets a sidecar `.{name}.flow-version` marker (plugin version plus content sha256), files without a Flow marker are never touched, and a user-edited Flow-owned file is backed up next to itself before an update replaces it. `bunx opencode-plugin-flow uninstall` removes the Flow-owned command/agent files (keeping user-edited ones), and `/flow-doctor` warns when the synced command/agent surface is missing or stale.
12
+
13
+ Not-tested: Live OpenCode UI slash-command discovery after a real npm upgrade (verified via the packed-tarball install smoke).
14
+
5
15
  ## [3.0.0] - 2026-06-12
6
16
 
7
17
  The skills-first inversion: skills carry the workflow, the plugin shrinks to a state backend
package/README.md CHANGED
@@ -35,18 +35,20 @@ Add Flow to the `plugin` array in your `opencode.json` (global `~/.config/openco
35
35
 
36
36
  OpenCode installs the package from npm on startup. Pin the major version you install (currently `@3`) so restarts pick up fixes without crossing a breaking release.
37
37
 
38
- On startup the plugin syncs its global skills into `~/.config/opencode/skills/`:
38
+ On startup the plugin syncs its global skills, commands, and review agent into OpenCode's normal discovery paths:
39
39
 
40
40
  ```text
41
- flow/SKILL.md # the driving loop: status → plan → run → review → repeat
42
- flow-plan/SKILL.md # decomposition heuristics, feature sizing, approval criteria
43
- flow-run/SKILL.md # one-feature discipline, validation evidence standards
44
- flow-review/SKILL.md # review depth criteria, finding taxonomy, report format
41
+ ~/.config/opencode/skills/flow/SKILL.md # driving loop
42
+ ~/.config/opencode/skills/flow-plan/SKILL.md # decomposition heuristics
43
+ ~/.config/opencode/skills/flow-run/SKILL.md # validation discipline
44
+ ~/.config/opencode/skills/flow-review/SKILL.md # review rubric
45
+ ~/.config/opencode/commands/flow-auto.md # slash command pointers
46
+ ~/.config/opencode/agents/flow-reviewer.md # read-only review agent
45
47
  ```
46
48
 
47
- **Restart OpenCode once after the first install or after an update** so freshly synced skills are discovered.
49
+ **Restart OpenCode once after the first install or after an update** so freshly synced skills, commands, and agents are discovered.
48
50
 
49
- Skill sync is ownership-aware: each Flow-owned skill folder carries a `.flow-skill-version` marker with a sha256 line per shipped file. Folders without the marker are never touched, and if you edit a Flow-owned file by hand — `SKILL.md` or a `references/` file — the previous content is backed up next to it (`SKILL.md.backup`, `references/<name>.md.backup`) before an update replaces it.
51
+ Sync is ownership-aware: each Flow-owned skill folder carries a `.flow-skill-version` marker with a sha256 line per shipped file, and each synced command/agent file has a sidecar `.flow-version` marker. Folders or files without Flow markers are never touched, and if you edit a Flow-owned file by hand the previous content is backed up next to it before an update replaces it.
50
52
 
51
53
  ### Per-project skill overrides
52
54
 
@@ -68,7 +70,7 @@ Releases before 2.1.0 installed a bundled plugin file at `~/.config/opencode/plu
68
70
  bunx opencode-plugin-flow uninstall
69
71
  ```
70
72
 
71
- This removes the Flow-owned global skill folders (those carrying the Flow marker) and a pre-npm `flow.js` copy if one exists, then reminds you to remove `"opencode-plugin-flow"` from the `plugin` array in `opencode.json`. Skills you created yourself are never deleted. Use `--dry-run` to preview.
73
+ This removes the Flow-owned global skill folders, command files, agent files, and a pre-npm `flow.js` copy if one exists, then reminds you to remove `"opencode-plugin-flow"` from the `plugin` array in `opencode.json`. Files you created yourself are never deleted. Use `--dry-run` to preview.
72
74
 
73
75
  ## Skills, commands, and tools
74
76
 
package/dist/cli.js CHANGED
@@ -1,10 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import{homedir as a}from"node:os";import{readdir as k,readFile as g,rm as X,rmdir as p}from"node:fs/promises";import{dirname as f,join as Z,normalize as h,sep as m}from"node:path";import{createHash as R}from"node:crypto";import{join as x}from"node:path";var S=x(".config","opencode","skills"),O=".flow-skill-version",w="SKILL.md.backup",F=x(".config","opencode","plugins","flow.js"),C=`// Managed by flow-opencode install/uninstall
3
- `,I="flow-opencode-generated-skill",c=`<!-- ${I} `,P=new RegExp(`^<!-- ${I} name=([a-z0-9]+(?:-[a-z0-9]+)*) version=([0-9]+) hash=sha256:([a-f0-9]{64}) -->$`,"u");function W(j){return R("sha256").update(j,"utf8").digest("hex")}function K(j){let q=j.split(`
4
- `),z=q.flatMap((M,L)=>M.startsWith(c)?[L]:[]);if(z.length===0)return{kind:"not_generated"};if(z.length>1)return{kind:"invalid_generated",reason:"duplicate_marker"};let B=z[0];if(B===void 0)return{kind:"not_generated"};let J=q[B];if(J===void 0)return{kind:"invalid_generated",reason:"malformed_marker"};let Q=J.match(P);if(!Q)return{kind:"invalid_generated",reason:"malformed_marker"};let[,V,$,Y]=Q;if(V===void 0||$===void 0||Y===void 0)return{kind:"invalid_generated",reason:"malformed_marker"};let T=[...q.slice(0,B),...q.slice(B+1)].join(`
5
- `);if(W(T)!==Y)return{kind:"invalid_generated",reason:"hash_mismatch"};return{kind:"valid_generated",marker:{name:V,version:$,hash:Y}}}var u="opencode-plugin-flow",A="file=",D="=sha256:";function U(j){let q=new Map;for(let z of j.split(`
6
- `)){if(!z.startsWith(A))continue;let B=z.slice(A.length),J=B.lastIndexOf(D);if(J===-1)continue;let Q=B.slice(0,J),V=B.slice(J+D.length);if(Q.length>0&&/^[a-f0-9]{64}$/.test(V))q.set(Q,V)}return q}function _(j){let q=new Map;for(let Q of j.split(`
7
- `)){let V=Q.indexOf("=");if(V===-1)continue;q.set(Q.slice(0,V),Q.slice(V+1))}let z=q.get("plugin"),B=q.get("version");if(z!==u||!B)return null;let J=q.get("hash");return{plugin:z,version:B,hash:J?.startsWith("sha256:")?J.slice(7):null}}async function E({homeDir:j,dryRun:q=!1,logger:z}){let B={removedSkills:[],keptUserEditedSkills:[],removedPreNpmPlugin:null,keptForeignPreNpmPlugin:null},J=Z(j,S);for(let $ of await i(J)){if($!=="flow"&&!$.startsWith("flow-"))continue;let Y=Z(J,$),T=await d(Y);if(T==="foreign")continue;if(T==="user_edited"){B.keptUserEditedSkills.push(Y),z?.(`Kept user-edited Flow skill at ${Y}; remove it manually if it is no longer needed.`);continue}if(!q)await s(Y);B.removedSkills.push(Y),z?.(`${q?"Would remove":"Removed"} Flow skill at ${Y}.`)}let Q=Z(j,F),V=await H(Q);if(V!==null)if(V.startsWith(C)){if(!q)await X(Q,{force:!0});B.removedPreNpmPlugin=Q,z?.(`${q?"Would remove":"Removed"} pre-npm Flow plugin copy at ${Q}.`)}else B.keptForeignPreNpmPlugin=Q,z?.(`Kept ${Q}: it is not managed by Flow. Remove it manually if it is a stale Flow copy.`);return z?.('Finally, remove "opencode-plugin-flow" from the plugin array in opencode.json and restart OpenCode.'),B}async function d(j){let q=Z(j,"SKILL.md"),z=await H(q),B=await H(Z(j,O)),J=B===null?null:_(B);if(J!==null&&B!==null){for(let[$,Y]of U(B)){if($==="SKILL.md")continue;let T=N(j,$);if(T===null)continue;let M=await H(T);if(M!==null&&W(M)!==Y)return"user_edited"}if(z===null)return"pristine";if(J.hash!==null&&W(z)===J.hash)return"pristine";return K(z).kind==="valid_generated"?"pristine":"user_edited"}if(z===null)return"foreign";let Q=K(z);if(Q.kind==="valid_generated")return"pristine";if(Q.kind==="invalid_generated")return"user_edited";return"foreign"}function N(j,q){let z=h(Z(j,...q.split("/")));if(z!==j&&z.startsWith(`${j}${m}`))return z;return null}async function s(j){let q=await H(Z(j,O)),z=new Set;if(q!==null)for(let B of U(q).keys()){let J=N(j,B);if(J===null)continue;await X(J,{force:!0}),await X(`${J}.backup`,{force:!0});let Q=f(J);if(Q!==j)z.add(Q)}await X(Z(j,"SKILL.md"),{force:!0}),await X(Z(j,O),{force:!0}),await X(Z(j,w),{force:!0});for(let B of[...z].sort((J,Q)=>Q.length-J.length))await v(B);await v(j)}async function v(j){try{await p(j)}catch(q){let z=q.code;if(z!=="ENOENT"&&z!=="ENOTEMPTY")throw q}}async function i(j){try{return(await k(j,{withFileTypes:!0})).filter((z)=>z.isDirectory()).map((z)=>z.name).sort()}catch(q){if(q.code==="ENOENT")return[];throw q}}async function H(j){try{return await g(j,"utf8")}catch(q){if(q.code==="ENOENT")return null;throw q}}var b=`opencode-plugin-flow — Flow plugin lifecycle commands
2
+ import{homedir as oe}from"node:os";import{readdir as H,readFile as M,rm as l,rmdir as D}from"node:fs/promises";import{dirname as P,join as c,normalize as ee,sep as te}from"node:path";import{createHash as Q}from"node:crypto";import{join as w}from"node:path";var g=w(".config","opencode","skills"),b=w(".config","opencode","commands"),x=w(".config","opencode","agents"),f=".flow-skill-version",F="SKILL.md.backup",A=w(".config","opencode","plugins","flow.js"),_=`// Managed by flow-opencode install/uninstall
3
+ `,W="flow-opencode-generated-skill",X=`<!-- ${W} `,J=new RegExp(`^<!-- ${W} name=([a-z0-9]+(?:-[a-z0-9]+)*) version=([0-9]+) hash=sha256:([a-f0-9]{64}) -->$`,"u");function h(e){return Q("sha256").update(e,"utf8").digest("hex")}function m(e){let t=e.split(`
4
+ `),i=t.flatMap((v,U)=>v.startsWith(X)?[U]:[]);if(i.length===0)return{kind:"not_generated"};if(i.length>1)return{kind:"invalid_generated",reason:"duplicate_marker"};let a=i[0];if(a===void 0)return{kind:"not_generated"};let s=t[a];if(s===void 0)return{kind:"invalid_generated",reason:"malformed_marker"};let o=s.match(J);if(!o)return{kind:"invalid_generated",reason:"malformed_marker"};let[,n,r,d]=o;if(n===void 0||r===void 0||d===void 0)return{kind:"invalid_generated",reason:"malformed_marker"};let u=[...t.slice(0,a),...t.slice(a+1)].join(`
5
+ `);if(h(u)!==d)return{kind:"invalid_generated",reason:"hash_mismatch"};return{kind:"valid_generated",marker:{name:n,version:r,hash:d}}}var z="opencode-plugin-flow",j="file=",B="=sha256:";function y(e){let t=new Map;for(let i of e.split(`
6
+ `)){if(!i.startsWith(j))continue;let a=i.slice(j.length),s=a.lastIndexOf(B);if(s===-1)continue;let o=a.slice(0,s),n=a.slice(s+B.length);if(o.length>0&&/^[a-f0-9]{64}$/.test(n))t.set(o,n)}return t}function k(e,t,i){let a=new Map;for(let n of e.split(`
7
+ `)){let r=n.indexOf("=");if(r===-1)continue;a.set(n.slice(0,r),n.slice(r+1))}let s=a.get("version"),o=a.get("hash");if(a.get("plugin")!==z||a.get("kind")!==t||a.get("name")!==i||!s||!o?.startsWith("sha256:"))return null;return{kind:t,name:i,version:s,hash:o.slice(7)}}function R(e){let t=new Map;for(let o of e.split(`
8
+ `)){let n=o.indexOf("=");if(n===-1)continue;t.set(o.slice(0,n),o.slice(n+1))}let i=t.get("plugin"),a=t.get("version");if(i!==z||!a)return null;let s=t.get("hash");return{plugin:i,version:a,hash:s?.startsWith("sha256:")?s.slice(7):null}}var Z={fast:"low",balanced:"medium",deep:"high"},O={"flow-reviewer":{mode:"all",description:"Review Flow work read-only and record a reviewer decision.",prompt:"You are the Flow reviewer. Load the `flow-review` skill, review the requested work read-only, then record your decision with flow_review_record.",reasoningEffort:Z.deep,permission:{edit:"deny",bash:"deny",task:{"*":"deny"},"flow_*":"deny",flow_status:"allow",flow_review_record:"allow"}}},S={"flow-plan":{description:"Create, update, or approve a Flow plan",template:"Load the `flow-plan` skill and plan: $ARGUMENTS"},"flow-run":{description:"Run one approved Flow feature",template:"Load the `flow-run` skill and execute the next approved Flow feature. $ARGUMENTS"},"flow-auto":{description:"Drive the Flow loop autonomously until completion or a real blocker",template:"Load the `flow` skill and drive the Flow loop (status, plan, run, review) until completion or a real blocker: $ARGUMENTS"},"flow-status":{description:"Inspect the active Flow session and workspace readiness",template:"Call flow_status (detailed) and report session state, readiness checks, and the suggested next step."},"flow-doctor":{description:"Check Flow readiness for the current workspace",template:"Call flow_status (detailed) and report the readiness checks with any remediation steps."},"flow-history":{description:"Inspect stored Flow session history",template:"Call flow_session with action 'history' and summarize the sessions."},"flow-session":{description:"Activate, close, list, or show a Flow session",template:"Call flow_session with the requested action (activate, close, history, or show): $ARGUMENTS"},"flow-reset":{description:"Reset a Flow feature to pending",template:"Call flow_feature_complete with reset=true for feature: $ARGUMENTS"},"flow-review":{description:"Run a read-only Flow review with a fresh context",agent:"flow-reviewer",subtask:!0,template:"Load the `flow-review` skill and review: $ARGUMENTS"}};function $(e){return`${["---",`description: ${JSON.stringify(e.description)}`,...e.agent?[`agent: ${JSON.stringify(e.agent)}`]:[],...e.subtask===void 0?[]:[`subtask: ${e.subtask}`],"---"].join(`
9
+ `)}
10
+
11
+ ${e.template}
12
+ `}function K(e){return`${["---",`description: ${JSON.stringify(e.description)}`,`mode: ${e.mode}`,...e.reasoningEffort?[`reasoningEffort: ${e.reasoningEffort}`]:[],...e.permission?C(e.permission):[],"---"].join(`
13
+ `)}
14
+
15
+ ${e.prompt}
16
+ `}function N(){return new Map(Object.entries(S).map(([e,t])=>[e,$(t)]))}function V(){return new Map(Object.entries(O).map(([e,t])=>[e,K(t)]))}function C(e){if(!e)return[];let t=["permission:"];for(let[i,a]of Object.entries(e)){if(typeof a==="string"){t.push(` ${JSON.stringify(i)}: ${JSON.stringify(a)}`);continue}if(a&&typeof a==="object"){t.push(` ${JSON.stringify(i)}:`);for(let[s,o]of Object.entries(a))t.push(` ${JSON.stringify(s)}: ${JSON.stringify(o)}`)}}return t}async function Y({homeDir:e,dryRun:t=!1,logger:i}){let a={removedSkills:[],keptUserEditedSkills:[],removedCommands:[],keptUserEditedCommands:[],removedAgents:[],keptUserEditedAgents:[],removedPreNpmPlugin:null,keptForeignPreNpmPlugin:null},s=c(e,g);for(let r of await se(s)){if(r!=="flow"&&!r.startsWith("flow-"))continue;let d=c(s,r),u=await ae(d);if(u==="foreign")continue;if(u==="user_edited"){a.keptUserEditedSkills.push(d),i?.(`Kept user-edited Flow skill at ${d}; remove it manually if it is no longer needed.`);continue}if(!t)await ie(d);a.removedSkills.push(d),i?.(`${t?"Would remove":"Removed"} Flow skill at ${d}.`)}await E({homeDir:e,dryRun:t,logger:i,kind:"command",root:c(e,b),files:N(),removed:a.removedCommands,keptUserEdited:a.keptUserEditedCommands}),await E({homeDir:e,dryRun:t,logger:i,kind:"agent",root:c(e,x),files:V(),removed:a.removedAgents,keptUserEdited:a.keptUserEditedAgents});let o=c(e,A),n=await p(o);if(n!==null)if(n.startsWith(_)){if(!t)await l(o,{force:!0});a.removedPreNpmPlugin=o,i?.(`${t?"Would remove":"Removed"} pre-npm Flow plugin copy at ${o}.`)}else a.keptForeignPreNpmPlugin=o,i?.(`Kept ${o}: it is not managed by Flow. Remove it manually if it is a stale Flow copy.`);return i?.('Finally, remove "opencode-plugin-flow" from the plugin array in opencode.json and restart OpenCode.'),a}async function ae(e){let t=c(e,"SKILL.md"),i=await p(t),a=await p(c(e,f)),s=a===null?null:R(a);if(s!==null&&a!==null){for(let[r,d]of y(a)){if(r==="SKILL.md")continue;let u=G(e,r);if(u===null)continue;let v=await p(u);if(v!==null&&h(v)!==d)return"user_edited"}if(i===null)return"pristine";if(s.hash!==null&&h(i)===s.hash)return"pristine";return m(i).kind==="valid_generated"?"pristine":"user_edited"}if(i===null)return"foreign";let o=m(i);if(o.kind==="valid_generated")return"pristine";if(o.kind==="invalid_generated")return"user_edited";return"foreign"}function G(e,t){let i=ee(c(e,...t.split("/")));if(i!==e&&i.startsWith(`${e}${te}`))return i;return null}async function ie(e){let t=await p(c(e,f)),i=new Set;if(t!==null)for(let a of y(t).keys()){let s=G(e,a);if(s===null)continue;await l(s,{force:!0}),await l(`${s}.backup`,{force:!0});let o=P(s);if(o!==e)i.add(o)}await l(c(e,"SKILL.md"),{force:!0}),await l(c(e,f),{force:!0}),await l(c(e,F),{force:!0});for(let a of[...i].sort((s,o)=>o.length-s.length))await T(a);await T(e)}async function T(e){try{await D(e)}catch(t){let i=t.code;if(i!=="ENOENT"&&i!=="ENOTEMPTY")throw t}}async function se(e){try{return(await H(e,{withFileTypes:!0})).filter((i)=>i.isDirectory()).map((i)=>i.name).sort()}catch(t){if(t.code==="ENOENT")return[];throw t}}async function p(e){try{return await M(e,"utf8")}catch(t){if(t.code==="ENOENT")return null;throw t}}async function E(e){for(let[t,i]of e.files){let a=c(e.root,`${t}.md`),s=c(e.root,`.${t}.flow-version`),o=await p(a),n=await p(s),r=n===null?null:k(n,e.kind,t);if(o===null&&r===null)continue;if(!(r!==null||o===i))continue;if(o!==null&&r!==null&&h(o)!==r.hash){e.keptUserEdited.push(a),e.logger?.(`Kept user-edited Flow ${e.kind} at ${a}; remove it manually if it is no longer needed.`);continue}if(!e.dryRun)await l(a,{force:!0}),await l(s,{force:!0}),await l(`${a}.backup`,{force:!0});e.removed.push(a),e.logger?.(`${e.dryRun?"Would remove":"Removed"} Flow ${e.kind} at ${a}.`)}if(!e.dryRun)await T(e.root)}var I=`opencode-plugin-flow — Flow plugin lifecycle commands
8
17
 
9
18
  Usage:
10
19
  bunx opencode-plugin-flow uninstall [--dry-run]
@@ -17,10 +26,10 @@ Commands:
17
26
 
18
27
  Options:
19
28
  --dry-run Show what would be removed without deleting anything
20
- --help Show this message`;function y(j){process.stdout.write(`${j}
21
- `)}function G(j){process.stderr.write(`${j}
22
- `)}async function n(j){let q=[...j];if(q.length===0||q.includes("--help")||q.includes("-h"))return y(b),0;let z=q.shift();if(z!=="uninstall")return G(`Unknown command: ${z}
29
+ --help Show this message`;function L(e){process.stdout.write(`${e}
30
+ `)}function q(e){process.stderr.write(`${e}
31
+ `)}async function ne(e){let t=[...e];if(t.length===0||t.includes("--help")||t.includes("-h"))return L(I),0;let i=t.shift();if(i!=="uninstall")return q(`Unknown command: ${i}
23
32
 
24
- ${b}`),1;let B=!1;for(let J of q){if(J==="--dry-run"){B=!0;continue}return G(`Unknown argument: ${J}
33
+ ${I}`),1;let a=!1;for(let s of t){if(s==="--dry-run"){a=!0;continue}return q(`Unknown argument: ${s}
25
34
 
26
- ${b}`),1}return await E({homeDir:process.env.HOME??a(),dryRun:B,logger:y}),0}try{process.exitCode=await n(process.argv.slice(2))}catch(j){G(j instanceof Error?j.message:String(j)),process.exitCode=1}
35
+ ${I}`),1}return await Y({homeDir:process.env.HOME??oe(),dryRun:a,logger:L}),0}try{process.exitCode=await ne(process.argv.slice(2))}catch(e){q(e instanceof Error?e.message:String(e)),process.exitCode=1}