@canonmsg/claude-code-plugin 0.3.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 +52 -0
- package/dist/agent-profiles.d.ts +21 -0
- package/dist/agent-profiles.js +63 -0
- package/dist/approval-server.d.ts +23 -0
- package/dist/approval-server.js +133 -0
- package/dist/host.d.ts +2 -0
- package/dist/host.js +813 -0
- package/dist/register.d.ts +2 -0
- package/dist/register.js +85 -0
- package/dist/rtdb-rest.d.ts +34 -0
- package/dist/rtdb-rest.js +128 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +400 -0
- package/dist/setup.d.ts +9 -0
- package/dist/setup.js +66 -0
- package/package.json +53 -0
- package/skills/configure/SKILL.md +50 -0
- package/skills/register/SKILL.md +41 -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_PLUGIN_API_KEY": "${user_config.api_key}"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Canon 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. Control Claude Code from your phone.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Install
|
|
9
|
+
npm install -g @canonmsg/claude-code-plugin
|
|
10
|
+
|
|
11
|
+
# Register (approve in Canon app when prompted)
|
|
12
|
+
canon-register --name "My Claude" --description "My Claude Code agent" --phone "+15551234567"
|
|
13
|
+
|
|
14
|
+
# Run
|
|
15
|
+
canon-claude
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Send a message to your agent in the Canon app. Claude Code responds with full capabilities (file read/write, bash, etc.).
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- **Two-way messaging** — Messages from Canon flow to Claude Code and back
|
|
23
|
+
- **Session controls** — Switch model (Haiku/Sonnet/Opus) and permission mode from the app
|
|
24
|
+
- **Live streaming** — See Claude's response as it types
|
|
25
|
+
- **Interrupt** — Stop Claude mid-response from the app
|
|
26
|
+
- **Context meter** — See context window usage in the app
|
|
27
|
+
- **Max plan auth** — Uses your Claude subscription, no API key billing
|
|
28
|
+
|
|
29
|
+
## Working directory
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
canon-claude --cwd /path/to/project
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Multiple agents
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
canon-register --name "Frontend" --description "React work" --phone "+1..." --profile frontend
|
|
39
|
+
CANON_AGENT=frontend canon-claude --cwd ~/projects/frontend
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Channel mode (alternative)
|
|
43
|
+
|
|
44
|
+
For a lighter integration without session controls, run Canon as a channel plugin inside your own Claude Code session. See [full setup guide](../../docs/claude-code-setup.md#channel-mode-alternative).
|
|
45
|
+
|
|
46
|
+
## Development
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cd packages/claude-code-plugin
|
|
50
|
+
npm install
|
|
51
|
+
npm run build
|
|
52
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared agent profile management — loading, locking, and resolution.
|
|
3
|
+
* Used by both host.ts and server.ts.
|
|
4
|
+
*/
|
|
5
|
+
export interface AgentProfile {
|
|
6
|
+
apiKey: string;
|
|
7
|
+
agentId: string;
|
|
8
|
+
agentName: string;
|
|
9
|
+
registeredAt: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const CANON_DIR: string;
|
|
12
|
+
export declare const AGENTS_PATH: string;
|
|
13
|
+
export declare const LOCKS_DIR: string;
|
|
14
|
+
export declare function loadProfiles(): Record<string, AgentProfile>;
|
|
15
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
16
|
+
export declare function isProfileLocked(profile: string): {
|
|
17
|
+
locked: boolean;
|
|
18
|
+
pid?: number;
|
|
19
|
+
};
|
|
20
|
+
export declare function acquireLock(profile: string): void;
|
|
21
|
+
export declare function releaseLock(profile: string): void;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared agent profile management — loading, locking, and resolution.
|
|
3
|
+
* Used by both host.ts and server.ts.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
// ── Paths ────────────────────────────────────────────────────────────
|
|
9
|
+
export const CANON_DIR = join(homedir(), '.canon');
|
|
10
|
+
export const AGENTS_PATH = join(CANON_DIR, 'agents.json');
|
|
11
|
+
export const LOCKS_DIR = join(CANON_DIR, 'locks');
|
|
12
|
+
// ── Profile loading ──────────────────────────────────────────────────
|
|
13
|
+
export function loadProfiles() {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// ── Process locking ──────────────────────────────────────────────────
|
|
22
|
+
export function isProcessAlive(pid) {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function isProfileLocked(profile) {
|
|
32
|
+
const lockPath = join(LOCKS_DIR, `${profile}.lock`);
|
|
33
|
+
try {
|
|
34
|
+
const pid = parseInt(readFileSync(lockPath, 'utf-8').trim());
|
|
35
|
+
if (isProcessAlive(pid))
|
|
36
|
+
return { locked: true, pid };
|
|
37
|
+
// Stale lock — clean it up
|
|
38
|
+
try {
|
|
39
|
+
unlinkSync(lockPath);
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// No lock file
|
|
45
|
+
}
|
|
46
|
+
return { locked: false };
|
|
47
|
+
}
|
|
48
|
+
export function acquireLock(profile) {
|
|
49
|
+
mkdirSync(LOCKS_DIR, { recursive: true });
|
|
50
|
+
const lockPath = join(LOCKS_DIR, `${profile}.lock`);
|
|
51
|
+
const check = isProfileLocked(profile);
|
|
52
|
+
if (check.locked) {
|
|
53
|
+
throw new Error(`Agent "${profile}" is in use by another session (PID ${check.pid})`);
|
|
54
|
+
}
|
|
55
|
+
writeFileSync(lockPath, String(process.pid));
|
|
56
|
+
}
|
|
57
|
+
export function releaseLock(profile) {
|
|
58
|
+
const lockPath = join(LOCKS_DIR, `${profile}.lock`);
|
|
59
|
+
try {
|
|
60
|
+
unlinkSync(lockPath);
|
|
61
|
+
}
|
|
62
|
+
catch { }
|
|
63
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ApprovalManager } from '@canonmsg/core';
|
|
2
|
+
/**
|
|
3
|
+
* HTTP server that bridges Claude Code's PermissionRequest hook
|
|
4
|
+
* to Canon's ApprovalManager.
|
|
5
|
+
*
|
|
6
|
+
* Listens on localhost for POST /approve from Claude Code hooks.
|
|
7
|
+
*/
|
|
8
|
+
export declare class ApprovalHttpServer {
|
|
9
|
+
private server;
|
|
10
|
+
private manager;
|
|
11
|
+
private port;
|
|
12
|
+
private getConversationId;
|
|
13
|
+
constructor(manager: ApprovalManager, getConversationId: () => string | null, opts?: {
|
|
14
|
+
port?: number;
|
|
15
|
+
});
|
|
16
|
+
private static readonly MAX_PORT_RETRIES;
|
|
17
|
+
start(): Promise<number>;
|
|
18
|
+
stop(): Promise<void>;
|
|
19
|
+
private handleRequest;
|
|
20
|
+
private handleApproval;
|
|
21
|
+
private sendHookResponse;
|
|
22
|
+
private classifyRisk;
|
|
23
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
/**
|
|
3
|
+
* HTTP server that bridges Claude Code's PermissionRequest hook
|
|
4
|
+
* to Canon's ApprovalManager.
|
|
5
|
+
*
|
|
6
|
+
* Listens on localhost for POST /approve from Claude Code hooks.
|
|
7
|
+
*/
|
|
8
|
+
export class ApprovalHttpServer {
|
|
9
|
+
server = null;
|
|
10
|
+
manager;
|
|
11
|
+
port;
|
|
12
|
+
getConversationId;
|
|
13
|
+
constructor(manager, getConversationId, opts) {
|
|
14
|
+
this.manager = manager;
|
|
15
|
+
this.getConversationId = getConversationId;
|
|
16
|
+
this.port = opts?.port ?? 19420;
|
|
17
|
+
}
|
|
18
|
+
static MAX_PORT_RETRIES = 10;
|
|
19
|
+
async start() {
|
|
20
|
+
const startPort = this.port;
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
this.server = createServer((req, res) => this.handleRequest(req, res));
|
|
23
|
+
this.server.on('error', (err) => {
|
|
24
|
+
if (err.code === 'EADDRINUSE') {
|
|
25
|
+
this.port++;
|
|
26
|
+
if (this.port - startPort >= ApprovalHttpServer.MAX_PORT_RETRIES) {
|
|
27
|
+
reject(new Error(`No available port found in range ${startPort}–${this.port}`));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
this.server.listen(this.port, '127.0.0.1');
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
reject(err);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
this.server.listen(this.port, '127.0.0.1', () => {
|
|
37
|
+
console.error(`[canon] Approval server listening on 127.0.0.1:${this.port}`);
|
|
38
|
+
resolve(this.port);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async stop() {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
if (this.server) {
|
|
45
|
+
this.server.close(() => resolve());
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
resolve();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
handleRequest(req, res) {
|
|
53
|
+
if (req.method !== 'POST' || req.url !== '/approve') {
|
|
54
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
55
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let body = '';
|
|
59
|
+
req.on('data', (chunk) => {
|
|
60
|
+
body += chunk.toString();
|
|
61
|
+
});
|
|
62
|
+
req.on('end', () => {
|
|
63
|
+
this.handleApproval(body, res).catch((err) => {
|
|
64
|
+
console.error('[canon] Approval handler error:', err);
|
|
65
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
66
|
+
res.end(JSON.stringify({ error: 'Internal error' }));
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async handleApproval(body, res) {
|
|
71
|
+
let payload;
|
|
72
|
+
try {
|
|
73
|
+
payload = JSON.parse(body);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
77
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const toolName = payload.tool_name ?? 'Unknown';
|
|
81
|
+
const toolInput = payload.tool_input ?? {};
|
|
82
|
+
// Determine risk level
|
|
83
|
+
const riskLevel = this.classifyRisk(toolName, toolInput);
|
|
84
|
+
// Check session rules first (may resolve immediately)
|
|
85
|
+
const existingRule = this.manager.checkSessionRules(toolName);
|
|
86
|
+
if (existingRule) {
|
|
87
|
+
const decision = existingRule.type === 'deny-tool' ? 'deny' : 'allow';
|
|
88
|
+
this.sendHookResponse(res, decision);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Find the conversation to send the approval to
|
|
92
|
+
const conversationId = this.getConversationId();
|
|
93
|
+
if (!conversationId) {
|
|
94
|
+
console.error('[canon] No active conversation for approval — falling back to allow');
|
|
95
|
+
// Can't send approval message, fall through to terminal prompt
|
|
96
|
+
this.sendHookResponse(res, 'ask');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const result = await this.manager.requestApproval(conversationId, toolName, toolInput, { riskLevel });
|
|
101
|
+
this.sendHookResponse(res, result.decision);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
console.error('[canon] Approval request failed:', err);
|
|
105
|
+
// On error, fall back to terminal prompt
|
|
106
|
+
this.sendHookResponse(res, 'ask');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
sendHookResponse(res, behavior) {
|
|
110
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
111
|
+
res.end(JSON.stringify({
|
|
112
|
+
hookSpecificOutput: {
|
|
113
|
+
hookEventName: 'PermissionRequest',
|
|
114
|
+
decision: { behavior },
|
|
115
|
+
},
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
classifyRisk(toolName, toolInput) {
|
|
119
|
+
if (toolName !== 'Bash')
|
|
120
|
+
return 'normal';
|
|
121
|
+
const cmd = typeof toolInput.command === 'string' ? toolInput.command : '';
|
|
122
|
+
const destructivePatterns = [
|
|
123
|
+
/\brm\s+(-rf?|--force)/,
|
|
124
|
+
/\bgit\s+push\s+--force/,
|
|
125
|
+
/\bgit\s+reset\s+--hard/,
|
|
126
|
+
/\bdrop\s+(table|database)/i,
|
|
127
|
+
/\bformat\b/,
|
|
128
|
+
];
|
|
129
|
+
return destructivePatterns.some((p) => p.test(cmd))
|
|
130
|
+
? 'destructive'
|
|
131
|
+
: 'normal';
|
|
132
|
+
}
|
|
133
|
+
}
|
package/dist/host.d.ts
ADDED