init: video-create project with skills and accounts
This commit is contained in:
205
.claude/skills/klingai-1.1.0/scripts/shared/args.mjs
Normal file
205
.claude/skills/klingai-1.1.0/scripts/shared/args.mjs
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Kling AI CLI helpers (zero external deps)
|
||||
* Argument parsing, auth, media file reading
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, relative, sep } from 'node:path';
|
||||
import { platform } from 'node:process';
|
||||
import {
|
||||
getBearerToken,
|
||||
CredentialsMissingError,
|
||||
setSkillVersion,
|
||||
} from './auth.mjs';
|
||||
import { runDeviceBindFlow } from './client.mjs';
|
||||
|
||||
/** 是否允许读取/写入 cwd 与 KLING_MEDIA_ROOTS 以外的本地路径(默认关闭) */
|
||||
function allowAbsolutePaths() {
|
||||
const v = (process.env.KLING_ALLOW_ABSOLUTE_PATHS || '').trim().toLowerCase();
|
||||
return v === '1' || v === 'true' || v === 'yes';
|
||||
}
|
||||
|
||||
/** 额外允许的根目录(逗号分隔),用于下载目录、WSL 跨盘路径等 */
|
||||
function extraMediaRoots() {
|
||||
const raw = (process.env.KLING_MEDIA_ROOTS || '').trim();
|
||||
if (!raw) return [];
|
||||
return raw.split(',').map((s) => s.trim()).filter(Boolean).map((p) => resolve(p));
|
||||
}
|
||||
|
||||
function allAllowedRoots() {
|
||||
const roots = [resolve(process.cwd()), ...extraMediaRoots()];
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** Windows:仅在同盘内做 relative 校验 */
|
||||
function sameDriveRoot(a, b) {
|
||||
if (platform !== 'win32') return true;
|
||||
const ra = resolve(a);
|
||||
const rb = resolve(b);
|
||||
const da = ra.match(/^([A-Za-z]:)/);
|
||||
const db = rb.match(/^([A-Za-z]:)/);
|
||||
if (!da || !db) return true;
|
||||
return da[1].toLowerCase() === db[1].toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断绝对路径是否落在任一允许根下(用于本地文件读、输出目录写)
|
||||
* @param {string} absPath 已 resolve 的绝对路径
|
||||
*/
|
||||
export function isAllowedLocalPath(absPath) {
|
||||
if (allowAbsolutePaths()) return true;
|
||||
const normalized = resolve(absPath);
|
||||
for (const root of allAllowedRoots()) {
|
||||
if (!sameDriveRoot(root, normalized)) continue;
|
||||
const rel = relative(root, normalized);
|
||||
if (rel === '') return true;
|
||||
if (!rel.startsWith('..') && !rel.includes(`${sep}..`)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并返回用于读文件的绝对路径(URL 不适用)
|
||||
* @param {string} userPath 用户传入的本地路径
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveAllowedReadPath(userPath) {
|
||||
const normalized = resolve(userPath.trim());
|
||||
if (!isAllowedLocalPath(normalized)) {
|
||||
const roots = allAllowedRoots().join(', ');
|
||||
throw new Error(
|
||||
`Local path outside allowed roots / 本地路径不在允许范围内: ${normalized}\n`
|
||||
+ `Allowed / 允许: cwd + KLING_MEDIA_ROOTS, or set KLING_ALLOW_ABSOLUTE_PATHS=1\n`
|
||||
+ `Roots / 当前根: ${roots}\n`
|
||||
+ `Example / 示例: export KLING_MEDIA_ROOTS="/mnt/c/Users/you/Downloads,/tmp/claw-downloads"`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验输出目录(相对路径相对于 cwd 解析)
|
||||
* @param {string} userPath 如 ./output 或绝对路径
|
||||
* @returns {string} 绝对路径
|
||||
*/
|
||||
export function resolveAllowedOutputDir(userPath) {
|
||||
const normalized = resolve(userPath.trim());
|
||||
if (!isAllowedLocalPath(normalized)) {
|
||||
const roots = allAllowedRoots().join(', ');
|
||||
throw new Error(
|
||||
`Output dir outside allowed roots / 输出目录不在允许范围内: ${normalized}\n`
|
||||
+ `Allowed / 允许: under cwd, KLING_MEDIA_ROOTS, or KLING_ALLOW_ABSOLUTE_PATHS=1\n`
|
||||
+ `Roots / 当前根: ${roots}`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** 消费 --skill-version */
|
||||
function consumeSkillVersionArgv(argv) {
|
||||
for (let i = 2; i < argv.length - 1; i++) {
|
||||
if (argv[i] === '--skill-version') {
|
||||
setSkillVersion(argv[i + 1]);
|
||||
argv.splice(i, 2);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析命令行参数
|
||||
* @param {string[]} argv process.argv(会原地消费 --skill-version)
|
||||
* @param {string[]} [booleanFlags] 额外的布尔标志名(不需要跟值的 --flag)
|
||||
* @returns {object} 参数键值对
|
||||
*/
|
||||
export function parseArgs(argv, booleanFlags = []) {
|
||||
consumeSkillVersionArgv(argv);
|
||||
const boolSet = new Set(['no-wait', 'download', 'wait', 'help', ...booleanFlags]);
|
||||
const args = {};
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const key = argv[i];
|
||||
if (!key.startsWith('--')) continue;
|
||||
const name = key.slice(2);
|
||||
if (name === 'no-wait') { args.wait = false; continue; }
|
||||
if (boolSet.has(name)) { args[name] = true; continue; }
|
||||
const val = argv[i + 1];
|
||||
if (val !== undefined && !val.startsWith('--')) {
|
||||
args[name] = val; i++;
|
||||
} else {
|
||||
args[name] = true;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Bearer:优先进程内 KLING_TOKEN;否则 credentials 中 AK/SK → JWT。
|
||||
* 若皆无(首次或仅有空凭证),自动执行设备绑定(bind)后再取 token。
|
||||
*/
|
||||
export async function getTokenOrExit() {
|
||||
try {
|
||||
return getBearerToken();
|
||||
} catch (e) {
|
||||
const missing = e instanceof CredentialsMissingError || e?.name === 'CredentialsMissingError';
|
||||
if (!missing) {
|
||||
throw new Error(`Auth error / 鉴权错误: ${e?.message || e}`);
|
||||
}
|
||||
try {
|
||||
console.error('\n── No credentials / 无可用凭证,启动设备绑定 bind ────\n');
|
||||
await runDeviceBindFlow({});
|
||||
return getBearerToken();
|
||||
} catch (err) {
|
||||
const lines = [
|
||||
`Bind failed / 绑定失败: ${err?.message || err}`,
|
||||
];
|
||||
if (err?.bindAuthorizeUrl) {
|
||||
lines.push(`Bind URL / 手动绑定链接: ${err.bindAuthorizeUrl}`);
|
||||
}
|
||||
lines.push('Fallback / 备选:');
|
||||
lines.push(' node skills/klingai/scripts/kling.mjs account --bind-url');
|
||||
lines.push(' set KLING_ACCESS_KEY_ID + KLING_SECRET_ACCESS_KEY, then');
|
||||
lines.push(' node skills/klingai/scripts/kling.mjs account --import-env');
|
||||
lines.push(' or pass args: --import-credentials --access_key_id <AK> --secret_access_key <SK>');
|
||||
lines.push(' or set KLING_TOKEN for this session / 或设置 KLING_TOKEN');
|
||||
throw new Error(lines.join('\n'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取媒体文件:URL 直接返回,本地文件读为 base64(路径受 KLING_MEDIA_ROOTS / KLING_ALLOW_ABSOLUTE_PATHS 约束)
|
||||
* @param {string} pathOrUrl 文件路径或 URL
|
||||
* @returns {Promise<string>} URL 或 base64 字符串
|
||||
*/
|
||||
export async function readMediaAsValue(pathOrUrl) {
|
||||
if (!pathOrUrl) return undefined;
|
||||
const s = pathOrUrl.trim();
|
||||
try {
|
||||
const u = new URL(s);
|
||||
if (u.protocol === 'http:' || u.protocol === 'https:') return s;
|
||||
} catch {
|
||||
// Not a URL: treat as local path below.
|
||||
}
|
||||
const abs = resolveAllowedReadPath(s);
|
||||
const buf = await readFile(abs);
|
||||
return buf.toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Omni-Video 参考视频字段 `video_list[].video_url`:仅接受公网 `http://` 或 `https://` 链接,不接受本地路径或 Base64。
|
||||
* @param {string} pathOrUrl
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function readOmniVideoRefUrl(pathOrUrl) {
|
||||
if (!pathOrUrl) return undefined;
|
||||
const s = pathOrUrl.trim();
|
||||
try {
|
||||
const u = new URL(s);
|
||||
if (u.protocol === 'http:' || u.protocol === 'https:') return s;
|
||||
} catch {
|
||||
// Fallthrough to unified error below.
|
||||
}
|
||||
throw new Error(
|
||||
'Omni --video must be a public http(s) URL / Omni --video 须为公网 http(s) 链接(不接受本地路径或 Base64)。\n'
|
||||
+ 'Upload the file and pass the URL / 请先上传视频再传入 URL。',
|
||||
);
|
||||
}
|
||||
457
.claude/skills/klingai-1.1.0/scripts/shared/auth.mjs
Normal file
457
.claude/skills/klingai-1.1.0/scripts/shared/auth.mjs
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Kling AI — 鉴权层(无网络)
|
||||
*
|
||||
* 凭证优先级:
|
||||
* 1. 当前进程 KLING_TOKEN(仅环境变量显式传入,不落盘)
|
||||
* 2. ~/.config/kling/.credentials(INI,[profile] access_key_id / secret_access_key)→ 请求时 makeJwt(30min exp)
|
||||
* bind / configure 写入 credentials,固定 default profile。
|
||||
* 存储根目录默认 ~/.config/kling;可选 KLING_STORAGE_ROOT 指向统一存储根。
|
||||
* 非凭证 env:仅读 <storageRoot>/kling.env,不覆盖启动前已在 process.env 中的键。
|
||||
* 探测得到的 API Base 由 client 调用 `persistProbedApiBase` 写回 ~/.config/kling/kling.env 中的 KLING_API_BASE;
|
||||
* **不会**从文件注入 KLING_TOKEN(凭证仅 credentials 文件 + 可选进程内 KLING_TOKEN)。
|
||||
*
|
||||
* 网络与 API Base 探测统一在 client.mjs。
|
||||
*/
|
||||
import { createHmac, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
readFileSync, writeFileSync, mkdirSync, chmodSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, resolve, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createInterface } from 'node:readline';
|
||||
import os from 'node:os';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const KLING_ENV_FILENAME = 'kling.env';
|
||||
const IDENTITY_FILENAME = 'identity.json';
|
||||
const CREDENTIALS_FILENAME = '.credentials';
|
||||
const STORAGE_ROOT_ENV = 'KLING_STORAGE_ROOT';
|
||||
|
||||
/** 写入 process.env 时跳过(凭证不走 dotenv 文件) */
|
||||
const CREDENTIAL_ENV_DENYLIST = new Set(['KLING_TOKEN']);
|
||||
|
||||
/**
|
||||
* @param {string} content
|
||||
* @param {{ shellKeys: Set<string> }} opts
|
||||
*/
|
||||
function parseEnvContent(content, opts) {
|
||||
const { shellKeys } = opts;
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx <= 0) continue;
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
if (CREDENTIAL_ENV_DENYLIST.has(key)) continue;
|
||||
let val = trimmed.slice(eqIdx + 1).trim();
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
// 已在启动前导出的环境变量优先,不被文件覆盖。
|
||||
if (!shellKeys.has(key) && !(key in process.env)) {
|
||||
process.env[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getKlingConfigDir() {
|
||||
const explicitRoot = (process.env[STORAGE_ROOT_ENV] || '').trim();
|
||||
if (explicitRoot) return resolve(explicitRoot);
|
||||
const home = process.env.HOME || process.env.USERPROFILE;
|
||||
if (home) return join(home, '.config', 'kling');
|
||||
return resolve(__dir, '..', '..', '..');
|
||||
}
|
||||
|
||||
function getDefaultKlingEnvPath() {
|
||||
return join(getKlingConfigDir(), KLING_ENV_FILENAME);
|
||||
}
|
||||
|
||||
/** 更新或追加 KLING_API_BASE=…,仅写入 ~/.config/kling/kling.env */
|
||||
function upsertEnvFileKey(content, key, value) {
|
||||
const line = `${key}=${value}`;
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`^${escaped}=.*$`, 'm');
|
||||
if (re.test(content)) return content.replace(re, line);
|
||||
const trimmed = content.replace(/\s+$/, '');
|
||||
if (!trimmed) return `${line}\n`;
|
||||
return `${trimmed}\n${line}\n`;
|
||||
}
|
||||
|
||||
(function loadEnvFiles() {
|
||||
const shellKeys = new Set(Object.keys(process.env));
|
||||
try {
|
||||
parseEnvContent(readFileSync(getDefaultKlingEnvPath(), 'utf-8'), { shellKeys });
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
export function getIdentityFilePath() {
|
||||
return join(getKlingConfigDir(), IDENTITY_FILENAME);
|
||||
}
|
||||
|
||||
/** 凭证 INI 路径:<storageRoot>/.credentials */
|
||||
export function getCredentialsFilePath() {
|
||||
return join(getKlingConfigDir(), CREDENTIALS_FILENAME);
|
||||
}
|
||||
|
||||
export function getActiveProfile() {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
export class CredentialsMissingError extends Error {
|
||||
constructor(msg = 'No credentials / 未配置凭证') {
|
||||
super(msg);
|
||||
this.name = 'CredentialsMissingError';
|
||||
}
|
||||
}
|
||||
|
||||
function logAuthSource(source) {
|
||||
const messageMap = {
|
||||
credentials: 'Auth source / 鉴权来源: credentials (AK/SK -> JWT)',
|
||||
env_token: 'Auth source / 鉴权来源: KLING_TOKEN (process env)',
|
||||
};
|
||||
const msg = messageMap[source];
|
||||
if (msg) console.error(msg);
|
||||
}
|
||||
|
||||
function parseCredentialsIni(content) {
|
||||
const profiles = {};
|
||||
let current = null;
|
||||
for (const line of content.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#') || t.startsWith(';')) continue;
|
||||
const m = t.match(/^\[([^\]]+)\]\s*$/);
|
||||
if (m) {
|
||||
current = m[1].trim();
|
||||
if (!profiles[current]) profiles[current] = {};
|
||||
continue;
|
||||
}
|
||||
const eqIdx = t.indexOf('=');
|
||||
if (eqIdx <= 0 || !current) continue;
|
||||
const k = t.slice(0, eqIdx).trim();
|
||||
let v = t.slice(eqIdx + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
||||
v = v.slice(1, -1);
|
||||
}
|
||||
profiles[current][k] = v;
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
/** @returns {{ access_key_id: string, secret_access_key: string }} */
|
||||
export function readCredentialsProfile(profile) {
|
||||
try {
|
||||
const raw = readFileSync(getCredentialsFilePath(), 'utf-8');
|
||||
const all = parseCredentialsIni(raw);
|
||||
const p = all[profile] || {};
|
||||
const ak = String(p.access_key_id || p.access_key || '').trim();
|
||||
const sk = String(p.secret_access_key || p.secret_key || '').trim();
|
||||
return { access_key_id: ak, secret_access_key: sk };
|
||||
} catch {
|
||||
return { access_key_id: '', secret_access_key: '' };
|
||||
}
|
||||
}
|
||||
|
||||
export function hasStoredAccessKeys() {
|
||||
const { access_key_id, secret_access_key } = readCredentialsProfile(getActiveProfile());
|
||||
return Boolean(access_key_id && secret_access_key);
|
||||
}
|
||||
|
||||
export function hasSessionBearerOverride() {
|
||||
return Boolean((process.env.KLING_TOKEN || '').trim());
|
||||
}
|
||||
|
||||
export function hasUsableCredentialSource() {
|
||||
return hasStoredAccessKeys() || hasSessionBearerOverride();
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 [profile] 下 AK/SK,Unix 上 chmod 600
|
||||
* @param {string} profile
|
||||
* @param {string} accessKey
|
||||
* @param {string} secretKey
|
||||
* @param {Record<string,string>} [extra] 如 region
|
||||
*/
|
||||
export function writeCredentialsProfile(profile, accessKey, secretKey, extra = {}) {
|
||||
const path = getCredentialsFilePath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
let all = {};
|
||||
try {
|
||||
all = parseCredentialsIni(readFileSync(path, 'utf-8'));
|
||||
} catch {}
|
||||
all[profile] = {
|
||||
...all[profile],
|
||||
access_key_id: String(accessKey || '').trim(),
|
||||
secret_access_key: String(secretKey || '').trim(),
|
||||
...extra,
|
||||
};
|
||||
const lines = [];
|
||||
for (const prof of Object.keys(all)) {
|
||||
lines.push(`[${prof}]`);
|
||||
const o = all[prof];
|
||||
for (const [k, v] of Object.entries(o)) {
|
||||
if (v == null || String(v) === '') continue;
|
||||
lines.push(`${k} = ${String(v)}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
writeFileSync(path, lines.join('\n').trimEnd() + '\n');
|
||||
try {
|
||||
if (process.platform !== 'win32') chmodSync(path, 0o600);
|
||||
} catch {}
|
||||
return path;
|
||||
}
|
||||
|
||||
// —— Skill 版本 / 请求头 ——
|
||||
const DEFAULT_SKILL_VERSION = '1.0.0';
|
||||
let skillVersion = DEFAULT_SKILL_VERSION;
|
||||
export function setSkillVersion(version) {
|
||||
skillVersion = String(version || DEFAULT_SKILL_VERSION);
|
||||
}
|
||||
export function getSkillVersion() {
|
||||
return skillVersion;
|
||||
}
|
||||
|
||||
export function makeKlingHeaders(token, contentType = 'application/json') {
|
||||
const h = { 'User-Agent': `Kling-Provider-Skill/${getSkillVersion()}` };
|
||||
if (token) h['Authorization'] = `Bearer ${token}`;
|
||||
if (contentType) h['Content-Type'] = contentType;
|
||||
return h;
|
||||
}
|
||||
|
||||
function base64url(buf) {
|
||||
return Buffer.from(buf).toString('base64')
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
function makeJwt(accessKey, secretKey) {
|
||||
const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = base64url(JSON.stringify({
|
||||
iss: accessKey,
|
||||
exp: now + 1800,
|
||||
nbf: now - 5,
|
||||
}));
|
||||
const signature = base64url(
|
||||
createHmac('sha256', secretKey).update(`${header}.${payload}`).digest()
|
||||
);
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1) 进程环境变量 KLING_TOKEN(不落盘;kling.env 不会注入 KLING_TOKEN)
|
||||
* 2) 否则 credentials 文件 AK/SK → 每次调用重新签发 JWT(30min exp)
|
||||
*/
|
||||
export function getBearerToken() {
|
||||
let token = (process.env.KLING_TOKEN || '').trim();
|
||||
if (token) {
|
||||
logAuthSource('env_token');
|
||||
if (token.toLowerCase().startsWith('bearer ')) {
|
||||
token = token.slice(7).trim();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
const profile = getActiveProfile();
|
||||
const { access_key_id, secret_access_key } = readCredentialsProfile(profile);
|
||||
if (access_key_id && secret_access_key) {
|
||||
logAuthSource('credentials');
|
||||
return makeJwt(access_key_id, secret_access_key);
|
||||
}
|
||||
throw new CredentialsMissingError(
|
||||
'Configure credentials under KLING_STORAGE_ROOT (or ~/.config/kling), set KLING_TOKEN for this session, or run account bind/configure / '
|
||||
+ '请在 KLING_STORAGE_ROOT(或 ~/.config/kling)下配置 credentials、本次 shell 导出 KLING_TOKEN,或执行 account --bind|--configure',
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfiguredApiBase() {
|
||||
const baseTest = (process.env.KLING_API_BASE_TEST || '').trim();
|
||||
if (baseTest) return baseTest;
|
||||
const base = (process.env.KLING_API_BASE || '').trim();
|
||||
return base || null;
|
||||
}
|
||||
|
||||
export function getConfiguredBindBase() {
|
||||
const baseTest = (process.env.KLING_BIND_BASE_TEST || '').trim();
|
||||
if (baseTest) return baseTest;
|
||||
const base = (process.env.KLING_BIND_BASE || '').trim();
|
||||
return base || null;
|
||||
}
|
||||
|
||||
/** 将探测到的业务 API 根写入 ~/.config/kling/kling.env(仅 KLING_API_BASE 一行) */
|
||||
export function persistProbedApiBase(baseUrl) {
|
||||
const b = String(baseUrl || '').trim();
|
||||
if (!b) return;
|
||||
const dir = getKlingConfigDir();
|
||||
const path = getDefaultKlingEnvPath();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
let raw = '';
|
||||
try {
|
||||
raw = readFileSync(path, 'utf-8');
|
||||
} catch {}
|
||||
writeFileSync(path, upsertEnvFileKey(raw, 'KLING_API_BASE', b));
|
||||
process.env.KLING_API_BASE = b;
|
||||
}
|
||||
|
||||
export function readIdentity() {
|
||||
try {
|
||||
const raw = readFileSync(getIdentityFilePath(), 'utf-8');
|
||||
const o = JSON.parse(raw);
|
||||
return o && typeof o === 'object' ? o : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeIdentity(obj) {
|
||||
const dir = getKlingConfigDir();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(getIdentityFilePath(), `${JSON.stringify(obj, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export function ensureIdentityForBind() {
|
||||
const existing = readIdentity() || {};
|
||||
const id = { ...existing };
|
||||
let dirty = Object.keys(existing).length === 0;
|
||||
if (!id.client_instance_id) {
|
||||
id.client_instance_id = randomUUID();
|
||||
dirty = true;
|
||||
}
|
||||
const localHostname = (() => {
|
||||
try {
|
||||
const h = String(os.hostname() || '').trim();
|
||||
return h || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
})();
|
||||
if (!id.hostname) {
|
||||
id.hostname = localHostname;
|
||||
dirty = true;
|
||||
}
|
||||
if (!id.device_name) {
|
||||
const n = String(process.env.COMPUTERNAME || process.env.HOSTNAME || id.hostname || '').trim();
|
||||
id.device_name = n || 'unknown';
|
||||
dirty = true;
|
||||
}
|
||||
if (!id.platform) {
|
||||
if (process.platform === 'darwin') id.platform = 'macOS';
|
||||
else if (process.platform === 'win32') id.platform = 'Windows';
|
||||
else if (process.platform === 'linux') id.platform = 'Linux';
|
||||
else id.platform = 'unknown';
|
||||
dirty = true;
|
||||
}
|
||||
id.version = id.version ?? 1;
|
||||
if (id.session_id === undefined) id.session_id = null;
|
||||
id.updated_at = Date.now();
|
||||
if (dirty) writeIdentity(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function patchKlingIdentity(patch) {
|
||||
const cur = readIdentity() || {};
|
||||
const next = { ...cur, ...patch, updated_at: Date.now() };
|
||||
writeIdentity(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 绑定 / configure 成功后写入 credentials;identity 中不保留 AK/SK(并清除历史字段) */
|
||||
export function persistBoundApiKeys(accessKey, secretKey, extraIdentity = {}, extraCredentials = {}) {
|
||||
const ak = String(accessKey || '').trim();
|
||||
const sk = String(secretKey || '').trim();
|
||||
if (!ak || !sk) throw new Error('Missing access_key or secret_key / 缺少 access_key 或 secret_key');
|
||||
const profile = getActiveProfile();
|
||||
const savePath = writeCredentialsProfile(profile, ak, sk, extraCredentials);
|
||||
const cur = readIdentity() || {};
|
||||
const next = { ...cur, ...extraIdentity, bound_at: Date.now(), updated_at: Date.now() };
|
||||
delete next.access_key;
|
||||
delete next.secret_key;
|
||||
delete next.credential_id;
|
||||
delete next.account_id;
|
||||
delete next.credentialId;
|
||||
delete next.accountId;
|
||||
writeIdentity(next);
|
||||
return { savePath, token: makeJwt(ak, sk) };
|
||||
}
|
||||
|
||||
export { makeJwt };
|
||||
|
||||
function readHiddenLine(prompt) {
|
||||
function sanitizeChunk(chunk) {
|
||||
// Strip bracketed-paste markers (\x1b[200~...\x1b[201~), keep printable chars only.
|
||||
return String(chunk || '')
|
||||
.replace(/\u001b\[200~/g, '')
|
||||
.replace(/\u001b\[201~/g, '')
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '');
|
||||
}
|
||||
|
||||
const stdin = process.stdin;
|
||||
const stdout = process.stderr;
|
||||
if (!stdin.isTTY) {
|
||||
return new Promise((r) => {
|
||||
const rl = createInterface({ input: stdin, output: stdout });
|
||||
rl.question(prompt, (a) => {
|
||||
rl.close();
|
||||
r(a.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
stdout.write(prompt);
|
||||
return new Promise((resolveLine) => {
|
||||
stdin.setRawMode(true);
|
||||
stdin.resume();
|
||||
stdin.setEncoding('utf8');
|
||||
let s = '';
|
||||
const onData = (key) => {
|
||||
const k = String(key);
|
||||
if (k === '\u0003') {
|
||||
stdin.setRawMode(false);
|
||||
stdin.removeListener('data', onData);
|
||||
stdin.pause();
|
||||
process.exit(1);
|
||||
}
|
||||
if (k === '\r' || k === '\n') {
|
||||
stdin.setRawMode(false);
|
||||
stdin.removeListener('data', onData);
|
||||
stdin.pause();
|
||||
stdout.write('\n');
|
||||
resolveLine(s);
|
||||
return;
|
||||
}
|
||||
if (k === '\u007f' || k === '\b') {
|
||||
s = s.slice(0, -1);
|
||||
return;
|
||||
}
|
||||
s += sanitizeChunk(k);
|
||||
};
|
||||
stdin.on('data', onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** 交互式录入 AK/SK → credentials(SK 在 TTY 下隐藏输入,支持粘贴) */
|
||||
export async function promptInteractiveCredentialsFile() {
|
||||
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
||||
throw new CredentialsMissingError(
|
||||
'TTY required / 需要交互式终端',
|
||||
);
|
||||
}
|
||||
|
||||
console.error('\n── Kling AI configure / 可灵凭证配置 ─────────────');
|
||||
console.error(`Profile / 配置名: ${getActiveProfile()}`);
|
||||
console.error(`File / 文件: ${getCredentialsFilePath()}`);
|
||||
console.error('────────────────────────────────────────────────\n');
|
||||
|
||||
const rl1 = createInterface({ input: process.stdin, output: process.stderr });
|
||||
const accessKey = await new Promise((r) => {
|
||||
rl1.question('Access Key ID / 访问密钥 ID: ', (a) => r(a.trim()));
|
||||
});
|
||||
rl1.close();
|
||||
if (!accessKey) throw new Error('Access Key required / 需要 Access Key');
|
||||
|
||||
const secretKey = await readHiddenLine('Secret Access Key / 秘密访问密钥(隐藏输入,可粘贴): ');
|
||||
if (!secretKey) throw new Error('Secret Key required / 需要 Secret Key');
|
||||
const savePath = writeCredentialsProfile(getActiveProfile(), accessKey, secretKey);
|
||||
console.error(`\n✓ Saved / 已保存(密钥未在日志中输出): ${savePath}\n`);
|
||||
return makeJwt(accessKey, secretKey);
|
||||
}
|
||||
680
.claude/skills/klingai-1.1.0/scripts/shared/client.mjs
Normal file
680
.claude/skills/klingai-1.1.0/scripts/shared/client.mjs
Normal file
@@ -0,0 +1,680 @@
|
||||
/**
|
||||
* Kling AI HTTP client (zero external deps, Node.js 18+ fetch)
|
||||
*
|
||||
* - **klingGet / klingPost**:Bearer 鉴权 + resolveApiBase(业务 API)
|
||||
* - **runAccountBindHttpSequence**:无 Bearer,固定 bind 端点(与鉴权流量区分在实现上,不混用 token)
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import {
|
||||
getBearerToken,
|
||||
makeKlingHeaders,
|
||||
getConfiguredApiBase,
|
||||
getConfiguredBindBase,
|
||||
persistProbedApiBase,
|
||||
getSkillVersion,
|
||||
ensureIdentityForBind,
|
||||
patchKlingIdentity,
|
||||
persistBoundApiKeys,
|
||||
} from './auth.mjs';
|
||||
|
||||
const KLING_API_ENDPOINTS = Object.freeze([
|
||||
{
|
||||
key: 'cn',
|
||||
apiBase: 'https://api-beijing.klingai.com',
|
||||
bindBase: 'https://klingai.com',
|
||||
consoleUrl: 'https://klingai.com/dev/api-key',
|
||||
},
|
||||
{
|
||||
key: 'global',
|
||||
apiBase: 'https://api-singapore.klingai.com',
|
||||
bindBase: 'https://kling.ai',
|
||||
consoleUrl: 'https://kling.ai/dev/api-key',
|
||||
},
|
||||
]);
|
||||
|
||||
const ALL_KLING_CONSOLE_URLS = Object.freeze(
|
||||
Object.fromEntries(KLING_API_ENDPOINTS.map((item) => [item.key, item.consoleUrl])),
|
||||
);
|
||||
|
||||
const API_BASE = KLING_API_ENDPOINTS[0].apiBase;
|
||||
const CANDIDATE_BASES = KLING_API_ENDPOINTS.map((item) => item.apiBase);
|
||||
export let KLING_CONSOLE_URLS = ALL_KLING_CONSOLE_URLS;
|
||||
|
||||
function normalizeApiBase(base) {
|
||||
return String(base || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function findEndpointByBase(base) {
|
||||
const normalized = normalizeApiBase(base);
|
||||
if (!normalized) return null;
|
||||
const direct = KLING_API_ENDPOINTS.find((item) => normalizeApiBase(item.apiBase) === normalized);
|
||||
if (direct) return direct;
|
||||
const bindDirect = KLING_API_ENDPOINTS.find((item) => normalizeApiBase(item.bindBase) === normalized);
|
||||
if (bindDirect) return bindDirect;
|
||||
if (normalized.includes('api-beijing.klingai.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'cn') || null;
|
||||
if (normalized.includes('api-singapore.klingai.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'global') || null;
|
||||
if (normalized.includes('klingai.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'cn') || null;
|
||||
if (normalized.includes('kling.ai')) return KLING_API_ENDPOINTS.find((item) => item.key === 'global') || null;
|
||||
if (normalized.includes('kuaishou.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'cn') || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function setConsoleUrlsForBase(base) {
|
||||
const endpoint = findEndpointByBase(base);
|
||||
if (!endpoint) {
|
||||
KLING_CONSOLE_URLS = ALL_KLING_CONSOLE_URLS;
|
||||
return;
|
||||
}
|
||||
KLING_CONSOLE_URLS = Object.freeze({ [endpoint.key]: endpoint.consoleUrl });
|
||||
}
|
||||
|
||||
const initialConfiguredApiBase = getConfiguredApiBase();
|
||||
if (initialConfiguredApiBase) {
|
||||
setConsoleUrlsForBase(initialConfiguredApiBase);
|
||||
}
|
||||
|
||||
function printConsoleUrlsHint(prefix = ' ') {
|
||||
for (const [region, url] of Object.entries(KLING_CONSOLE_URLS)) {
|
||||
const label = region === 'cn' ? 'China / 国内' : (region === 'global' ? 'Global / 国际' : region);
|
||||
console.error(`${prefix}${label}: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function probeBase(base, token) {
|
||||
try {
|
||||
const res = await fetch(`${base}/v1/videos/text2video?pageNum=1&pageSize=1`, {
|
||||
method: 'GET',
|
||||
headers: makeKlingHeaders(token, null),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json().catch(() => null);
|
||||
return json != null && (json.code === 0 || json.code === 200);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let _resolvedBase = null;
|
||||
|
||||
async function resolveApiBase(token) {
|
||||
if (_resolvedBase) return _resolvedBase;
|
||||
const configuredApiBase = getConfiguredApiBase();
|
||||
if (configuredApiBase) {
|
||||
_resolvedBase = normalizeApiBase(configuredApiBase);
|
||||
setConsoleUrlsForBase(_resolvedBase);
|
||||
return _resolvedBase;
|
||||
}
|
||||
|
||||
console.error('\n🔍 Probing API endpoints... / 正在检测 API 节点...');
|
||||
for (const endpoint of KLING_API_ENDPOINTS) {
|
||||
process.stderr.write(` [${endpoint.key}] ${endpoint.apiBase} ... `);
|
||||
if (await probeBase(endpoint.apiBase, token)) {
|
||||
process.stderr.write('✓ OK\n\n');
|
||||
_resolvedBase = endpoint.apiBase;
|
||||
setConsoleUrlsForBase(_resolvedBase);
|
||||
try {
|
||||
persistProbedApiBase(_resolvedBase);
|
||||
} catch {}
|
||||
return _resolvedBase;
|
||||
}
|
||||
process.stderr.write('✗\n');
|
||||
}
|
||||
|
||||
console.error('\n❌ Cannot connect to any Kling API endpoint / 无法连接任何可灵 API 节点');
|
||||
for (const base of CANDIDATE_BASES) console.error(` • ${base}`);
|
||||
console.error('\nPossible causes / 可能原因:');
|
||||
console.error(' 1. Token invalid or expired / Token 无效或已过期:');
|
||||
printConsoleUrlsHint();
|
||||
console.error(' 2. Network issue / 网络问题');
|
||||
console.error('\nCheck credentials file, KLING_TOKEN, or run account configure / 检查 credentials、KLING_TOKEN 或 account configure:\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保护 JSON 中的大整数字段(防止 Number 精度丢失)
|
||||
* 将 element_id, task_id 等大整数字段转为字符串
|
||||
*/
|
||||
function protectBigInts(text) {
|
||||
return text.replace(
|
||||
/"(element_id|task_id|elementId|taskId)":\s*(\d{15,})/g,
|
||||
'"$1":"$2"'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析可灵 API 响应,code 为 0 或 200 为成功
|
||||
*/
|
||||
function parseResponse(json) {
|
||||
if (json.code !== 0 && json.code !== 200) {
|
||||
throw new Error(`API error / API 错误 (code=${json.code}): ${json.message || 'Unknown error'}`);
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
function parseJsonSafely(text) {
|
||||
try {
|
||||
return JSON.parse(protectBigInts(String(text || '')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHttpErrorMessage(status, text) {
|
||||
const body = parseJsonSafely(text);
|
||||
if (status === 401 && body && typeof body === 'object') {
|
||||
const code = Number(body.code);
|
||||
const requestId = body.request_id ? `, request_id=${body.request_id}` : '';
|
||||
if (code === 1000) {
|
||||
return `HTTP 401: code=1000,signature is invalid / 秘钥无效,请重新绑定${requestId}`;
|
||||
}
|
||||
if (code === 1002) {
|
||||
return `HTTP 401: code=1002,access key not exist / 账户不存在,请重新绑定${requestId}`;
|
||||
}
|
||||
}
|
||||
return `HTTP ${status}: ${text}`;
|
||||
}
|
||||
|
||||
function parseApiJsonOrThrow(text) {
|
||||
const parsed = parseJsonSafely(text);
|
||||
if (parsed != null) return parsed;
|
||||
const preview = String(text || '').trim().slice(0, 60);
|
||||
if (preview.startsWith('<')) {
|
||||
throw new Error(`API Service Error: Non-JSON content. check KLING_API_BASE and network/DNS/proxy: ${preview}`);
|
||||
}
|
||||
throw new Error(`API Service Error: Cannot parse JSON: ${preview}`);
|
||||
}
|
||||
|
||||
async function safeFetch(url, init, context) {
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (e) {
|
||||
const baseHint = getConfiguredApiBase() || '<auto>';
|
||||
const msg = e?.message || String(e);
|
||||
throw new Error(
|
||||
`Network error / 网络错误: ${msg}\n`
|
||||
+ `Request / 请求: ${context.method} ${url}\n`
|
||||
+ `KLING_API_BASE: ${baseHint}\n`
|
||||
+ 'Hint / 提示: check KLING_API_BASE and network/DNS/proxy, or remove KLING_API_BASE to auto-probe official endpoints / '
|
||||
+ '请检查 KLING_API_BASE 与网络(DNS/代理),或移除 KLING_API_BASE 让脚本自动探测官方节点。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求可灵 API
|
||||
* @param {string} path API 路径,如 /v1/videos/image2video
|
||||
* @param {object} body 请求体
|
||||
* @param {string} [token] 可选 token,不传则自动获取
|
||||
* @returns {Promise<object>} data 字段
|
||||
*/
|
||||
export async function klingPost(path, body, token) {
|
||||
if (!token) token = getBearerToken();
|
||||
const base = await resolveApiBase(token);
|
||||
const url = `${base}${path}`;
|
||||
const res = await safeFetch(url, {
|
||||
method: 'POST',
|
||||
headers: makeKlingHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
}, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(buildHttpErrorMessage(res.status, text));
|
||||
}
|
||||
const text = await res.text();
|
||||
return parseResponse(parseApiJsonOrThrow(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 请求可灵 API
|
||||
* @param {string} path API 路径,如 /v1/videos/image2video/{task_id}
|
||||
* @param {string} [token] 可选 token,不传则自动获取
|
||||
* @param {{ contentType?: string|null }} [options] 如部分接口要求 `Content-Type: application/json`(传 `'application/json'`);默认不传 Content-Type
|
||||
* @returns {Promise<object>} data 字段
|
||||
*/
|
||||
export async function klingGet(path, token, options = {}) {
|
||||
if (!token) token = getBearerToken();
|
||||
const base = await resolveApiBase(token);
|
||||
const ct = options.contentType !== undefined ? options.contentType : null;
|
||||
const url = `${base}${path}`;
|
||||
const res = await safeFetch(url, {
|
||||
method: 'GET',
|
||||
headers: makeKlingHeaders(token, ct),
|
||||
}, { method: 'GET' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(buildHttpErrorMessage(res.status, text));
|
||||
}
|
||||
const text = await res.text();
|
||||
return parseResponse(parseApiJsonOrThrow(text));
|
||||
}
|
||||
|
||||
// —— 设备绑定 HTTP(无 Authorization;不经过 resolveApiBase) ——
|
||||
|
||||
const DEFAULT_BIND_INIT = '/console/api/auth/skill/init-sessions';
|
||||
const DEFAULT_BIND_EXCHANGE = '/console/api/auth/skill/exchange';
|
||||
const DEFAULT_BIND_SKILL_ID = 'Kling-Provider-Skill';
|
||||
const DEFAULT_BIND_SCOPE = 'kling.openapi.invoke';
|
||||
const DEFAULT_BIND_FETCH_TIMEOUT_MS = 30000;
|
||||
const DEFAULT_BIND_TIMEOUT_MS = 180000;
|
||||
|
||||
function sleepBind(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function base64url(input) {
|
||||
return Buffer.from(input).toString('base64')
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
function createPkcePair() {
|
||||
const codeVerifier = base64url(randomBytes(48));
|
||||
const codeChallenge = base64url(createHash('sha256').update(codeVerifier, "utf8").digest());
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
function bindExtractData(json) {
|
||||
if (json == null || typeof json !== 'object') return json;
|
||||
const c = json.code;
|
||||
if (c !== undefined && c !== 0 && c !== 200) {
|
||||
const msg = json.message || json.msg || 'Unknown error';
|
||||
throw new Error(`Bind API error / 绑定接口错误 (code=${c}): ${msg}`);
|
||||
}
|
||||
return json.data !== undefined ? json.data : json;
|
||||
}
|
||||
|
||||
function normalizeBindBase(base) {
|
||||
const raw = String(base || '').trim();
|
||||
return raw.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function resolveBindBase(bindBaseOverride) {
|
||||
const override = normalizeBindBase(bindBaseOverride);
|
||||
if (override) {
|
||||
const overrideEndpoint = findEndpointByBase(override);
|
||||
if (overrideEndpoint?.bindBase) return normalizeBindBase(overrideEndpoint.bindBase);
|
||||
return override;
|
||||
}
|
||||
const configuredBindBase = getConfiguredBindBase();
|
||||
if (configuredBindBase) return normalizeBindBase(configuredBindBase);
|
||||
const candidate = getConfiguredApiBase() || _resolvedBase || API_BASE;
|
||||
const endpoint = findEndpointByBase(candidate);
|
||||
if (endpoint?.bindBase) return normalizeBindBase(endpoint.bindBase);
|
||||
return normalizeBindBase(candidate);
|
||||
}
|
||||
|
||||
async function skillBindHttpJson(userAgent, base, path, body, method = 'POST') {
|
||||
const b = String(base || '').replace(/\/$/, '');
|
||||
const p = path.startsWith('/') ? path : `/${path}`;
|
||||
const url = method === 'GET' && body && typeof body === 'object'
|
||||
? `${b}${p}${p.includes('?') ? '&' : '?'}${new URLSearchParams(
|
||||
Object.entries(body).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]),
|
||||
).toString()}`
|
||||
: `${b}${p}`;
|
||||
const headers = { 'User-Agent': userAgent };
|
||||
if (method !== 'GET') headers['Content-Type'] = 'application/json';
|
||||
const init = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(DEFAULT_BIND_FETCH_TIMEOUT_MS),
|
||||
};
|
||||
if (method !== 'GET' && body != null) init.body = JSON.stringify(body);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, init);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Network error / 网络错误: ${e?.message || e}\n`
|
||||
+ 'Hint / 提示: check network/DNS/proxy and endpoint reachability / 请检查网络、DNS、代理与目标地址可达性。',
|
||||
);
|
||||
}
|
||||
const text = await res.text().catch(() => '');
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`HTTP ${res.status}: ${text}\n`
|
||||
+ 'Hint / 提示: verify API base and network reachability / 请确认 API 基址与网络可达性。',
|
||||
);
|
||||
}
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON / 非 JSON 响应: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return bindExtractData(json);
|
||||
}
|
||||
|
||||
function pickBindSessionId(data) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
return data.session_id || data.sessionId || data.bind_session_id || data.id || null;
|
||||
}
|
||||
|
||||
function pickBindAuthorizeHint(data) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
return (
|
||||
data.verificationUriComplete
|
||||
|| data.verification_uri_complete
|
||||
|| data.verificationUri
|
||||
|| data.verification_uri
|
||||
|| data.authorize_url
|
||||
|| data.authorization_url
|
||||
|| data.qr_url
|
||||
|| null
|
||||
);
|
||||
}
|
||||
|
||||
function pickBindAccessSecretKeys(data) {
|
||||
const src = data?.credential && typeof data.credential === 'object' ? data.credential : data;
|
||||
if (!src || typeof src !== 'object') {
|
||||
return {
|
||||
ak: null, sk: null, credentialId: null, accountId: null,
|
||||
};
|
||||
}
|
||||
const ak = src.accessKey || src.access_key || src.access_key_id || src.accessKeyId || src.ak;
|
||||
const sk = src.secretKey || src.secret_key || src.secret_access_key || src.secretAccessKey || src.sk;
|
||||
const credentialId = src.credentialId || src.credential_id || src.credentialID || src.credentialid;
|
||||
const accountId = src.accountId || src.account_id || src.accountID || src.accountid;
|
||||
return {
|
||||
ak: ak != null ? String(ak).trim() : null,
|
||||
sk: sk != null ? String(sk).trim() : null,
|
||||
credentialId: credentialId != null ? String(credentialId).trim() : null,
|
||||
accountId: accountId != null ? String(accountId).trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBindStatus(data) {
|
||||
if (!data || typeof data !== 'object') return 'pending';
|
||||
const s = data.status || data.state || data.bind_status || data.phase;
|
||||
if (s == null) return 'pending';
|
||||
return String(s).toUpperCase();
|
||||
}
|
||||
|
||||
function makeBindFlowError(message, meta = {}) {
|
||||
const err = new Error(message);
|
||||
err.name = 'BindFlowError';
|
||||
if (meta.code) err.bindCode = meta.code;
|
||||
if (meta.authorizeUrl) err.bindAuthorizeUrl = meta.authorizeUrl;
|
||||
if (meta.sessionId) err.bindSessionId = meta.sessionId;
|
||||
if (meta.status) err.bindStatus = meta.status;
|
||||
if (meta.responseData !== undefined) err.bindResponseData = meta.responseData;
|
||||
return err;
|
||||
}
|
||||
|
||||
function resolveAuthorizationUrl(bindBase, authorizePathOrUrl) {
|
||||
const raw = String(authorizePathOrUrl || '').trim();
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
|
||||
const baseUrl = new URL(`${normalizeBindBase(bindBase)}/`);
|
||||
if (raw.startsWith('/')) return `${baseUrl.origin}${raw}`;
|
||||
return new URL(raw, baseUrl).toString();
|
||||
}
|
||||
|
||||
function defaultBindOnLog(ev) {
|
||||
if (ev.url) {
|
||||
console.error(`${ev.message}\n ${ev.url}`);
|
||||
} else {
|
||||
console.error(ev.message);
|
||||
}
|
||||
}
|
||||
|
||||
function maskSecret(secret) {
|
||||
const s = String(secret || '');
|
||||
if (!s) return '';
|
||||
if (s.length <= 6) return '***';
|
||||
return `${s.slice(0, 3)}***${s.slice(-2)}`;
|
||||
}
|
||||
|
||||
function maskAccessKey(accessKey) {
|
||||
const s = String(accessKey || '');
|
||||
if (!s) return '';
|
||||
if (s.length <= 8) return `${s.slice(0, 2)}***`;
|
||||
return `${s.slice(0, 4)}***${s.slice(-3)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行完整设备绑定并写入 credentials(供 account 与 getTokenOrExit 自动调用)
|
||||
* @param {{ onLog?: function }} [options]
|
||||
*/
|
||||
export async function runDeviceBindFlow(options = {}) {
|
||||
const onLog = options.onLog || defaultBindOnLog;
|
||||
const identity = ensureIdentityForBind();
|
||||
const {
|
||||
client_instance_id, device_name, platform, hostname,
|
||||
} = identity;
|
||||
const userAgent = `Kling-Provider-Skill/${getSkillVersion()}`;
|
||||
|
||||
const result = await runAccountBindHttpSequence({
|
||||
userAgent,
|
||||
skillVersion: getSkillVersion(),
|
||||
identity: {
|
||||
clientInstanceId: client_instance_id,
|
||||
deviceName: device_name,
|
||||
platform,
|
||||
hostname,
|
||||
},
|
||||
onInitSession: (sessionId) => {
|
||||
patchKlingIdentity({ session_id: sessionId });
|
||||
},
|
||||
onLog,
|
||||
});
|
||||
const persisted = persistBoundApiKeys(
|
||||
result.accessKey,
|
||||
result.secretKey,
|
||||
{ session_id: result.sessionId },
|
||||
{
|
||||
credentialId: result.credentialId || null,
|
||||
accountId: result.accountId || null,
|
||||
},
|
||||
);
|
||||
return {
|
||||
sessionId: result.sessionId,
|
||||
authorizeUrl: result.authorizeHint || null,
|
||||
savePath: persisted.savePath,
|
||||
accessKeyMasked: maskAccessKey(result.accessKey),
|
||||
secretKeyMasked: maskSecret(result.secretKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅执行绑定前置:init → verify,返回可手动打开的授权 URL。
|
||||
* @param {{ onLog?: function }} [options]
|
||||
*/
|
||||
export async function prepareDeviceBindUrl(options = {}) {
|
||||
const onLog = options.onLog || defaultBindOnLog;
|
||||
const identity = ensureIdentityForBind();
|
||||
const {
|
||||
client_instance_id, device_name, platform, hostname,
|
||||
} = identity;
|
||||
const userAgent = `Kling-Provider-Skill/${getSkillVersion()}`;
|
||||
const result = await runAccountBindInitVerify({
|
||||
userAgent,
|
||||
skillVersion: getSkillVersion(),
|
||||
identity: {
|
||||
clientInstanceId: client_instance_id,
|
||||
deviceName: device_name,
|
||||
platform,
|
||||
hostname,
|
||||
},
|
||||
onInitSession: (sessionId) => {
|
||||
patchKlingIdentity({ session_id: sessionId });
|
||||
},
|
||||
onLog,
|
||||
});
|
||||
return {
|
||||
sessionId: result.sessionId,
|
||||
authorizeUrl: result.authorizeHint || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号绑定前半段:init → verify,拿到可给用户手动打开的授权 URL。
|
||||
* @returns {Promise<{sessionId: string, authorizeHint: string|null}>}
|
||||
*/
|
||||
export async function runAccountBindInitVerify(options) {
|
||||
const bindBase = options.bindBase ? normalizeBindBase(options.bindBase) : resolveBindBase();
|
||||
const initPath = options.initPath || DEFAULT_BIND_INIT;
|
||||
const {
|
||||
clientInstanceId,
|
||||
deviceName,
|
||||
platform,
|
||||
hostname,
|
||||
} = options.identity || {};
|
||||
if (!clientInstanceId) {
|
||||
throw makeBindFlowError('identity.clientInstanceId is required / 缺少 identity.clientInstanceId', { code: 'MISSING_CLIENT_INSTANCE_ID' });
|
||||
}
|
||||
const userAgent = String(options.userAgent || 'Kling-Provider-Skill/unknown');
|
||||
const skillVersion = String(options.skillVersion || getSkillVersion());
|
||||
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {};
|
||||
const onInitSession = options.onInitSession;
|
||||
const { codeVerifier, codeChallenge } = createPkcePair();
|
||||
onLog({ step: 'base', message: 'Using bind base / 当前 bind 基址:', url: bindBase });
|
||||
|
||||
onLog({ step: 'init', message: 'Calling init-sessions / 调用 init-sessions …' });
|
||||
const initData = await skillBindHttpJson(userAgent, bindBase, initPath, {
|
||||
skillId: DEFAULT_BIND_SKILL_ID,
|
||||
skillVersion,
|
||||
clientInstanceId,
|
||||
deviceName: String(deviceName || '').trim() || 'unknown',
|
||||
platform: String(platform || '').trim() || 'unknown',
|
||||
hostname: String(hostname || '').trim() || 'unknown',
|
||||
requestedScopes: [DEFAULT_BIND_SCOPE],
|
||||
codeChallenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
const sessionId = pickBindSessionId(initData);
|
||||
if (!sessionId) {
|
||||
throw makeBindFlowError(
|
||||
'init-sessions response missing sessionId / init-sessions 响应缺少 sessionId',
|
||||
{ code: 'MISSING_SESSION_ID' },
|
||||
);
|
||||
}
|
||||
if (onInitSession) await onInitSession(sessionId);
|
||||
const deviceCode = String(initData.deviceCode || initData.device_code || '').trim();
|
||||
if (!deviceCode) {
|
||||
throw makeBindFlowError(
|
||||
'init-sessions response missing deviceCode / init-sessions 响应缺少 deviceCode',
|
||||
{ code: 'MISSING_DEVICE_CODE', sessionId },
|
||||
);
|
||||
}
|
||||
const authorizeHint = resolveAuthorizationUrl(bindBase, pickBindAuthorizeHint(initData));
|
||||
if (!authorizeHint) {
|
||||
throw makeBindFlowError(
|
||||
'init-sessions response missing authorize url / init-sessions 响应缺少授权链接',
|
||||
{ code: 'MISSING_AUTHORIZE_URL', sessionId },
|
||||
);
|
||||
}
|
||||
|
||||
onLog({ step: 'authorize', message: 'Open in browser / 请在浏览器完成授权:', url: authorizeHint });
|
||||
return {
|
||||
sessionId,
|
||||
deviceCode,
|
||||
codeVerifier,
|
||||
authorizeHint,
|
||||
interval: Number(initData.interval),
|
||||
expiresIn: Number(initData.expiresIn),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号设备绑定:init → verify → 轮询 check。无 Bearer;凭证落盘由调用方配合 auth 负责。
|
||||
*/
|
||||
export async function runAccountBindHttpSequence(options) {
|
||||
const bindBase = resolveBindBase(options.bindBase);
|
||||
const exchangePath = options.exchangePath || DEFAULT_BIND_EXCHANGE;
|
||||
const timeoutMs = Math.max(1000, Number(options.timeoutMs ?? DEFAULT_BIND_TIMEOUT_MS));
|
||||
const userAgent = String(options.userAgent || 'Kling-Provider-Skill/unknown');
|
||||
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {};
|
||||
const {
|
||||
sessionId,
|
||||
deviceCode,
|
||||
codeVerifier,
|
||||
authorizeHint,
|
||||
expiresIn,
|
||||
} = await runAccountBindInitVerify({
|
||||
...options,
|
||||
bindBase,
|
||||
userAgent,
|
||||
onLog,
|
||||
});
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
// 服务端已返回 ttl,优先取较小值避免本地等待过长。
|
||||
let remainingTtlSec = Number.isFinite(Number(expiresIn))
|
||||
? Number(expiresIn)
|
||||
: null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (remainingTtlSec != null && remainingTtlSec <= 0) {
|
||||
throw makeBindFlowError('Bind expired / 绑定已过期', {
|
||||
code: 'BIND_EXPIRED',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status: 'EXPIRED',
|
||||
});
|
||||
}
|
||||
onLog({ step: 'exchange', message: 'Polling exchange / 轮询 exchange …' });
|
||||
const exchangeData = await skillBindHttpJson(userAgent, bindBase, exchangePath, {
|
||||
sessionId,
|
||||
deviceCode,
|
||||
codeVerifier,
|
||||
}, 'POST');
|
||||
const status = normalizeBindStatus(exchangeData);
|
||||
if (status === 'ISSUED' || status === 'ALREADY_EXCHANGED') {
|
||||
const {
|
||||
ak, sk, credentialId, accountId,
|
||||
} = pickBindAccessSecretKeys(exchangeData);
|
||||
if (!ak || !sk) {
|
||||
throw makeBindFlowError(`${status} without credential / ${status} 但缺少 credential`, {
|
||||
code: 'MISSING_CREDENTIAL',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status,
|
||||
responseData: exchangeData,
|
||||
});
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
authorizeHint,
|
||||
accessKey: ak,
|
||||
secretKey: sk,
|
||||
credentialId,
|
||||
accountId,
|
||||
status,
|
||||
};
|
||||
}
|
||||
if (status !== 'PENDING') {
|
||||
throw makeBindFlowError(`Bind status: ${status}`, {
|
||||
code: 'BIND_STATUS',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status,
|
||||
responseData: exchangeData,
|
||||
});
|
||||
}
|
||||
const waitSec = Number(exchangeData?.pollAfterSeconds);
|
||||
const nextExpiresSec = Number(exchangeData?.expiresIn);
|
||||
if (Number.isFinite(nextExpiresSec)) remainingTtlSec = nextExpiresSec;
|
||||
if (!Number.isFinite(waitSec) || waitSec <= 0) {
|
||||
throw makeBindFlowError('Missing pollAfterSeconds, treat as timeout / 缺少 pollAfterSeconds,按超时处理', {
|
||||
code: 'BIND_TIMEOUT',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status,
|
||||
});
|
||||
}
|
||||
await sleepBind(waitSec * 1000);
|
||||
}
|
||||
|
||||
throw makeBindFlowError(`Bind timeout / 绑定超时(>${timeoutMs}ms)`, {
|
||||
code: 'BIND_TIMEOUT',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status: 'TIMEOUT',
|
||||
});
|
||||
}
|
||||
|
||||
export { getBearerToken, makeKlingHeaders, setSkillVersion, getSkillVersion } from './auth.mjs';
|
||||
export { API_BASE, CANDIDATE_BASES, resolveApiBase };
|
||||
103
.claude/skills/klingai-1.1.0/scripts/shared/task.mjs
Normal file
103
.claude/skills/klingai-1.1.0/scripts/shared/task.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Kling AI task helpers (zero external deps)
|
||||
* Submit → poll status → download result
|
||||
*/
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { klingPost, klingGet, makeKlingHeaders } from './client.mjs';
|
||||
|
||||
/**
|
||||
* 提交任务
|
||||
* @param {string} apiPath 如 /v1/videos/image2video
|
||||
* @param {object} payload 请求体
|
||||
* @param {string} [token]
|
||||
* @returns {Promise<{taskId: string, status: string, data: object}>}
|
||||
*/
|
||||
export async function submitTask(apiPath, payload, token) {
|
||||
const data = await klingPost(apiPath, payload, token);
|
||||
const taskId = data?.task_id;
|
||||
if (!taskId) throw new Error('API did not return task_id / API 未返回 task_id');
|
||||
console.log(`Task submitted / 任务已提交: ${taskId}`);
|
||||
console.log(`Status / 状态: ${data.task_status || 'submitted'}`);
|
||||
return { taskId, status: data.task_status || 'submitted', data };
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询任务状态
|
||||
* @param {string} apiPath 如 /v1/videos/image2video
|
||||
* @param {string} taskId
|
||||
* @param {string} [token]
|
||||
* @returns {Promise<object>} task data
|
||||
*/
|
||||
export async function queryTask(apiPath, taskId, token) {
|
||||
return klingGet(`${apiPath}/${taskId}`, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询任务直到完成
|
||||
* @param {string} apiPath
|
||||
* @param {string} taskId
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.interval=10000] 轮询间隔(ms)
|
||||
* @param {string} [opts.token]
|
||||
* @returns {Promise<object>} 成功的 task data
|
||||
*/
|
||||
export async function pollTask(apiPath, taskId, opts = {}) {
|
||||
const interval = opts.interval || 10000;
|
||||
const token = opts.token;
|
||||
console.log('Waiting for task... / 等待任务完成...');
|
||||
while (true) {
|
||||
const data = await queryTask(apiPath, taskId, token);
|
||||
const status = data?.task_status;
|
||||
console.log(`Status / 状态: ${status}`);
|
||||
if (status === 'succeed') return data;
|
||||
if (status === 'failed') {
|
||||
throw new Error(`Task failed / 任务失败: ${data?.task_status_msg || 'Unknown error'}`);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件到本地
|
||||
* @param {string} url 下载 URL
|
||||
* @param {string} outPath 输出文件路径
|
||||
*/
|
||||
export async function downloadFile(url, outPath) {
|
||||
console.log('Downloading... / 正在下载...');
|
||||
const res = await fetch(url, { headers: makeKlingHeaders(null, null) });
|
||||
if (!res.ok) throw new Error(`Download failed / 下载失败: HTTP ${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await mkdir(join(outPath, '..'), { recursive: true });
|
||||
await writeFile(outPath, buf);
|
||||
console.log(`Saved / 已保存: ${outPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询并下载结果
|
||||
* @param {string} apiPath
|
||||
* @param {string} taskId
|
||||
* @param {string} outputDir
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.urlField='url'] output 中的 URL 字段名
|
||||
* @param {string} [opts.ext='.mp4'] 文件扩展名
|
||||
* @param {number} [opts.interval]
|
||||
* @param {string} [opts.token]
|
||||
* @returns {Promise<string>} 输出文件路径
|
||||
*/
|
||||
export async function pollAndDownload(apiPath, taskId, outputDir, opts = {}) {
|
||||
const data = await pollTask(apiPath, taskId, opts);
|
||||
const urlField = opts.urlField || 'url';
|
||||
const ext = opts.ext || '.mp4';
|
||||
const output = data?.task_result || {};
|
||||
// 支持多种输出结构
|
||||
const url = output[urlField]
|
||||
|| output?.videos?.[0]?.[urlField]
|
||||
|| output?.images?.[0]?.url
|
||||
|| (typeof output === 'string' ? output : null);
|
||||
if (!url) throw new Error(`Task succeeded but missing ${urlField} / 任务成功但未返回 ${urlField}`);
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
const outPath = join(outputDir, `${taskId}${ext}`);
|
||||
await downloadFile(url, outPath);
|
||||
return outPath;
|
||||
}
|
||||
Reference in New Issue
Block a user