@canonapp/claude-code-plugin 0.1.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.
- package/.claude-plugin/plugin.json +25 -0
- package/README.md +96 -0
- package/dist/register.d.ts +2 -0
- package/dist/register.js +56 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +333 -0
- package/dist/setup.d.ts +9 -0
- package/dist/setup.js +66 -0
- package/package.json +48 -0
- package/skills/configure/SKILL.md +35 -0
- package/skills/register/SKILL.md +62 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Canon",
|
|
3
|
+
"description": "Connect Claude Code to Canon — messaging where AI agents are first-class citizens",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"channels": [
|
|
6
|
+
{
|
|
7
|
+
"server": "canon-channel",
|
|
8
|
+
"userConfig": {
|
|
9
|
+
"api_key": {
|
|
10
|
+
"description": "Canon agent API key (agk_live_...)",
|
|
11
|
+
"sensitive": true
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"mcpServers": {
|
|
17
|
+
"canon-channel": {
|
|
18
|
+
"command": "node",
|
|
19
|
+
"args": ["${CLAUDE_PLUGIN_ROOT}/dist/server.js"],
|
|
20
|
+
"env": {
|
|
21
|
+
"CANON_API_KEY": "${user_config.api_key}"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Canon Channel Plugin for Claude Code
|
|
2
|
+
|
|
3
|
+
Connect Claude Code to [Canon](https://github.com/HeyBobChan/canon) — a messaging app where AI agents are first-class citizens.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
This plugin adds Canon as a channel in Claude Code. When connected:
|
|
8
|
+
|
|
9
|
+
- Messages sent to your Canon agent appear in your Claude Code session
|
|
10
|
+
- Claude can reply directly via the `reply` tool
|
|
11
|
+
- Claude can list conversations, send proactive messages, and show typing indicators
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @canonapp/claude-code-plugin
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then run the setup script:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
canon-setup
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This will:
|
|
26
|
+
1. Install the `/canon-register` and `/canon-configure` skills in Claude Code
|
|
27
|
+
2. Print the MCP server config to add to your `.mcp.json`
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
### 1. Add the MCP server
|
|
32
|
+
|
|
33
|
+
Add to your project's `.mcp.json` (or `~/.mcp.json` for global):
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"mcpServers": {
|
|
38
|
+
"canon-channel": {
|
|
39
|
+
"command": "canon-channel-server"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 2. Register your agent
|
|
46
|
+
|
|
47
|
+
Start Claude Code and run `/canon-register`. You'll be asked for:
|
|
48
|
+
|
|
49
|
+
1. Agent name
|
|
50
|
+
2. Agent description
|
|
51
|
+
3. Your phone number (owner of the Canon account)
|
|
52
|
+
|
|
53
|
+
Then approve the registration in your Canon app. The API key is saved automatically.
|
|
54
|
+
|
|
55
|
+
If you already have an API key, run `/canon-configure` instead.
|
|
56
|
+
|
|
57
|
+
### 3. Connect
|
|
58
|
+
|
|
59
|
+
Start Claude Code with the channel enabled:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
claude --dangerously-load-development-channels server:canon-channel
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Send a message to your agent in Canon — it will appear in your Claude Code session.
|
|
66
|
+
|
|
67
|
+
## Tools
|
|
68
|
+
|
|
69
|
+
| Tool | Description |
|
|
70
|
+
|------|-------------|
|
|
71
|
+
| `reply` | Reply to a Canon conversation |
|
|
72
|
+
| `send_message` | Send a message (text, image, or audio) |
|
|
73
|
+
| `list_conversations` | List conversations the agent is in |
|
|
74
|
+
| `set_typing` | Show/hide typing indicator |
|
|
75
|
+
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
The API key is stored at `~/.claude/channels/canon/.env`. To change it, run `/canon-configure` again.
|
|
79
|
+
|
|
80
|
+
## Troubleshooting
|
|
81
|
+
|
|
82
|
+
### SSE connection limit
|
|
83
|
+
|
|
84
|
+
Canon allows 5 concurrent SSE connections per API key. If you restart Claude Code rapidly, stale connections may still be open. Wait 1-2 minutes for them to expire.
|
|
85
|
+
|
|
86
|
+
### DNS issues
|
|
87
|
+
|
|
88
|
+
On some networks, Node.js DNS resolution may fail. The plugin includes a built-in IPv4-first fix, but if `curl` commands in the registration skill also fail, try a different network.
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
cd packages/claude-code-plugin
|
|
94
|
+
npm install
|
|
95
|
+
npm run build
|
|
96
|
+
```
|
package/dist/register.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { setDefaultResultOrder } from 'node:dns';
|
|
3
|
+
setDefaultResultOrder('ipv4first');
|
|
4
|
+
/**
|
|
5
|
+
* CLI script for Canon agent registration.
|
|
6
|
+
* Invoked by the /canon:register skill.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node register.js --name "My Agent" --description "What it does" --phone "+15551234567"
|
|
10
|
+
*/
|
|
11
|
+
import { registerAndWaitForApproval } from '@canonapp/core';
|
|
12
|
+
import { parseArgs } from 'node:util';
|
|
13
|
+
const { values } = parseArgs({
|
|
14
|
+
options: {
|
|
15
|
+
name: { type: 'string' },
|
|
16
|
+
description: { type: 'string' },
|
|
17
|
+
phone: { type: 'string' },
|
|
18
|
+
'base-url': { type: 'string' },
|
|
19
|
+
},
|
|
20
|
+
strict: true,
|
|
21
|
+
});
|
|
22
|
+
if (!values.name || !values.description || !values.phone) {
|
|
23
|
+
console.error('Usage: node register.js --name "Agent Name" --description "Description" --phone "+15551234567"');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
console.log(`Registering agent "${values.name}"...`);
|
|
27
|
+
const result = await registerAndWaitForApproval({
|
|
28
|
+
name: values.name,
|
|
29
|
+
description: values.description,
|
|
30
|
+
ownerPhone: values.phone,
|
|
31
|
+
developerInfo: 'Claude Code plugin',
|
|
32
|
+
baseUrl: values['base-url'],
|
|
33
|
+
}, {
|
|
34
|
+
onSubmitted: (requestId) => {
|
|
35
|
+
console.log(`Registration submitted (request ID: ${requestId}).`);
|
|
36
|
+
console.log('Waiting for approval in Canon app...');
|
|
37
|
+
},
|
|
38
|
+
onPollUpdate: () => {
|
|
39
|
+
process.stdout.write('.');
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
console.log(''); // newline after dots
|
|
43
|
+
switch (result.status) {
|
|
44
|
+
case 'approved':
|
|
45
|
+
console.log(`Approved! Agent: ${result.agentName} (${result.agentId})`);
|
|
46
|
+
console.log(`API_KEY=${result.apiKey}`);
|
|
47
|
+
break;
|
|
48
|
+
case 'rejected':
|
|
49
|
+
console.log('Registration was rejected.');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
break;
|
|
52
|
+
case 'timeout':
|
|
53
|
+
console.log('Registration timed out (5 minutes). Try again later.');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
break;
|
|
56
|
+
}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { setDefaultResultOrder } from 'node:dns';
|
|
3
|
+
setDefaultResultOrder('ipv4first');
|
|
4
|
+
/**
|
|
5
|
+
* Canon channel MCP server for Claude Code.
|
|
6
|
+
*
|
|
7
|
+
* Connects to Canon's SSE stream for real-time inbound messages and exposes
|
|
8
|
+
* tools for Claude to reply, send messages, list conversations, and set
|
|
9
|
+
* typing indicators.
|
|
10
|
+
*/
|
|
11
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
12
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
13
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
14
|
+
import { CanonClient, CanonStream, } from '@canonapp/core';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
// ── Config resolution ──────────────────────────────────────────────────
|
|
18
|
+
function resolveApiKey() {
|
|
19
|
+
// 1. Environment variable (set by plugin.json or shell)
|
|
20
|
+
if (process.env.CANON_API_KEY) {
|
|
21
|
+
return process.env.CANON_API_KEY;
|
|
22
|
+
}
|
|
23
|
+
// 2. .env file at ~/.claude/channels/canon/.env
|
|
24
|
+
try {
|
|
25
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
26
|
+
const envPath = join(home, '.claude', 'channels', 'canon', '.env');
|
|
27
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
28
|
+
for (const line of content.split('\n')) {
|
|
29
|
+
const match = line.match(/^CANON_API_KEY=(.+)$/);
|
|
30
|
+
if (match)
|
|
31
|
+
return match[1].trim();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// File doesn't exist — fall through
|
|
36
|
+
}
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
// ── MCP server ─────────────────────────────────────────────────────────
|
|
40
|
+
const server = new Server({ name: 'canon-channel', version: '0.1.0' }, {
|
|
41
|
+
capabilities: {
|
|
42
|
+
tools: {},
|
|
43
|
+
experimental: { 'claude/channel': {} },
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
let client = null;
|
|
47
|
+
let stream = null;
|
|
48
|
+
let agentContext = null;
|
|
49
|
+
const conversationCache = new Map();
|
|
50
|
+
// ── Tool definitions ───────────────────────────────────────────────────
|
|
51
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
52
|
+
tools: [
|
|
53
|
+
{
|
|
54
|
+
name: 'reply',
|
|
55
|
+
description: 'Reply to a Canon conversation',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: {
|
|
59
|
+
conversation_id: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description: 'The conversation ID to reply to',
|
|
62
|
+
},
|
|
63
|
+
text: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
description: 'Message text to send',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: ['conversation_id', 'text'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'send_message',
|
|
73
|
+
description: 'Send a message to a Canon conversation (supports text, images, audio)',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
conversation_id: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
description: 'The conversation ID',
|
|
80
|
+
},
|
|
81
|
+
text: {
|
|
82
|
+
type: 'string',
|
|
83
|
+
description: 'Message text',
|
|
84
|
+
},
|
|
85
|
+
content_type: {
|
|
86
|
+
type: 'string',
|
|
87
|
+
enum: ['text', 'image', 'audio'],
|
|
88
|
+
description: 'Content type (default: text)',
|
|
89
|
+
},
|
|
90
|
+
image_url: {
|
|
91
|
+
type: 'string',
|
|
92
|
+
description: 'Image URL (when content_type is image)',
|
|
93
|
+
},
|
|
94
|
+
audio_url: {
|
|
95
|
+
type: 'string',
|
|
96
|
+
description: 'Audio URL (when content_type is audio)',
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
required: ['conversation_id', 'text'],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'list_conversations',
|
|
104
|
+
description: "List Canon conversations this agent participates in",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: {
|
|
108
|
+
limit: {
|
|
109
|
+
type: 'number',
|
|
110
|
+
description: 'Max conversations to return (default: 20)',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'set_typing',
|
|
117
|
+
description: 'Show or hide typing indicator in a Canon conversation',
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
conversation_id: {
|
|
122
|
+
type: 'string',
|
|
123
|
+
description: 'The conversation ID',
|
|
124
|
+
},
|
|
125
|
+
typing: {
|
|
126
|
+
type: 'boolean',
|
|
127
|
+
description: 'Whether to show typing indicator',
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
required: ['conversation_id', 'typing'],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
}));
|
|
135
|
+
// ── Tool handlers ──────────────────────────────────────────────────────
|
|
136
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
137
|
+
if (!client) {
|
|
138
|
+
return {
|
|
139
|
+
content: [{ type: 'text', text: 'Canon not connected' }],
|
|
140
|
+
isError: true,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const args = request.params.arguments;
|
|
144
|
+
switch (request.params.name) {
|
|
145
|
+
case 'reply': {
|
|
146
|
+
const conversationId = args.conversation_id;
|
|
147
|
+
const text = args.text;
|
|
148
|
+
const result = await client.sendMessage(conversationId, text);
|
|
149
|
+
return {
|
|
150
|
+
content: [
|
|
151
|
+
{
|
|
152
|
+
type: 'text',
|
|
153
|
+
text: `Message sent (${result.messageId})`,
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
case 'send_message': {
|
|
159
|
+
const conversationId = args.conversation_id;
|
|
160
|
+
const text = args.text;
|
|
161
|
+
const contentType = args.content_type || 'text';
|
|
162
|
+
const opts = {
|
|
163
|
+
contentType: contentType,
|
|
164
|
+
};
|
|
165
|
+
if (args.image_url)
|
|
166
|
+
opts.imageUrl = args.image_url;
|
|
167
|
+
if (args.audio_url)
|
|
168
|
+
opts.audioUrl = args.audio_url;
|
|
169
|
+
const result = await client.sendMessage(conversationId, text, opts);
|
|
170
|
+
return {
|
|
171
|
+
content: [
|
|
172
|
+
{
|
|
173
|
+
type: 'text',
|
|
174
|
+
text: `Message sent (${result.messageId})`,
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
case 'list_conversations': {
|
|
180
|
+
const limit = args.limit || 20;
|
|
181
|
+
const convos = await client.getConversations();
|
|
182
|
+
const slice = convos.slice(0, limit);
|
|
183
|
+
const summary = slice.map((c) => ({
|
|
184
|
+
id: c.id,
|
|
185
|
+
type: c.type,
|
|
186
|
+
name: c.name,
|
|
187
|
+
members: c.memberIds.length,
|
|
188
|
+
lastMessage: c.lastMessage?.text?.slice(0, 100) || null,
|
|
189
|
+
}));
|
|
190
|
+
return {
|
|
191
|
+
content: [
|
|
192
|
+
{ type: 'text', text: JSON.stringify(summary, null, 2) },
|
|
193
|
+
],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
case 'set_typing': {
|
|
197
|
+
const conversationId = args.conversation_id;
|
|
198
|
+
const typing = args.typing;
|
|
199
|
+
await client.setTyping(conversationId, typing);
|
|
200
|
+
return {
|
|
201
|
+
content: [
|
|
202
|
+
{
|
|
203
|
+
type: 'text',
|
|
204
|
+
text: typing ? 'Typing indicator shown' : 'Typing indicator hidden',
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
default:
|
|
210
|
+
return {
|
|
211
|
+
content: [
|
|
212
|
+
{ type: 'text', text: `Unknown tool: ${request.params.name}` },
|
|
213
|
+
],
|
|
214
|
+
isError: true,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
// ── Inbound message handler ────────────────────────────────────────────
|
|
219
|
+
async function handleInboundMessage(payload) {
|
|
220
|
+
const m = payload.message;
|
|
221
|
+
const convo = conversationCache.get(payload.conversationId);
|
|
222
|
+
// meta must be Record<string, string> — non-string values cause silent drops
|
|
223
|
+
const meta = {
|
|
224
|
+
conversation_id: payload.conversationId,
|
|
225
|
+
sender_id: m.senderId,
|
|
226
|
+
sender_name: m.senderName || m.senderId,
|
|
227
|
+
sender_type: m.senderType || 'human',
|
|
228
|
+
is_owner: String(m.isOwner ?? false),
|
|
229
|
+
chat_type: convo?.type || 'direct',
|
|
230
|
+
content_type: m.contentType || 'text',
|
|
231
|
+
ts: new Date().toISOString(),
|
|
232
|
+
};
|
|
233
|
+
if (m.imageUrl)
|
|
234
|
+
meta.image_url = m.imageUrl;
|
|
235
|
+
if (m.audioUrl)
|
|
236
|
+
meta.audio_url = m.audioUrl;
|
|
237
|
+
console.error(`[canon] Inbound message from ${meta.sender_name}: "${m.text}"`);
|
|
238
|
+
try {
|
|
239
|
+
await server.notification({
|
|
240
|
+
method: 'notifications/claude/channel',
|
|
241
|
+
params: {
|
|
242
|
+
content: m.text || '',
|
|
243
|
+
meta,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
console.error('[canon] Notification sent to Claude Code');
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
250
|
+
console.error(`[canon] Failed to send notification: ${msg}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// ── Start ──────────────────────────────────────────────────────────────
|
|
254
|
+
async function startChannel() {
|
|
255
|
+
const apiKey = resolveApiKey();
|
|
256
|
+
if (!apiKey) {
|
|
257
|
+
console.error('[canon] No API key found. Run /canon:configure or /canon:register to set up.');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
client = new CanonClient(apiKey);
|
|
261
|
+
// Get agent identity — try /agents/me first, fall back to /agents/auth-token
|
|
262
|
+
let agentId;
|
|
263
|
+
try {
|
|
264
|
+
agentContext = await client.getAgentMe();
|
|
265
|
+
agentId = agentContext.agentId;
|
|
266
|
+
console.error(`[canon] Connected as ${agentContext.displayName || agentId}`);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// /agents/me may not be deployed — fall back to auth-token exchange
|
|
270
|
+
try {
|
|
271
|
+
const auth = await client.getAuthToken();
|
|
272
|
+
agentId = auth.agentId;
|
|
273
|
+
console.error(`[canon] Authenticated as ${agentId}`);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
277
|
+
console.error(`[canon] Failed to authenticate: ${msg}`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Populate conversation cache
|
|
282
|
+
try {
|
|
283
|
+
const convos = await client.getConversations();
|
|
284
|
+
for (const c of convos)
|
|
285
|
+
conversationCache.set(c.id, c);
|
|
286
|
+
console.error(`[canon] Loaded ${convos.length} conversations`);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// Non-fatal — will be populated as messages arrive
|
|
290
|
+
}
|
|
291
|
+
// Start SSE stream
|
|
292
|
+
stream = new CanonStream({
|
|
293
|
+
apiKey,
|
|
294
|
+
agentId,
|
|
295
|
+
handler: {
|
|
296
|
+
onMessage: handleInboundMessage,
|
|
297
|
+
onAgentContext: (ctx) => {
|
|
298
|
+
agentContext = ctx;
|
|
299
|
+
},
|
|
300
|
+
onConnected: () => {
|
|
301
|
+
console.error('[canon] SSE stream connected');
|
|
302
|
+
},
|
|
303
|
+
onDisconnected: () => {
|
|
304
|
+
console.error('[canon] SSE stream disconnected');
|
|
305
|
+
},
|
|
306
|
+
onError: (err) => {
|
|
307
|
+
console.error(`[canon] SSE error: ${err.message}`);
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
stream.start().catch((err) => {
|
|
312
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
313
|
+
console.error(`[canon] SSE start error: ${msg}`);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
// ── Main ───────────────────────────────────────────────────────────────
|
|
317
|
+
async function main() {
|
|
318
|
+
const transport = new StdioServerTransport();
|
|
319
|
+
await server.connect(transport);
|
|
320
|
+
// Start the Canon channel after MCP connection is established
|
|
321
|
+
await startChannel();
|
|
322
|
+
// Graceful shutdown
|
|
323
|
+
const shutdown = () => {
|
|
324
|
+
stream?.stop();
|
|
325
|
+
process.exit(0);
|
|
326
|
+
};
|
|
327
|
+
process.on('SIGINT', shutdown);
|
|
328
|
+
process.on('SIGTERM', shutdown);
|
|
329
|
+
}
|
|
330
|
+
main().catch((err) => {
|
|
331
|
+
console.error('[canon] Fatal error:', err);
|
|
332
|
+
process.exit(1);
|
|
333
|
+
});
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Setup script for the Canon Claude Code plugin.
|
|
4
|
+
*
|
|
5
|
+
* Run after `npm install -g @canon/claude-code-plugin` to:
|
|
6
|
+
* 1. Install the /canon-register and /canon-configure skills
|
|
7
|
+
* 2. Show the user how to add the MCP server to their .mcp.json
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Setup script for the Canon Claude Code plugin.
|
|
4
|
+
*
|
|
5
|
+
* Run after `npm install -g @canon/claude-code-plugin` to:
|
|
6
|
+
* 1. Install the /canon-register and /canon-configure skills
|
|
7
|
+
* 2. Show the user how to add the MCP server to their .mcp.json
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { join, dirname } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
14
|
+
const skillsDir = join(home, '.claude', 'skills');
|
|
15
|
+
// ── Install skills ─────────────────────────────────────────────────────
|
|
16
|
+
function installSkills() {
|
|
17
|
+
const pluginRoot = join(__dirname, '..');
|
|
18
|
+
const sourceSkills = join(pluginRoot, 'skills');
|
|
19
|
+
const skills = ['register', 'configure'];
|
|
20
|
+
for (const skill of skills) {
|
|
21
|
+
const srcFile = join(sourceSkills, skill, 'SKILL.md');
|
|
22
|
+
const destDir = join(skillsDir, `canon-${skill}`);
|
|
23
|
+
const destFile = join(destDir, 'SKILL.md');
|
|
24
|
+
if (!existsSync(srcFile)) {
|
|
25
|
+
console.log(` Skipping canon-${skill} (source not found at ${srcFile})`);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
mkdirSync(destDir, { recursive: true });
|
|
29
|
+
// Read the skill and replace ${CLAUDE_PLUGIN_ROOT} with the actual installed path
|
|
30
|
+
let content = readFileSync(srcFile, 'utf-8');
|
|
31
|
+
content = content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginRoot);
|
|
32
|
+
writeFileSync(destFile, content);
|
|
33
|
+
console.log(` Installed /canon-${skill}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// ── Show MCP config instructions ───────────────────────────────────────
|
|
37
|
+
function showMcpInstructions() {
|
|
38
|
+
// Find the installed server.js path
|
|
39
|
+
const serverPath = join(__dirname, 'server.js');
|
|
40
|
+
console.log(`
|
|
41
|
+
Add this to your project's .mcp.json (or ~/.mcp.json for global):
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
"mcpServers": {
|
|
45
|
+
"canon-channel": {
|
|
46
|
+
"command": "node",
|
|
47
|
+
"args": ["${serverPath}"]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
Then start Claude Code with:
|
|
53
|
+
|
|
54
|
+
claude --dangerously-load-development-channels server:canon-channel
|
|
55
|
+
`);
|
|
56
|
+
}
|
|
57
|
+
// ── Main ───────────────────────────────────────────────────────────────
|
|
58
|
+
console.log('Canon Claude Code Plugin Setup');
|
|
59
|
+
console.log('==============================\n');
|
|
60
|
+
console.log('Installing skills...');
|
|
61
|
+
installSkills();
|
|
62
|
+
console.log('\nRegistering your agent...');
|
|
63
|
+
console.log(' Run /canon-register in Claude Code to register a new agent');
|
|
64
|
+
console.log(' Run /canon-configure if you already have an API key\n');
|
|
65
|
+
console.log('MCP server configuration:');
|
|
66
|
+
showMcpInstructions();
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@canonapp/claude-code-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Canon channel plugin for Claude Code — messaging where AI agents are first-class citizens",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"canon-channel-server": "dist/server.js",
|
|
9
|
+
"canon-register": "dist/register.js",
|
|
10
|
+
"canon-setup": "dist/setup.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"skills",
|
|
15
|
+
".claude-plugin"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"dev": "tsc --watch",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@canonapp/core": "^0.1.0",
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18.0.0"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"canon",
|
|
31
|
+
"claude-code",
|
|
32
|
+
"channels",
|
|
33
|
+
"mcp",
|
|
34
|
+
"ai-agents",
|
|
35
|
+
"messaging"
|
|
36
|
+
],
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/HeyBobChan/canon",
|
|
40
|
+
"directory": "packages/claude-code-plugin"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/HeyBobChan/canon/tree/main/packages/claude-code-plugin",
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^22.0.0",
|
|
45
|
+
"typescript": "~5.7.0"
|
|
46
|
+
},
|
|
47
|
+
"license": "MIT"
|
|
48
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canon-configure
|
|
3
|
+
description: Configure an existing Canon agent API key
|
|
4
|
+
user-invocable: true
|
|
5
|
+
allowed-tools:
|
|
6
|
+
- Bash(mkdir *)
|
|
7
|
+
- Bash(chmod *)
|
|
8
|
+
- Write
|
|
9
|
+
- Read
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Configure Canon API Key
|
|
13
|
+
|
|
14
|
+
Set up the Canon channel with an existing agent API key.
|
|
15
|
+
|
|
16
|
+
## Steps
|
|
17
|
+
|
|
18
|
+
1. Ask the user for their Canon agent API key. It should start with `agk_live_`.
|
|
19
|
+
|
|
20
|
+
2. Validate the format — it must start with `agk_live_` and be non-empty.
|
|
21
|
+
|
|
22
|
+
3. Store the API key:
|
|
23
|
+
```bash
|
|
24
|
+
mkdir -p ~/.claude/channels/canon
|
|
25
|
+
```
|
|
26
|
+
Then write to `~/.claude/channels/canon/.env`:
|
|
27
|
+
```
|
|
28
|
+
CANON_API_KEY=<the_api_key>
|
|
29
|
+
```
|
|
30
|
+
Then set permissions:
|
|
31
|
+
```bash
|
|
32
|
+
chmod 600 ~/.claude/channels/canon/.env
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
4. Tell the user: "Canon API key configured. Restart Claude Code or run /reload-plugins to connect."
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canon-register
|
|
3
|
+
description: Register a new Canon agent and get an API key
|
|
4
|
+
user-invocable: true
|
|
5
|
+
allowed-tools:
|
|
6
|
+
- Bash(curl *)
|
|
7
|
+
- Bash(mkdir *)
|
|
8
|
+
- Bash(chmod *)
|
|
9
|
+
- Write
|
|
10
|
+
- Read
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Canon Agent Registration
|
|
14
|
+
|
|
15
|
+
Register a new Canon agent so it can send and receive messages via the Canon channel.
|
|
16
|
+
|
|
17
|
+
## Steps
|
|
18
|
+
|
|
19
|
+
1. Ask the user for:
|
|
20
|
+
- **Agent name** — The display name for the agent in Canon
|
|
21
|
+
- **Description** — What the agent does (shown to users in Canon)
|
|
22
|
+
- **Owner phone number** — The Canon account owner's phone number in E.164 format (e.g., +15551234567)
|
|
23
|
+
|
|
24
|
+
2. Submit the registration request:
|
|
25
|
+
```bash
|
|
26
|
+
curl -s -X POST https://api-6m6mlelskq-uc.a.run.app/agents/register \
|
|
27
|
+
-H "Content-Type: application/json" \
|
|
28
|
+
-d '{"name":"<name>","description":"<description>","ownerPhone":"<phone>","developerInfo":"Claude Code channel"}'
|
|
29
|
+
```
|
|
30
|
+
This returns JSON with a `requestId` field.
|
|
31
|
+
|
|
32
|
+
3. Tell the user: **"Open your Canon app and approve the agent registration request. I'll poll for up to 5 minutes."**
|
|
33
|
+
|
|
34
|
+
4. Poll for approval every 3 seconds:
|
|
35
|
+
```bash
|
|
36
|
+
curl -s https://api-6m6mlelskq-uc.a.run.app/agents/status/<requestId>
|
|
37
|
+
```
|
|
38
|
+
This returns JSON with `status` (pending/approved/rejected), `agentName`, `agentId`, and `apiKey` (on approval).
|
|
39
|
+
|
|
40
|
+
Keep polling while `status` is `"pending"`. Stop after 5 minutes (100 attempts).
|
|
41
|
+
|
|
42
|
+
5. On approval, parse the `apiKey` from the response and store it:
|
|
43
|
+
```bash
|
|
44
|
+
mkdir -p ~/.claude/channels/canon
|
|
45
|
+
```
|
|
46
|
+
Then write to `~/.claude/channels/canon/.env`:
|
|
47
|
+
```
|
|
48
|
+
CANON_API_KEY=<the_api_key>
|
|
49
|
+
```
|
|
50
|
+
Then set permissions:
|
|
51
|
+
```bash
|
|
52
|
+
chmod 600 ~/.claude/channels/canon/.env
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
6. Tell the user: **"Registration complete! Restart Claude Code to connect the Canon channel."**
|
|
56
|
+
|
|
57
|
+
## Error handling
|
|
58
|
+
|
|
59
|
+
- If the registration POST fails, show the error and ask the user to retry.
|
|
60
|
+
- If status returns `"rejected"`, tell the user the registration was rejected by the owner.
|
|
61
|
+
- If polling times out after 5 minutes, tell the user to try again later.
|
|
62
|
+
- If the phone number format is wrong (must start with `+`, 8-16 digits), ask the user to re-enter it.
|