feat: add OAuth subscription billing mode via claude CLI
- useSubscriptionBilling toggle in settings (default off) - claudeCLIPath setting (default /usr/local/bin/claude) - ClaudeClient.streamChatCLI: spawn claude CLI, delete both API key env vars for OAuth keychain routing - SettingsTab: toggle, CLI path input, API key field hidden in CLI mode, model refresh disabled in CLI mode - ChatView: guard allows send without API key when subscription billing active
This commit is contained in:
@@ -31319,8 +31319,8 @@ var ChatView = class extends import_obsidian.ItemView {
|
||||
const query = this.inputEl.value.trim();
|
||||
if (!query || this.isLoading)
|
||||
return;
|
||||
if (!this.plugin.settings.apiKey) {
|
||||
this.setStatus("\u26A0 Bitte API Key in den Einstellungen eingeben");
|
||||
if (!this.plugin.settings.apiKey && !this.plugin.settings.useSubscriptionBilling) {
|
||||
this.setStatus("\u26A0 Bitte API Key in den Einstellungen eingeben (oder Claude Abo aktivieren)");
|
||||
return;
|
||||
}
|
||||
const mentions = [];
|
||||
@@ -31479,7 +31479,9 @@ ${content}`;
|
||||
apiKey: this.plugin.settings.apiKey,
|
||||
model: this.plugin.settings.model,
|
||||
maxTokens: this.plugin.settings.maxTokens,
|
||||
systemPrompt
|
||||
systemPrompt,
|
||||
useSubscriptionBilling: this.plugin.settings.useSubscriptionBilling,
|
||||
claudeCLIPath: this.plugin.settings.claudeCLIPath
|
||||
});
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.type === "text" && chunk.text) {
|
||||
@@ -32748,6 +32750,7 @@ var HybridSearch = class {
|
||||
// src/ClaudeClient.ts
|
||||
var import_obsidian2 = require("obsidian");
|
||||
var https = __toESM(require("https"));
|
||||
var import_child_process = require("child_process");
|
||||
var ClaudeClient = class {
|
||||
constructor() {
|
||||
this.baseUrl = "https://api.anthropic.com/v1/messages";
|
||||
@@ -32759,12 +32762,99 @@ var ClaudeClient = class {
|
||||
"anthropic-version": "2023-06-01"
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Stream via claude CLI using OAuth keychain billing.
|
||||
* Both ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN are deleted from env so the CLI
|
||||
* falls back to the keychain OAuth token → subscription billing.
|
||||
*/
|
||||
async *streamChatCLI(messages, options) {
|
||||
if (messages.length === 0) {
|
||||
yield { type: "done" };
|
||||
return;
|
||||
}
|
||||
const history = messages.slice(0, -1);
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
let prompt = "";
|
||||
if (history.length > 0) {
|
||||
prompt += "Bisheriges Gespr\xE4ch:\n";
|
||||
for (const m of history) {
|
||||
prompt += `${m.role === "user" ? "User" : "Assistant"}: ${m.content}
|
||||
`;
|
||||
}
|
||||
prompt += "\n";
|
||||
}
|
||||
prompt += lastMessage.content;
|
||||
const cliPath = options.claudeCLIPath ?? "/usr/local/bin/claude";
|
||||
const args = [
|
||||
"--print",
|
||||
"--model",
|
||||
options.model,
|
||||
"--output-format",
|
||||
"text",
|
||||
"--setting-sources",
|
||||
"",
|
||||
"--tools",
|
||||
"",
|
||||
"--system-prompt",
|
||||
options.systemPrompt
|
||||
];
|
||||
const env3 = { ...process.env };
|
||||
delete env3.ANTHROPIC_API_KEY;
|
||||
delete env3.ANTHROPIC_AUTH_TOKEN;
|
||||
const queue = [];
|
||||
let done = false;
|
||||
let wakeup = null;
|
||||
const push = (c) => {
|
||||
queue.push(c);
|
||||
wakeup?.();
|
||||
wakeup = null;
|
||||
};
|
||||
const finish = () => {
|
||||
done = true;
|
||||
wakeup?.();
|
||||
wakeup = null;
|
||||
};
|
||||
const child = (0, import_child_process.spawn)(cliPath, args, { env: env3, stdio: ["pipe", "pipe", "pipe"] });
|
||||
child.stdout.on("data", (chunk) => {
|
||||
push({ type: "text", text: chunk.toString() });
|
||||
});
|
||||
child.stderr.on("data", () => {
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
push({ type: "error", error: `claude CLI error: ${err.message}` });
|
||||
finish();
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0 && code !== null && queue.every((c) => c.type !== "text")) {
|
||||
push({ type: "error", error: `claude CLI exited with code ${code}` });
|
||||
}
|
||||
finish();
|
||||
});
|
||||
child.stdin.write(prompt);
|
||||
child.stdin.end();
|
||||
while (true) {
|
||||
while (queue.length)
|
||||
yield queue.shift();
|
||||
if (done)
|
||||
break;
|
||||
await new Promise((r) => {
|
||||
wakeup = r;
|
||||
});
|
||||
}
|
||||
while (queue.length)
|
||||
yield queue.shift();
|
||||
yield { type: "done" };
|
||||
}
|
||||
/**
|
||||
* Stream a chat completion via Node.js https + SSE, yielding text chunks as they arrive.
|
||||
* Uses the Node.js https module (available in Obsidian's Electron renderer via Node integration)
|
||||
* to bypass Electron's CORS/CSP restrictions that block fetch and XHR to external APIs.
|
||||
*/
|
||||
async *streamChat(messages, options) {
|
||||
if (options.useSubscriptionBilling) {
|
||||
yield* this.streamChatCLI(messages, options);
|
||||
return;
|
||||
}
|
||||
const queue = [];
|
||||
let done = false;
|
||||
let wakeup = null;
|
||||
@@ -32926,6 +33016,8 @@ Wenn du Fragen beantwortest:
|
||||
embedExcludeFolders: [],
|
||||
useMempalace: false,
|
||||
mempalaceResults: 3,
|
||||
useSubscriptionBilling: false,
|
||||
claudeCLIPath: "/usr/local/bin/claude",
|
||||
promptButtons: [
|
||||
{
|
||||
label: "Draft Check",
|
||||
@@ -32936,7 +33028,7 @@ Wenn du Fragen beantwortest:
|
||||
label: "Monthly Check",
|
||||
filePath: "Schreibdenken/ferals/Code/Prompts/MONTHLY COHERENCE AUDIT",
|
||||
searchMode: "date",
|
||||
searchFolders: ["Schreibdenken/ferals/Content/Artikel"]
|
||||
searchFolders: ["Schreibdenken/ferals/Content/Artikel", "Schreibdenken/ferals/Content/Newsletter", "Schreibdenken/ferals/Content/Artikel Draft"]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -32994,15 +33086,25 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
|
||||
cls: "setting-item-description"
|
||||
});
|
||||
containerEl.createEl("h3", { text: "Claude API" });
|
||||
new import_obsidian3.Setting(containerEl).setName("API Key").setDesc("Dein Anthropic API Key (sk-ant-...)").addText(
|
||||
new import_obsidian3.Setting(containerEl).setName("Claude Abo verwenden").setDesc("Claude CLI + OAuth statt API Key nutzen (nur Desktop, ben\xF6tigt claude im PATH)").addToggle(
|
||||
(toggle) => toggle.setValue(this.plugin.settings.useSubscriptionBilling).onChange(async (value) => {
|
||||
this.plugin.settings.useSubscriptionBilling = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
})
|
||||
);
|
||||
const apiKeySetting = new import_obsidian3.Setting(containerEl).setName("API Key").setDesc("Dein Anthropic API Key (sk-ant-...)").addText(
|
||||
(text) => text.setPlaceholder("sk-ant-api03-...").setValue(this.plugin.settings.apiKey).onChange(async (value) => {
|
||||
this.plugin.settings.apiKey = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
if (this.plugin.settings.useSubscriptionBilling) {
|
||||
apiKeySetting.settingEl.style.display = "none";
|
||||
}
|
||||
let modelDrop;
|
||||
let refreshBtn;
|
||||
new import_obsidian3.Setting(containerEl).setName("Modell").setDesc("Welches Claude-Modell verwenden? (Aktualisieren zeigt Roh-IDs)").addDropdown((drop) => {
|
||||
const modelSetting = new import_obsidian3.Setting(containerEl).setName("Modell").setDesc("Welches Claude-Modell verwenden? (Aktualisieren zeigt Roh-IDs)").addDropdown((drop) => {
|
||||
modelDrop = drop;
|
||||
for (const m of MODELS)
|
||||
drop.addOption(m.id, m.name);
|
||||
@@ -33010,7 +33112,9 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
|
||||
this.plugin.settings.model = value;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
}).addButton((btn) => {
|
||||
});
|
||||
if (!this.plugin.settings.useSubscriptionBilling) {
|
||||
modelSetting.addButton((btn) => {
|
||||
refreshBtn = btn;
|
||||
btn.setButtonText("Aktualisieren").onClick(async () => {
|
||||
const prev = modelDrop.getValue();
|
||||
@@ -33032,6 +33136,15 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (this.plugin.settings.useSubscriptionBilling) {
|
||||
new import_obsidian3.Setting(containerEl).setName("Claude CLI Pfad").setDesc("Pfad zur claude-Binary (z.B. /usr/local/bin/claude). Nur Desktop.").addText(
|
||||
(text) => text.setPlaceholder("/usr/local/bin/claude").setValue(this.plugin.settings.claudeCLIPath).onChange(async (value) => {
|
||||
this.plugin.settings.claudeCLIPath = value.trim() || "/usr/local/bin/claude";
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
new import_obsidian3.Setting(containerEl).setName("Max. Antwort-Tokens").setDesc("Maximale L\xE4nge der Claude-Antwort. F\xFCr lange Analysen (z.B. Monthly Check) h\xF6her einstellen. (1024\u201316000)").addSlider(
|
||||
(slider) => slider.setLimits(1024, 16e3, 512).setValue(this.plugin.settings.maxTokens).setDynamicTooltip().onChange(async (value) => {
|
||||
this.plugin.settings.maxTokens = value;
|
||||
|
||||
+4
-2
@@ -256,8 +256,8 @@ export class ChatView extends ItemView {
|
||||
const query = this.inputEl.value.trim();
|
||||
if (!query || this.isLoading) return;
|
||||
|
||||
if (!this.plugin.settings.apiKey) {
|
||||
this.setStatus("⚠ Bitte API Key in den Einstellungen eingeben");
|
||||
if (!this.plugin.settings.apiKey && !this.plugin.settings.useSubscriptionBilling) {
|
||||
this.setStatus("⚠ Bitte API Key in den Einstellungen eingeben (oder Claude Abo aktivieren)");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -458,6 +458,8 @@ export class ChatView extends ItemView {
|
||||
model: this.plugin.settings.model,
|
||||
maxTokens: this.plugin.settings.maxTokens,
|
||||
systemPrompt,
|
||||
useSubscriptionBilling: this.plugin.settings.useSubscriptionBilling,
|
||||
claudeCLIPath: this.plugin.settings.claudeCLIPath,
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { requestUrl } from "obsidian";
|
||||
import * as https from "https";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
export interface ClaudeMessage {
|
||||
role: "user" | "assistant";
|
||||
@@ -11,6 +12,8 @@ export interface ClaudeOptions {
|
||||
model: string;
|
||||
maxTokens?: number;
|
||||
systemPrompt: string;
|
||||
useSubscriptionBilling?: boolean;
|
||||
claudeCLIPath?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeStreamChunk {
|
||||
@@ -31,6 +34,85 @@ export class ClaudeClient {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream via claude CLI using OAuth keychain billing.
|
||||
* Both ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN are deleted from env so the CLI
|
||||
* falls back to the keychain OAuth token → subscription billing.
|
||||
*/
|
||||
private async *streamChatCLI(
|
||||
messages: ClaudeMessage[],
|
||||
options: ClaudeOptions
|
||||
): AsyncGenerator<ClaudeStreamChunk> {
|
||||
if (messages.length === 0) {
|
||||
yield { type: "done" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the prompt: history + current user message
|
||||
const history = messages.slice(0, -1);
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
let prompt = "";
|
||||
if (history.length > 0) {
|
||||
prompt += "Bisheriges Gespräch:\n";
|
||||
for (const m of history) {
|
||||
prompt += `${m.role === "user" ? "User" : "Assistant"}: ${m.content}\n`;
|
||||
}
|
||||
prompt += "\n";
|
||||
}
|
||||
prompt += lastMessage.content;
|
||||
|
||||
const cliPath = options.claudeCLIPath ?? "/usr/local/bin/claude";
|
||||
const args = [
|
||||
"--print",
|
||||
"--model", options.model,
|
||||
"--output-format", "text",
|
||||
"--setting-sources", "",
|
||||
"--tools", "",
|
||||
"--system-prompt", options.systemPrompt,
|
||||
];
|
||||
|
||||
const env = { ...process.env };
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
const queue: ClaudeStreamChunk[] = [];
|
||||
let done = false;
|
||||
let wakeup: (() => void) | null = null;
|
||||
const push = (c: ClaudeStreamChunk) => { queue.push(c); wakeup?.(); wakeup = null; };
|
||||
const finish = () => { done = true; wakeup?.(); wakeup = null; };
|
||||
|
||||
const child = spawn(cliPath, args, { env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
push({ type: "text", text: chunk.toString() });
|
||||
});
|
||||
|
||||
child.stderr.on("data", () => { /* discard stderr */ });
|
||||
|
||||
child.on("error", (err: Error) => {
|
||||
push({ type: "error", error: `claude CLI error: ${err.message}` });
|
||||
finish();
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null) => {
|
||||
if (code !== 0 && code !== null && queue.every(c => c.type !== "text")) {
|
||||
push({ type: "error", error: `claude CLI exited with code ${code}` });
|
||||
}
|
||||
finish();
|
||||
});
|
||||
|
||||
child.stdin.write(prompt);
|
||||
child.stdin.end();
|
||||
|
||||
while (true) {
|
||||
while (queue.length) yield queue.shift()!;
|
||||
if (done) break;
|
||||
await new Promise<void>(r => { wakeup = r; });
|
||||
}
|
||||
while (queue.length) yield queue.shift()!;
|
||||
yield { type: "done" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a chat completion via Node.js https + SSE, yielding text chunks as they arrive.
|
||||
* Uses the Node.js https module (available in Obsidian's Electron renderer via Node integration)
|
||||
@@ -40,6 +122,10 @@ export class ClaudeClient {
|
||||
messages: ClaudeMessage[],
|
||||
options: ClaudeOptions
|
||||
): AsyncGenerator<ClaudeStreamChunk> {
|
||||
if (options.useSubscriptionBilling) {
|
||||
yield* this.streamChatCLI(messages, options);
|
||||
return;
|
||||
}
|
||||
const queue: ClaudeStreamChunk[] = [];
|
||||
let done = false;
|
||||
let wakeup: (() => void) | null = null;
|
||||
|
||||
+42
-3
@@ -30,6 +30,8 @@ export interface MemexChatSettings {
|
||||
embedExcludeFolders: string[]; // vault folders to skip during embedding
|
||||
useMempalace: boolean; // inject MemPalace search results as additional context
|
||||
mempalaceResults: number; // number of MemPalace results to include
|
||||
useSubscriptionBilling: boolean; // use claude CLI + OAuth keychain instead of API key
|
||||
claudeCLIPath: string; // path to claude binary
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MemexChatSettings = {
|
||||
@@ -58,6 +60,8 @@ Wenn du Fragen beantwortest:
|
||||
embedExcludeFolders: [],
|
||||
useMempalace: false,
|
||||
mempalaceResults: 3,
|
||||
useSubscriptionBilling: false,
|
||||
claudeCLIPath: "/usr/local/bin/claude",
|
||||
promptButtons: [
|
||||
{
|
||||
label: "Draft Check",
|
||||
@@ -139,7 +143,20 @@ export class MemexChatSettingsTab extends PluginSettingTab {
|
||||
// --- API ---
|
||||
containerEl.createEl("h3", { text: "Claude API" });
|
||||
|
||||
// Subscription billing toggle
|
||||
new Setting(containerEl)
|
||||
.setName("Claude Abo verwenden")
|
||||
.setDesc("Claude CLI + OAuth statt API Key nutzen (nur Desktop, benötigt claude im PATH)")
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.useSubscriptionBilling).onChange(async (value) => {
|
||||
this.plugin.settings.useSubscriptionBilling = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.display(); // re-render to show/hide API key field
|
||||
})
|
||||
);
|
||||
|
||||
// API key field — hidden when subscription billing is active
|
||||
const apiKeySetting = new Setting(containerEl)
|
||||
.setName("API Key")
|
||||
.setDesc("Dein Anthropic API Key (sk-ant-...)")
|
||||
.addText((text) =>
|
||||
@@ -151,11 +168,14 @@ export class MemexChatSettingsTab extends PluginSettingTab {
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
if (this.plugin.settings.useSubscriptionBilling) {
|
||||
apiKeySetting.settingEl.style.display = "none";
|
||||
}
|
||||
|
||||
let modelDrop: DropdownComponent;
|
||||
let refreshBtn: ButtonComponent;
|
||||
|
||||
new Setting(containerEl)
|
||||
const modelSetting = new Setting(containerEl)
|
||||
.setName("Modell")
|
||||
.setDesc("Welches Claude-Modell verwenden? (Aktualisieren zeigt Roh-IDs)")
|
||||
.addDropdown((drop) => {
|
||||
@@ -165,8 +185,10 @@ export class MemexChatSettingsTab extends PluginSettingTab {
|
||||
this.plugin.settings.model = value;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
})
|
||||
.addButton((btn) => {
|
||||
});
|
||||
|
||||
if (!this.plugin.settings.useSubscriptionBilling) {
|
||||
modelSetting.addButton((btn) => {
|
||||
refreshBtn = btn;
|
||||
btn.setButtonText("Aktualisieren").onClick(async () => {
|
||||
const prev = modelDrop.getValue();
|
||||
@@ -187,6 +209,23 @@ export class MemexChatSettingsTab extends PluginSettingTab {
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// CLI path — visible only in subscription billing mode
|
||||
if (this.plugin.settings.useSubscriptionBilling) {
|
||||
new Setting(containerEl)
|
||||
.setName("Claude CLI Pfad")
|
||||
.setDesc("Pfad zur claude-Binary (z.B. /usr/local/bin/claude). Nur Desktop.")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("/usr/local/bin/claude")
|
||||
.setValue(this.plugin.settings.claudeCLIPath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.claudeCLIPath = value.trim() || "/usr/local/bin/claude";
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Max. Antwort-Tokens")
|
||||
|
||||
Reference in New Issue
Block a user