@ruvnet/open-claude-code 2.0.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/package.json +27 -0
- package/src/agents/loader.mjs +89 -0
- package/src/agents/parser.mjs +98 -0
- package/src/agents/teams.mjs +123 -0
- package/src/auth/oauth.mjs +220 -0
- package/src/config/cli-args.mjs +139 -0
- package/src/config/env.mjs +261 -0
- package/src/config/settings.mjs +119 -0
- package/src/core/agent-loop.mjs +462 -0
- package/src/core/cache.mjs +105 -0
- package/src/core/checkpoints.mjs +141 -0
- package/src/core/context-manager.mjs +198 -0
- package/src/core/providers.mjs +219 -0
- package/src/core/rate-limiter.mjs +119 -0
- package/src/core/scheduler.mjs +173 -0
- package/src/core/session.mjs +143 -0
- package/src/core/streaming.mjs +182 -0
- package/src/core/system-prompt.mjs +135 -0
- package/src/hooks/engine.mjs +162 -0
- package/src/index.mjs +220 -0
- package/src/mcp/client.mjs +253 -0
- package/src/mcp/transport-shttp.mjs +130 -0
- package/src/mcp/transport-sse.mjs +131 -0
- package/src/mcp/transport-ws.mjs +134 -0
- package/src/permissions/checker.mjs +57 -0
- package/src/permissions/injection-check.mjs +60 -0
- package/src/permissions/path-check.mjs +102 -0
- package/src/permissions/prompt.mjs +73 -0
- package/src/permissions/sandbox.mjs +112 -0
- package/src/plugins/loader.mjs +138 -0
- package/src/skills/loader.mjs +147 -0
- package/src/skills/runner.mjs +55 -0
- package/src/telemetry/index.mjs +96 -0
- package/src/tools/agent.mjs +127 -0
- package/src/tools/ask-user.mjs +61 -0
- package/src/tools/bash.mjs +148 -0
- package/src/tools/cron-create.mjs +120 -0
- package/src/tools/cron-delete.mjs +49 -0
- package/src/tools/cron-list.mjs +37 -0
- package/src/tools/edit.mjs +82 -0
- package/src/tools/enter-worktree.mjs +69 -0
- package/src/tools/exit-worktree.mjs +57 -0
- package/src/tools/glob.mjs +117 -0
- package/src/tools/grep.mjs +127 -0
- package/src/tools/ls.mjs +58 -0
- package/src/tools/lsp.mjs +115 -0
- package/src/tools/multi-edit.mjs +94 -0
- package/src/tools/notebook-edit.mjs +96 -0
- package/src/tools/read-mcp-resource.mjs +57 -0
- package/src/tools/read.mjs +138 -0
- package/src/tools/registry.mjs +114 -0
- package/src/tools/remote-trigger.mjs +84 -0
- package/src/tools/send-message.mjs +64 -0
- package/src/tools/skill.mjs +52 -0
- package/src/tools/todo-write.mjs +68 -0
- package/src/tools/tool-search.mjs +77 -0
- package/src/tools/web-fetch.mjs +65 -0
- package/src/tools/web-search.mjs +89 -0
- package/src/tools/write.mjs +55 -0
- package/src/ui/commands.mjs +499 -0
- package/src/ui/ink-app.mjs +231 -0
- package/src/ui/repl.mjs +144 -0
- package/test/test.mjs +1857 -0
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ruvnet/open-claude-code",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Open source Claude Code CLI — based on ruDevolution decompilation of v2.1.91",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"occ": "src/index.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/",
|
|
11
|
+
"test/"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node src/index.mjs",
|
|
15
|
+
"test": "node test/test.mjs"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"claude",
|
|
19
|
+
"code",
|
|
20
|
+
"cli",
|
|
21
|
+
"ai",
|
|
22
|
+
"decompiler",
|
|
23
|
+
"open-source"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": "https://github.com/ruvnet/open-claude-code"
|
|
27
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Loader — loads custom agent definitions from .claude/agents/
|
|
3
|
+
*
|
|
4
|
+
* Supports two formats:
|
|
5
|
+
* - JSON: .claude/agents/*.json
|
|
6
|
+
* - Markdown with YAML frontmatter: .claude/agents/*.md
|
|
7
|
+
*
|
|
8
|
+
* Agent definitions specify: name, description, model, tools, hooks, prompt.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { parseAgentDefinition } from './parser.mjs';
|
|
14
|
+
|
|
15
|
+
export class AgentLoader {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.agents = new Map();
|
|
18
|
+
this.searchPaths = [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Load agents from standard directories.
|
|
23
|
+
* @param {string} [cwd] - project working directory
|
|
24
|
+
*/
|
|
25
|
+
load(cwd = process.cwd()) {
|
|
26
|
+
this.searchPaths = [
|
|
27
|
+
path.join(cwd, '.claude', 'agents'),
|
|
28
|
+
path.join(process.env.HOME || '', '.claude', 'agents'),
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
for (const dir of this.searchPaths) {
|
|
32
|
+
this._loadFromDir(dir);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_loadFromDir(dir) {
|
|
39
|
+
try {
|
|
40
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (!entry.isFile()) continue;
|
|
43
|
+
const ext = path.extname(entry.name);
|
|
44
|
+
if (ext !== '.json' && ext !== '.md') continue;
|
|
45
|
+
|
|
46
|
+
const filePath = path.join(dir, entry.name);
|
|
47
|
+
try {
|
|
48
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
49
|
+
const agent = parseAgentDefinition(content, ext);
|
|
50
|
+
if (agent && agent.name) {
|
|
51
|
+
this.agents.set(agent.name, { ...agent, source: filePath });
|
|
52
|
+
}
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (process.env.DEBUG) {
|
|
55
|
+
console.error(`Failed to load agent ${filePath}: ${err.message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Directory does not exist
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get an agent definition by name.
|
|
66
|
+
* @param {string} name
|
|
67
|
+
* @returns {object|null}
|
|
68
|
+
*/
|
|
69
|
+
get(name) {
|
|
70
|
+
return this.agents.get(name) || null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* List all loaded agents.
|
|
75
|
+
* @returns {Array<object>}
|
|
76
|
+
*/
|
|
77
|
+
list() {
|
|
78
|
+
return [...this.agents.values()];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if an agent exists.
|
|
83
|
+
* @param {string} name
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
has(name) {
|
|
87
|
+
return this.agents.has(name);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Parser — parses agent definitions from JSON and Markdown formats.
|
|
3
|
+
*
|
|
4
|
+
* JSON format:
|
|
5
|
+
* {
|
|
6
|
+
* "name": "my-agent",
|
|
7
|
+
* "description": "Does things",
|
|
8
|
+
* "model": "claude-sonnet-4-6",
|
|
9
|
+
* "tools": ["Bash", "Read", "Write"],
|
|
10
|
+
* "hooks": { ... },
|
|
11
|
+
* "prompt": "You are a specialized agent..."
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* Markdown format (YAML frontmatter):
|
|
15
|
+
* ---
|
|
16
|
+
* name: my-agent
|
|
17
|
+
* description: Does things
|
|
18
|
+
* model: claude-sonnet-4-6
|
|
19
|
+
* tools: [Bash, Read, Write]
|
|
20
|
+
* ---
|
|
21
|
+
* You are a specialized agent...
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse an agent definition from file content.
|
|
26
|
+
* @param {string} content - file content
|
|
27
|
+
* @param {string} ext - file extension (.json or .md)
|
|
28
|
+
* @returns {object} agent definition
|
|
29
|
+
*/
|
|
30
|
+
export function parseAgentDefinition(content, ext) {
|
|
31
|
+
if (ext === '.json') {
|
|
32
|
+
return parseJsonAgent(content);
|
|
33
|
+
}
|
|
34
|
+
if (ext === '.md') {
|
|
35
|
+
return parseMarkdownAgent(content);
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`Unsupported agent format: ${ext}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseJsonAgent(content) {
|
|
41
|
+
const data = JSON.parse(content);
|
|
42
|
+
return normalizeAgent(data);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseMarkdownAgent(content) {
|
|
46
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
47
|
+
return normalizeAgent({ ...frontmatter, prompt: body });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse YAML-like frontmatter from markdown.
|
|
52
|
+
* Simple key-value parser (no full YAML dependency).
|
|
53
|
+
*/
|
|
54
|
+
function parseFrontmatter(content) {
|
|
55
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
|
56
|
+
if (!match) {
|
|
57
|
+
return { frontmatter: {}, body: content };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const yamlBlock = match[1];
|
|
61
|
+
const body = match[2].trim();
|
|
62
|
+
const frontmatter = {};
|
|
63
|
+
|
|
64
|
+
for (const line of yamlBlock.split('\n')) {
|
|
65
|
+
const colonIdx = line.indexOf(':');
|
|
66
|
+
if (colonIdx === -1) continue;
|
|
67
|
+
|
|
68
|
+
const key = line.slice(0, colonIdx).trim();
|
|
69
|
+
let value = line.slice(colonIdx + 1).trim();
|
|
70
|
+
|
|
71
|
+
// Parse arrays: [a, b, c]
|
|
72
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
73
|
+
value = value.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
// Parse booleans
|
|
76
|
+
else if (value === 'true') value = true;
|
|
77
|
+
else if (value === 'false') value = false;
|
|
78
|
+
// Parse numbers
|
|
79
|
+
else if (/^\d+$/.test(value)) value = parseInt(value, 10);
|
|
80
|
+
|
|
81
|
+
frontmatter[key] = value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { frontmatter, body };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeAgent(data) {
|
|
88
|
+
return {
|
|
89
|
+
name: data.name || 'unnamed',
|
|
90
|
+
description: data.description || '',
|
|
91
|
+
model: data.model || null,
|
|
92
|
+
tools: Array.isArray(data.tools) ? data.tools : [],
|
|
93
|
+
hooks: data.hooks || {},
|
|
94
|
+
prompt: data.prompt || '',
|
|
95
|
+
maxTurns: data.maxTurns || data.max_turns || 10,
|
|
96
|
+
temperature: data.temperature || undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Teams — multi-agent coordination.
|
|
3
|
+
*
|
|
4
|
+
* Allows registering named agents that can communicate via messages.
|
|
5
|
+
* Each teammate is an agent loop that can be invoked with a prompt.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class AgentTeams {
|
|
9
|
+
constructor() {
|
|
10
|
+
/** @type {Map<string, { loop: object, role?: string, status: string }>} */
|
|
11
|
+
this.teammates = new Map();
|
|
12
|
+
this.messageLog = [];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Register a named agent.
|
|
17
|
+
* @param {string} name - unique agent name
|
|
18
|
+
* @param {object} agentLoop - agent loop with .run() async generator
|
|
19
|
+
* @param {object} [options]
|
|
20
|
+
* @param {string} [options.role] - agent role description
|
|
21
|
+
*/
|
|
22
|
+
register(name, agentLoop, options = {}) {
|
|
23
|
+
if (this.teammates.has(name)) {
|
|
24
|
+
throw new Error(`Agent "${name}" is already registered`);
|
|
25
|
+
}
|
|
26
|
+
this.teammates.set(name, {
|
|
27
|
+
loop: agentLoop,
|
|
28
|
+
role: options.role || 'general',
|
|
29
|
+
status: 'idle',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Unregister an agent.
|
|
35
|
+
* @param {string} name
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
unregister(name) {
|
|
39
|
+
return this.teammates.delete(name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Send a message to a teammate and collect all events.
|
|
44
|
+
* @param {string} to - target agent name
|
|
45
|
+
* @param {string} message - prompt to send
|
|
46
|
+
* @returns {Promise<Array<object>>} collected events
|
|
47
|
+
*/
|
|
48
|
+
async sendMessage(to, message) {
|
|
49
|
+
const agent = this.teammates.get(to);
|
|
50
|
+
if (!agent) throw new Error(`Unknown teammate: ${to}`);
|
|
51
|
+
|
|
52
|
+
agent.status = 'running';
|
|
53
|
+
const results = [];
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
for await (const event of agent.loop.run(message)) {
|
|
57
|
+
results.push(event);
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
agent.status = 'idle';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.messageLog.push({
|
|
64
|
+
to,
|
|
65
|
+
message: message.substring(0, 100),
|
|
66
|
+
resultCount: results.length,
|
|
67
|
+
timestamp: new Date().toISOString(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return results;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Broadcast a message to all teammates.
|
|
75
|
+
* @param {string} message
|
|
76
|
+
* @returns {Promise<Map<string, Array<object>>>} results per agent
|
|
77
|
+
*/
|
|
78
|
+
async broadcast(message) {
|
|
79
|
+
const results = new Map();
|
|
80
|
+
const promises = [];
|
|
81
|
+
|
|
82
|
+
for (const [name] of this.teammates) {
|
|
83
|
+
promises.push(
|
|
84
|
+
this.sendMessage(name, message)
|
|
85
|
+
.then(events => results.set(name, events))
|
|
86
|
+
.catch(err => results.set(name, [{ type: 'error', message: err.message }]))
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await Promise.all(promises);
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* List all registered teammates.
|
|
96
|
+
* @returns {Array<{ name: string, role: string, status: string }>}
|
|
97
|
+
*/
|
|
98
|
+
list() {
|
|
99
|
+
return [...this.teammates.entries()].map(([name, info]) => ({
|
|
100
|
+
name,
|
|
101
|
+
role: info.role,
|
|
102
|
+
status: info.status,
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get the message log.
|
|
108
|
+
* @param {number} [limit] - max entries to return
|
|
109
|
+
* @returns {Array<object>}
|
|
110
|
+
*/
|
|
111
|
+
getMessageLog(limit) {
|
|
112
|
+
if (limit) return this.messageLog.slice(-limit);
|
|
113
|
+
return [...this.messageLog];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get count of registered teammates.
|
|
118
|
+
* @returns {number}
|
|
119
|
+
*/
|
|
120
|
+
size() {
|
|
121
|
+
return this.teammates.size;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Client — PKCE OAuth flow for Anthropic and other providers.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - Device code flow (for headless environments)
|
|
6
|
+
* - Authorization code + PKCE
|
|
7
|
+
* - Token refresh
|
|
8
|
+
* - Credential storage in ~/.claude/credentials
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import crypto from 'crypto';
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import os from 'os';
|
|
15
|
+
|
|
16
|
+
export class OAuthClient {
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} clientId - OAuth client ID
|
|
19
|
+
* @param {object} [options]
|
|
20
|
+
* @param {string} [options.authUrl] - authorization endpoint
|
|
21
|
+
* @param {string} [options.tokenUrl] - token endpoint
|
|
22
|
+
* @param {string} [options.deviceUrl] - device authorization endpoint
|
|
23
|
+
* @param {string} [options.credentialsPath] - path to store credentials
|
|
24
|
+
*/
|
|
25
|
+
constructor(clientId, options = {}) {
|
|
26
|
+
this.clientId = clientId;
|
|
27
|
+
this.authUrl = options.authUrl || 'https://console.anthropic.com/oauth/authorize';
|
|
28
|
+
this.tokenUrl = options.tokenUrl || 'https://console.anthropic.com/oauth/token';
|
|
29
|
+
this.deviceUrl = options.deviceUrl || 'https://console.anthropic.com/oauth/device';
|
|
30
|
+
this.credentialsPath = options.credentialsPath ||
|
|
31
|
+
path.join(os.homedir(), '.claude', 'credentials');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Generate a PKCE code verifier and challenge.
|
|
36
|
+
* @returns {{ verifier: string, challenge: string }}
|
|
37
|
+
*/
|
|
38
|
+
generatePKCE() {
|
|
39
|
+
const verifier = crypto.randomBytes(32)
|
|
40
|
+
.toString('base64url')
|
|
41
|
+
.replace(/[^a-zA-Z0-9]/g, '')
|
|
42
|
+
.substring(0, 128);
|
|
43
|
+
|
|
44
|
+
const challenge = crypto
|
|
45
|
+
.createHash('sha256')
|
|
46
|
+
.update(verifier)
|
|
47
|
+
.digest('base64url');
|
|
48
|
+
|
|
49
|
+
return { verifier, challenge };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get the authorization URL for the PKCE flow.
|
|
54
|
+
* @param {object} [options]
|
|
55
|
+
* @param {string} [options.redirectUri] - redirect URI
|
|
56
|
+
* @param {string} [options.scope] - requested scope
|
|
57
|
+
* @returns {{ url: string, verifier: string, state: string }}
|
|
58
|
+
*/
|
|
59
|
+
getAuthorizationUrl(options = {}) {
|
|
60
|
+
const { verifier, challenge } = this.generatePKCE();
|
|
61
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
62
|
+
|
|
63
|
+
const params = new URLSearchParams({
|
|
64
|
+
client_id: this.clientId,
|
|
65
|
+
response_type: 'code',
|
|
66
|
+
code_challenge: challenge,
|
|
67
|
+
code_challenge_method: 'S256',
|
|
68
|
+
state,
|
|
69
|
+
redirect_uri: options.redirectUri || 'http://localhost:9876/callback',
|
|
70
|
+
...(options.scope && { scope: options.scope }),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
url: `${this.authUrl}?${params.toString()}`,
|
|
75
|
+
verifier,
|
|
76
|
+
state,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Start a device code flow (for headless environments).
|
|
82
|
+
* @returns {Promise<{ device_code: string, user_code: string, verification_uri: string, interval: number, expires_in: number }>}
|
|
83
|
+
*/
|
|
84
|
+
async startDeviceFlow() {
|
|
85
|
+
const body = new URLSearchParams({
|
|
86
|
+
client_id: this.clientId,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const res = await fetch(this.deviceUrl, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
92
|
+
body: body.toString(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
const text = await res.text();
|
|
97
|
+
throw new Error(`Device flow failed (${res.status}): ${text}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return res.json();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Exchange an authorization code for tokens (PKCE flow).
|
|
105
|
+
* @param {string} code - authorization code
|
|
106
|
+
* @param {string} verifier - PKCE code verifier
|
|
107
|
+
* @param {string} [redirectUri]
|
|
108
|
+
* @returns {Promise<{ access_token: string, refresh_token?: string, expires_in: number }>}
|
|
109
|
+
*/
|
|
110
|
+
async exchangeCode(code, verifier, redirectUri) {
|
|
111
|
+
const body = new URLSearchParams({
|
|
112
|
+
grant_type: 'authorization_code',
|
|
113
|
+
client_id: this.clientId,
|
|
114
|
+
code,
|
|
115
|
+
code_verifier: verifier,
|
|
116
|
+
redirect_uri: redirectUri || 'http://localhost:9876/callback',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const res = await fetch(this.tokenUrl, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
122
|
+
body: body.toString(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
const text = await res.text();
|
|
127
|
+
throw new Error(`Token exchange failed (${res.status}): ${text}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const token = await res.json();
|
|
131
|
+
this.saveToken(token);
|
|
132
|
+
return token;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Refresh an access token using a refresh token.
|
|
137
|
+
* @param {string} refreshToken
|
|
138
|
+
* @returns {Promise<{ access_token: string, refresh_token?: string, expires_in: number }>}
|
|
139
|
+
*/
|
|
140
|
+
async refreshToken(refreshToken) {
|
|
141
|
+
const body = new URLSearchParams({
|
|
142
|
+
grant_type: 'refresh_token',
|
|
143
|
+
client_id: this.clientId,
|
|
144
|
+
refresh_token: refreshToken,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const res = await fetch(this.tokenUrl, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
150
|
+
body: body.toString(),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
const text = await res.text();
|
|
155
|
+
throw new Error(`Token refresh failed (${res.status}): ${text}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const token = await res.json();
|
|
159
|
+
this.saveToken(token);
|
|
160
|
+
return token;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get stored token from credentials file.
|
|
165
|
+
* @returns {object|null}
|
|
166
|
+
*/
|
|
167
|
+
getStoredToken() {
|
|
168
|
+
try {
|
|
169
|
+
const raw = fs.readFileSync(this.credentialsPath, 'utf-8');
|
|
170
|
+
return JSON.parse(raw);
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Save a token to the credentials file.
|
|
178
|
+
* @param {object} token
|
|
179
|
+
*/
|
|
180
|
+
saveToken(token) {
|
|
181
|
+
try {
|
|
182
|
+
const dir = path.dirname(this.credentialsPath);
|
|
183
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
184
|
+
|
|
185
|
+
const data = {
|
|
186
|
+
...token,
|
|
187
|
+
saved_at: new Date().toISOString(),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
fs.writeFileSync(this.credentialsPath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
191
|
+
} catch {
|
|
192
|
+
// Best effort
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Delete stored credentials.
|
|
198
|
+
*/
|
|
199
|
+
clearToken() {
|
|
200
|
+
try {
|
|
201
|
+
fs.unlinkSync(this.credentialsPath);
|
|
202
|
+
return true;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check if the stored token is expired.
|
|
210
|
+
* @returns {boolean}
|
|
211
|
+
*/
|
|
212
|
+
isTokenExpired() {
|
|
213
|
+
const token = this.getStoredToken();
|
|
214
|
+
if (!token || !token.saved_at || !token.expires_in) return true;
|
|
215
|
+
|
|
216
|
+
const savedAt = new Date(token.saved_at).getTime();
|
|
217
|
+
const expiresAt = savedAt + (token.expires_in * 1000);
|
|
218
|
+
return Date.now() >= expiresAt;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Argument Parser — supports all major Claude Code flags.
|
|
3
|
+
*
|
|
4
|
+
* Flags:
|
|
5
|
+
* --model, -m Model to use
|
|
6
|
+
* --permission-mode Permission mode
|
|
7
|
+
* --print, -p Print mode (non-interactive prompt)
|
|
8
|
+
* --output-format json, text, stream-json
|
|
9
|
+
* --system-prompt Override system prompt
|
|
10
|
+
* --add-dir Additional CLAUDE.md directories
|
|
11
|
+
* --max-turns Maximum conversation turns
|
|
12
|
+
* --allowedTools Comma-separated allowed tools
|
|
13
|
+
* --disallowedTools Comma-separated denied tools
|
|
14
|
+
* --verbose, -v Verbose output
|
|
15
|
+
* --debug, -d Debug mode
|
|
16
|
+
* --version Show version
|
|
17
|
+
* --help, -h Show help
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export function parseArgs(args) {
|
|
21
|
+
const result = {
|
|
22
|
+
prompt: null,
|
|
23
|
+
model: null,
|
|
24
|
+
permissionMode: null,
|
|
25
|
+
outputFormat: null,
|
|
26
|
+
systemPrompt: null,
|
|
27
|
+
addDirs: [],
|
|
28
|
+
maxTurns: null,
|
|
29
|
+
allowedTools: null,
|
|
30
|
+
disallowedTools: null,
|
|
31
|
+
verbose: false,
|
|
32
|
+
debug: false,
|
|
33
|
+
showVersion: false,
|
|
34
|
+
showHelp: false,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < args.length; i++) {
|
|
38
|
+
const arg = args[i];
|
|
39
|
+
|
|
40
|
+
switch (arg) {
|
|
41
|
+
case '--model':
|
|
42
|
+
case '-m':
|
|
43
|
+
result.model = args[++i];
|
|
44
|
+
break;
|
|
45
|
+
|
|
46
|
+
case '--permission-mode':
|
|
47
|
+
result.permissionMode = args[++i];
|
|
48
|
+
break;
|
|
49
|
+
|
|
50
|
+
case '--print':
|
|
51
|
+
case '-p':
|
|
52
|
+
result.prompt = args[++i];
|
|
53
|
+
break;
|
|
54
|
+
|
|
55
|
+
case '--output-format':
|
|
56
|
+
result.outputFormat = args[++i];
|
|
57
|
+
break;
|
|
58
|
+
|
|
59
|
+
case '--system-prompt':
|
|
60
|
+
result.systemPrompt = args[++i];
|
|
61
|
+
break;
|
|
62
|
+
|
|
63
|
+
case '--add-dir':
|
|
64
|
+
result.addDirs.push(args[++i]);
|
|
65
|
+
break;
|
|
66
|
+
|
|
67
|
+
case '--max-turns':
|
|
68
|
+
result.maxTurns = parseInt(args[++i], 10);
|
|
69
|
+
break;
|
|
70
|
+
|
|
71
|
+
case '--allowedTools':
|
|
72
|
+
result.allowedTools = args[++i]?.split(',').map(s => s.trim());
|
|
73
|
+
break;
|
|
74
|
+
|
|
75
|
+
case '--disallowedTools':
|
|
76
|
+
result.disallowedTools = args[++i]?.split(',').map(s => s.trim());
|
|
77
|
+
break;
|
|
78
|
+
|
|
79
|
+
case '--verbose':
|
|
80
|
+
case '-v':
|
|
81
|
+
result.verbose = true;
|
|
82
|
+
break;
|
|
83
|
+
|
|
84
|
+
case '--debug':
|
|
85
|
+
case '-d':
|
|
86
|
+
result.debug = true;
|
|
87
|
+
break;
|
|
88
|
+
|
|
89
|
+
case '--version':
|
|
90
|
+
result.showVersion = true;
|
|
91
|
+
break;
|
|
92
|
+
|
|
93
|
+
case '--help':
|
|
94
|
+
case '-h':
|
|
95
|
+
result.showHelp = true;
|
|
96
|
+
break;
|
|
97
|
+
|
|
98
|
+
default:
|
|
99
|
+
// Bare argument becomes prompt
|
|
100
|
+
if (!arg.startsWith('-')) {
|
|
101
|
+
result.prompt = arg;
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Print usage/help text.
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function getUsageText() {
|
|
115
|
+
return `
|
|
116
|
+
Usage: occ [options] [prompt]
|
|
117
|
+
|
|
118
|
+
Options:
|
|
119
|
+
--model, -m <model> Model to use (default: claude-sonnet-4-6)
|
|
120
|
+
--permission-mode <mode> Permission mode (bypassPermissions, acceptEdits, plan, auto, dontAsk)
|
|
121
|
+
--print, -p <prompt> Non-interactive mode: run prompt and exit
|
|
122
|
+
--output-format <fmt> Output format: text, json, stream-json
|
|
123
|
+
--system-prompt <text> Override system prompt
|
|
124
|
+
--add-dir <dir> Additional directory to search for CLAUDE.md
|
|
125
|
+
--max-turns <n> Maximum conversation turns
|
|
126
|
+
--allowedTools <tools> Comma-separated list of allowed tools
|
|
127
|
+
--disallowedTools <tools> Comma-separated list of denied tools
|
|
128
|
+
--verbose, -v Verbose output
|
|
129
|
+
--debug, -d Debug mode
|
|
130
|
+
--version Show version
|
|
131
|
+
--help, -h Show this help
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
occ Start interactive REPL
|
|
135
|
+
occ -p "What is 2+2?" Run prompt and exit
|
|
136
|
+
occ -m claude-haiku-4-5 Use Haiku model
|
|
137
|
+
occ --debug -p "Fix bug" Debug mode with prompt
|
|
138
|
+
`.trim();
|
|
139
|
+
}
|