diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e553007..7e4e689 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "genesys-cloud-architect", "source": "./", "description": "Create, test, and debug Genesys Cloud Architect flows", - "version": "1.0.8" + "version": "1.0.9" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7adfcd8..2796ca9 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "genesys-cloud-architect", "description": "Create, test, and debug Genesys Cloud Architect flows", - "version": "1.0.8", + "version": "1.0.9", "author": { "name": "Lucas Woodward", "url": "https://makingchatbots.com/" diff --git a/.mcp.dev.json b/.mcp.dev.json new file mode 100644 index 0000000..08df76b --- /dev/null +++ b/.mcp.dev.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "genesys-cloud-architect-mcp": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/servers/genesys-cloud-architect-mcp.js"], + "env": { + "GENESYS_REGION": "${GENESYS_REGION}", + "GENESYS_CLIENT_ID": "${GENESYS_CLIENT_ID}", + "GENESYS_CLIENT_SECRET": "${GENESYS_CLIENT_SECRET}", + "DEPLOY_SCRIPT_PATH": "${CLAUDE_PLUGIN_ROOT}/scripts/deploy-runner.js" + } + } + } +} diff --git a/.mcp.json b/.mcp.json index b712a38..a5426b2 100644 --- a/.mcp.json +++ b/.mcp.json @@ -6,7 +6,7 @@ "env": { "GENESYS_REGION": "${user_config.genesys_region}", "GENESYS_CLIENT_ID": "${user_config.genesys_client_id}", - "GENESYS_CLIENT_SECRET": "${user_config.genesys_client_secret}", + "GENESYS_CLIENT_SECRET": "${user_config.genesys_client_secret}", "DEPLOY_SCRIPT_PATH": "${CLAUDE_PLUGIN_ROOT}/scripts/deploy-runner.js" } } diff --git a/README.md b/README.md index d75dd18..d1cac83 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,121 @@ -# Genesys Cloud Architect Claude Code Plugin +# Claude Code Plugin: Genesys Cloud Architect -Create, debug and test Genesys Cloud Architect Flows using Claude Code. +
+
+
+
+[Read more...](https://makingchatbots.com/i/200764669/create-your-flows-with-ai)
+
+### Run automated tests against Digital flows
+
+The plugin allows Claude Code to run tests against Digital bot flows. This is useful when
+it's developing flows, or simply to test for edge-cases in existing flows:
+
+> Inspect the 'Bank bot' flow and run tests against it to ensure it behaves as expected.
+
+
+
+### Create and test flow expressions
+
+> My Genesys Architect flow needs to extract the 'author' from the JSON retrieved from a participant attribute below:
+>
+> { "newsletter": {"makingchatbots": {"author": "Lucas Woodward "}}
+>
+> Create an expression that returns the value of 'author'. However, if the property (or any of the parent properties) do not exist then return an empty value.
+>
+> Create a Digital Bot flow to test your expression against different test cases.
+
+
+
+
+[Read more...](https://makingchatbots.com/i/200764669/create-and-test-expressions)
+
+### Inspect and fix issues in flows
+
+TODO Add example
+
+### Document flows
+
+TODO Add example
## Installation
-```
-# Add the marketplace
-/plugin marketplace add MakingChatbots/genesys-cloud-plugins
+1. Open Claude Code
+2. Type the following to add the marketplace and install the plugin:
+ 1. Add the marketplace
+ ```
+ /plugin marketplace add MakingChatbots/genesys-cloud-plugins
+ ```
-# Install the plugin
-/plugin install genesys-cloud-architect@makingchatbots-genesys-cloud-plugins
-```
+ 2. Install the plugin
+ ```
+ /plugin install genesys-cloud-architect@makingchatbots-genesys-cloud-plugins
+ ```
+3. When asked, provide the Credentials for an OAuth Client with the following permissions:
+ * `Architect > Flow > *`
+ * `Architect > Job > *`
+ * `Architect > UI > *`
+ * `Language Understanding > NLU Domain Version > View`
+ * `Textbots > *`
+ * `Architect > Dependency Tracking > View`
-## Getting Started
+## Who built this?
-Once you've installed the plugin you can start working with Claude Code to create your Architect flows.
+This is built by [Lucas Woodward](https://makingchatbots.com/about#§who-am-i).
-Try some of the following examples:
+I've been building this in public - engaging with the Genesys community with each milestone. If you'd like to keep up
+to date with releases then [follow me on LinkedIn](https://www.linkedin.com/in/lucas-woodward-the-dev/).
-> Create a Digital Chatbot that asks the customer for their name, then welcomes them by their name.
+What else I have built:
+* [Genesys Cloud MCP Server](https://github.com/MakingChatbots/genesys-cloud-mcp-server)
+* [Genesys Cloud n8n community node](https://github.com/MakingChatbots/n8n-nodes-genesys-cloud)
+* [Genesys Cloud Chatbot Tester](https://github.com/MakingChatbots/genesys-cloud-chatbot-tester)
+* _many more on [my newsletter...](https://makingchatbots.com/)_
## Development
Docs to help understand how this works, or contribute:
* [docs/development.md](docs/development.md)
-* [docs/architecture.md](docs/architecture.md)
+* [docs/architectural-decisions.md](docs/architectural-decisions.md)
diff --git a/docs/architecture.md b/docs/architectural-decisions.md
similarity index 100%
rename from docs/architecture.md
rename to docs/architectural-decisions.md
diff --git a/docs/assets/expression-result.png b/docs/assets/expression-result.png
new file mode 100644
index 0000000..b36ac92
Binary files /dev/null and b/docs/assets/expression-result.png differ
diff --git a/docs/assets/flow.png b/docs/assets/flow.png
new file mode 100644
index 0000000..61cde9f
Binary files /dev/null and b/docs/assets/flow.png differ
diff --git a/docs/assets/logo.png b/docs/assets/logo.png
new file mode 100644
index 0000000..5bd3df0
Binary files /dev/null and b/docs/assets/logo.png differ
diff --git a/docs/assets/running-tests.png b/docs/assets/running-tests.png
new file mode 100644
index 0000000..16b7592
Binary files /dev/null and b/docs/assets/running-tests.png differ
diff --git a/docs/development.md b/docs/development.md
index e800cae..be5d735 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -3,15 +3,28 @@
Test the plugin locally in the Claude Code CLI:
```shell
-CLAUDE_PLUGIN_ROOT=$(pwd) claude --plugin-dir .
+CLAUDE_PLUGIN_ROOT=$(pwd) claude --plugin-dir . --mcp-config ./.mcp.dev.json --debug
```
-Debug the plugin:
+Notable options:
+ * `--mcp-config ./.mcp.dev.json` - loads the MCP using a environment vars, rather than User Config keys that aren't
+ supported when running the plugin locally
+ * `--debug` - writes out logs that can be inspected if there is a problem
+
+## Run with 1Password CLI
+
+During development if you don't want the .env to contain secrets then 1Password's CLI can be used to
+expand a placeholder:
```shell
-CLAUDE_PLUGIN_ROOT=$(pwd) claude --plugin-dir . --debug
+CLAUDE_PLUGIN_ROOT=$(pwd) op run --env-file='./.env' --no-masking -- claude --plugin-dir . --debug --mcp-config ./.mcp.dev.json
```
+Notable options:
+ * `--no-masking` - By default `op run` pipes the child's stdio to redact secrets, which makes Claude think it is being
+ scripted. This option prevents the redaction piping.
+
+
To aid in the development of the MCP server install the MCP Server Skill:
```
diff --git a/package.json b/package.json
index eef2ba2..60ae326 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "genesys-cloud-architect",
- "version": "1.0.8",
+ "version": "1.0.9",
"private": true,
"packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d",
"author": {
@@ -18,14 +18,13 @@
"scripts": {
"build": "pnpm run build:mcp-server && pnpm run build:deploy-runner",
"build:mcp-server": "esbuild src/mcp-server/index.ts --bundle --platform=node --target=node22 --format=cjs --minify --tree-shaking=true --define:process.env.npm_package_version=\\\"$npm_package_version\\\" --outfile=servers/genesys-cloud-architect-mcp.js",
- "build:deploy-runner": "esbuild src/deploy-runner/index.ts --bundle --platform=node --target=node22 --format=cjs --outfile=bin/deploy-runner.js",
+ "build:deploy-runner": "esbuild src/deploy-runner/index.ts --bundle --platform=node --target=node22 --format=cjs --outfile=scripts/deploy-runner.js",
"typecheck": "tsc --noEmit",
"lint": "biome check",
"lint:fix": "biome check --write",
"format": "biome format --write",
"inspect-mcp": "pnpm dlx @modelcontextprotocol/inspector node servers/genesys-cloud-architect-mcp.js",
"mcp-server": "node --experimental-strip-types src/mcp-server/index.ts",
- "mcp-server-with-op": "op run --env-file='./.env' -- node --experimental-strip-types src/mcp-server/index.ts",
"prepare": "husky"
},
"dependencies": {
diff --git a/servers/genesys-cloud-architect-mcp.js b/servers/genesys-cloud-architect-mcp.js
index f885a35..98b0a63 100644
--- a/servers/genesys-cloud-architect-mcp.js
+++ b/servers/genesys-cloud-architect-mcp.js
@@ -157,7 +157,7 @@ ${s.warnings.join(`
`)}`),l({content:[{type:"text",text:f.join(`
`)+m}]})}else{let f=s?.error??`Deploy runner exited with code ${h}`;l({isError:!0,content:[{type:"text",text:`Deploy failed: ${f}${m}`}]})}})})}});var vt=t=>`${t}::start`,ws=(t,e)=>`${t}::${e}`;function AI(t,e,i,n){let a=new Set(e.backEdges.map(h=>`${h.from} ${h.to}`)),r=new Map,s=new Map,o=new Map,l=new Map;for(let h of t.nodes.keys())r.set(h,[]),s.set(h,new Set),o.set(h,[]),l.set(h,new Set);for(let h of t.edges){let g=a.has(`${h.from} ${h.to}`),m=r.get(h.to);if(m){let w=`${h.from} ${h.label??""} ${g}`,b=s.get(h.to);b&&!b.has(w)&&(b.add(w),m.push({id:h.from,label:h.label,backEdge:g}))}let f=o.get(h.from);if(f){let w=`${h.to} ${h.label??""} ${g}`,b=l.get(h.from);b&&!b.has(w)&&(b.add(w),f.push({id:h.to,label:h.label,backEdge:g}))}}let u=-1;for(let h of e.order.values())h>u&&(u=h);let c=u+1,p=[];for(let[h,g]of t.nodes){let m=e.order.has(h),f=m?e.order.get(h):c++;p.push({id:h,kind:g.kind,actionType:g.actionType,label:g.label,description:g.description,predecessors:r.get(h)??[],successors:o.get(h)??[],order:f,taskId:g.taskId,taskName:g.taskName,reachable:m,terminal:g.terminal})}p.sort((h,g)=>h.order-g.order);let d=i.taskOrder.flatMap(h=>{let g=i.tasks.get(h);return g?[{id:h,name:g.name,reusable:i.reusableTaskIds.has(h)}]:[]});return{flowName:i.flowName,flowType:i.flowType,entryTaskId:i.entryTaskId,reachabilityIsComplete:n,tasks:d,nodes:p}}function er(t,e){let i=t;for(let n of e.split(".")){if(i==null||typeof i!="object")return;i=i[n]}return typeof i=="string"&&i.length>0?i:void 0}function it(t,e){for(let i of e){let n=er(t,i);if(n!==void 0)return n}}function bI(t){let e=t.expression;if(e==null||typeof e!="object")return;let{text:i,type:n}=e;if(typeof i=="string"&&i.trim().length>0)return i;if(typeof n=="string"&&n.length>0)return n}function Db(t,e){let i=t.find(n=>n.outputId===e);return i?(i.terminal=!0,t):[{outputId:e,label:Gb(e),synthetic:!0,terminal:!0},...t]}var Tl={isTerminal:()=>!0,outputs:()=>[]},yI={isTerminal:()=>!0,describe:t=>it(t,["transferTo","group.text","flowName","externalNumber.text","user.text","transferTarget.text","transferTargetGroup.text","queues.0.text"]),outputs:t=>Db(vs(t).map(ir),"__SUCCESS__")},k7={isTerminal:()=>!1,outputs:t=>{let e=t.path;if(!e||typeof e!="object")return[];let i=ir(e,0);return i.outputId="__LOOP__",typeof e.label=="string"&&e.label.length>0||(i.label="Loop"),[i]}},q7={describe:()=>"Exits the innermost enclosing loop; control continues at the loop's next action (jump edge not modelled \u2014 this node's successors omit it)",outputs:()=>[]},_7={outputs:t=>Db(vs(t).map(ir),"__SUCCESS__")},H7={isTerminal:()=>!0,describe:t=>it(t,["flowName"]),outputs:t=>Db(vs(t).map(ir),"__DISCONNECT__")},I7={outputs:t=>{let e=vs(t);return e.length>0?[ir(e[0],0)]:[]}},Ni=t=>({describe:t}),id={DisconnectAction:Tl,EndFlowAction:Tl,EndTaskAction:Tl,EndStateAction:Tl,ExitBotFlowAction:Tl,LoopAction:k7,ExitLoopAction:q7,ProcessVoicemailInputAction:_7,CallBotFlowAction:H7,AskForNLUNextIntentAction:I7,WaitForInputAction:{},CallTaskAction:{},TaskAction:{},TransferMenuAction:{},TransferTaskAction:{},LoopUntilAction:{},DecisionAction:Ni(bI),SwitchAction:Ni(bI),DataAction:Ni(t=>it(t,["actionName"])),DataTableLookupAction:Ni(t=>it(t,["datatableName"])),CallCommonModuleAction:Ni(t=>it(t,["flowName"])),CallBotConnectorAction:Ni(t=>it(t,["botName"])),CallDigitalBotFlowAction:Ni(t=>it(t,["flowName"])),AddFlowMilestoneAction:Ni(t=>it(t,["milestoneName"])),ScreenPopAction:Ni(t=>it(t,["inputs.0.value.text"])),PlayAudioAction:Ni(t=>it(t,["prompts.defaultAudio.text"])),SetFlowOutcomeAction:Ni(t=>{let e=er(t,"outcomeName"),i=er(t,"outcomeValue.text");return e&&i?`${e} = ${i}`:e??i}),InitializeFlowOutcomeAction:Ni(t=>er(t,"outcomeName")),EvaluateScheduleGroupAction:Ni(t=>er(t,"scheduleGroup.text")),FindSystemPromptAction:Ni(t=>er(t,"findName.text")),SetUUIDataAction:Ni(t=>er(t,"uuiData.text")),UpdateVariableAction:{},SetAttributesAction:{},GetAttributesAction:{},EvaluateScheduleAction:{},CollectInputAction:{},GetResponseAction:{},AskForBooleanAction:{},AskForSlotAction:{},CommunicateAction:{},DigitalMenuAction:{},SendResponseAction:{},TranscriptionAction:{},FindQueueAction:{},FindUserPromptAction:{},SetWhisperAudio:{},BridgeServerAction:{}};function nd(t){return typeof t=="string"&&t.length>0}function vs(t){let e=[];if(Array.isArray(t.paths))for(let i of t.paths)i&&typeof i=="object"&&e.push(i);return t.path&&typeof t.path=="object"&&e.push(t.path),e}function Gb(t){let e=t.replace(/^__/,"").replace(/__$/,"");if(e.length===0)return t;let i=e.toLowerCase();return i.charAt(0).toUpperCase()+i.slice(1)}function ir(t,e){let i=t.outputId??`__OUTPUT_${e}__`,n=typeof t.label=="string"&&t.label.length>0?t.label:Gb(i),a={outputId:i,label:n,nextActionId:t.nextActionId,synthetic:!1,terminal:!1};return t.enabled===!1&&(a.disabled=!0),a}var R7=["taskReference","menuReference"],PI={describe:()=>{},outputs:t=>vs(t).map(ir),isTerminal:()=>!1,referencedTaskIds:t=>{let e=[];for(let i of R7){let n=t[i];typeof n=="string"&&n.length>0&&e.push(n)}return e}},z7=new Set(["TransferMenuAction","TransferTaskAction"]);function jI(t){return typeof t=="string"&&t.startsWith("Transfer")&&!z7.has(t)}function SI(t){return t?t in id||jI(t):!1}function OI(t){let e=jI(t)?{...PI,...yI}:PI,i=t&&t in id?id[t]:void 0;return i?{...e,...i}:e}function*td(t){for(let e of t.taskOrder){let i=t.tasks.get(e);if(i)for(let n of i.orderedActions)typeof n.id=="string"&&(yield{taskId:e,task:i,action:n,actionId:n.id,handler:OI(n.__type)})}}function TI(t){let e=[],i=[];for(let n of t.taskOrder){let a=t.tasks.get(n);if(a){nd(a.startAction)&&e.push({from:vt(n),to:a.startAction,kind:"fallthrough"});for(let r of a.menuChoices)e.push({from:vt(n),to:r.actionId,kind:"reference",label:r.label})}}for(let{taskId:n,actionId:a,action:r,handler:s}of td(t)){let o=s.isTerminal(r);nd(r.nextAction)&&!o&&e.push({from:a,to:r.nextAction,kind:"fallthrough"});for(let l of s.outputs(r)){let u=ws(a,l.outputId);e.push({from:a,to:u,kind:"branch",label:l.label}),nd(l.nextActionId)&&!l.terminal&&e.push({from:u,to:l.nextActionId,kind:l.outputId==="__LOOP__"?"loop":"branch"})}for(let l of s.referencedTaskIds(r)){let u=t.tasks.get(l);u?e.push({from:a,to:vt(l),kind:"reference",label:u.name}):i.push({code:"UNRESOLVED_REFERENCE",message:`Reference from ${a} to task ${l} is unresolvable.`,nodeId:a,taskId:n})}}return{edges:e,warnings:i}}function xI(t,e,i){let n=new Map;for(let u of t)n.set(u.id,u);let a=new Map;for(let u of n.keys())a.set(u,[]);let r=[],s=[],o=[];for(let u of e){let c=n.has(u.from),p=n.has(u.to);if(!c||!p){let d=c?`unknown target node "${u.to}"`:`unknown source node "${u.from}"`;s.push({...u,reason:d}),o.push({code:"DROPPED_EDGE",message:`Dropped ${u.kind} edge ${u.from} \u2192 ${u.to}: ${d}.`});continue}r.push(u),a.get(u.from)?.push(u.to)}let l=i.taskOrder.map(vt).filter(u=>n.has(u));return{graph:{nodes:n,edges:r,adjacency:a,roots:l,dropped:s},warnings:o}}function D7(t,e,i){let n=[],a=[];if(Array.isArray(t.actionList))for(let r of t.actionList)r&&typeof r=="object"&&n.push(r);if(Array.isArray(t.menuChoiceList))for(let r of t.menuChoiceList){let s=r?.action;if(!s||typeof s!="object")continue;if(typeof s.id!="string"){i.push({code:"MISSING_ACTION_ID",message:`Menu-choice action of type "${s.__type??"(missing)"}" in task ${e} has no id and was skipped.`,taskId:e});continue}n.push(s);let o=typeof r.name=="string"&&r.name.length>0?r.name:s.name??s.id;a.push({actionId:s.id,label:o})}return{orderedActions:n,menuChoices:a}}function G7(t){if(typeof t=="string")return t;if(t&&typeof t=="object"){let e=t.id;if(typeof e=="string")return e}}function MI(t){let e=[],i=new Map,n=Array.isArray(t.flowSequenceItemList)?t.flowSequenceItemList:[];for(let p of n){if(!p||typeof p!="object"||typeof p.id!="string")continue;let d=typeof p.name=="string"&&p.name.length>0?p.name:p.id,{orderedActions:h,menuChoices:g}=D7(p,p.id,e);i.set(p.id,{id:p.id,name:d,startAction:typeof p.startAction=="string"?p.startAction:void 0,orderedActions:h,menuChoices:g})}let a=new Set;for(let{id:p,orderedActions:d}of i.values())for(let h of d){if(typeof h.id!="string"){e.push({code:"MISSING_ACTION_ID",message:`Action of type "${h.__type??"(missing)"}" in task ${p} has no id and was skipped.`,taskId:p});continue}a.has(h.id)&&e.push({code:"DUPLICATE_ACTION_ID",message:`Duplicate action id "${h.id}" in task ${p}; the later definition wins.`,nodeId:h.id,taskId:p}),a.add(h.id)}let r=[...i.keys()],s=t.initialSequence,o=typeof s=="string"&&i.has(s),l=o?[s,...r.filter(p=>p!==s)]:r;typeof s=="string"&&s.length>0&&!o&&e.push({code:"UNRESOLVED_INITIAL_SEQUENCE",message:`initialSequence "${s}" matches no task; using declaration order.`});let u=new Set,c=t.uiMetaData?.task;if(Array.isArray(c))for(let p of c){let d=G7(p);d!==void 0&&u.add(d)}return{flowName:typeof t.name=="string"?t.name:"",flowType:typeof t.type=="string"?t.type:"",tasks:i,taskOrder:l,entryTaskId:o?s:void 0,reusableTaskIds:u,warnings:e}}var $7=new Set(["AskForNLUNextIntentAction","WaitForInputAction"]);function EI(t){let e=[],i=[];for(let n of t.taskOrder){let a=t.tasks.get(n);a&&e.push({id:vt(n),kind:"task-start",label:a.name,taskId:n,taskName:a.name,terminal:!1})}for(let{taskId:n,task:a,action:r,actionId:s,handler:o}of td(t)){let l=r.__type,u=typeof r.name=="string"&&r.name.length>0?r.name:l??s;e.push({id:s,kind:"action",actionType:l,label:u,description:o.describe(r),taskId:n,taskName:a.name,terminal:o.isTerminal(r)}),SI(l)||i.push({code:"UNKNOWN_ACTION_TYPE",message:`Unknown action __type "${l??"(missing)"}" \u2014 handled generically.`,nodeId:s,taskId:n}),$7.has(l??"")&&i.push({code:"UNRESOLVED_INTENT_FANOUT",message:`Intent fan-out for ${s} is not resolved: per-intent routing (nluMetaData) is not modelled.`,nodeId:s,taskId:n});for(let c of o.outputs(r))e.push({id:ws(s,c.outputId),kind:"branch-output",label:c.label,taskId:n,taskName:a.name,terminal:c.terminal}),c.disabled&&i.push({code:"DISABLED_BRANCH",message:`Output ${c.outputId} of ${s} is disabled.`,nodeId:ws(s,c.outputId),taskId:n})}return{nodes:e,warnings:i}}function $b(t){let e=new Map;for(let s of t.nodes.keys())e.set(s,0);let i=new Map,n=[],a=0;for(let s of t.roots){if(e.get(s)!==0)continue;e.set(s,1),i.set(s,a++);let o=[{node:s,index:0}];for(;o.length>0;){let l=o[o.length-1],u=t.adjacency.get(l.node)??[];if(l.index