recur-skills 0.0.7 ā 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/cli.mjs +4 -2
- package/dist/cli.mjs.map +1 -1
- package/package.json +10 -1
- package/skills/recur-checkout/SKILL.md +183 -138
- package/skills/recur-entitlements/SKILL.md +184 -232
- package/skills/recur-help/SKILL.md +15 -3
- package/skills/recur-portal/SKILL.md +73 -159
- package/skills/recur-quickstart/SKILL.md +51 -17
- package/skills/recur-webhooks/SKILL.md +122 -219
- package/skills/recur-webhooks/scripts/verify-signature.ts +6 -1
package/dist/cli.mjs
CHANGED
|
@@ -76,8 +76,10 @@ function getSkillsDir() {
|
|
|
76
76
|
|
|
77
77
|
//#endregion
|
|
78
78
|
//#region src/cli.ts
|
|
79
|
-
const
|
|
80
|
-
|
|
79
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
80
|
+
const SKILLS_SOURCE = join(__dirname, "..", "skills");
|
|
81
|
+
const { version } = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"));
|
|
82
|
+
program.name("recur-skills").description("Claude Code skills for Recur payment integration").version(version);
|
|
81
83
|
program.command("list").description("List all available skills").action(() => {
|
|
82
84
|
const skills = getAllSkills();
|
|
83
85
|
if (skills.length === 0) {
|
package/dist/cli.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.mjs","names":["data: Record<string, string>","skills: Skill[]","targetDir: string","toInstall: string[]"],"sources":["../src/index.ts","../src/cli.ts"],"sourcesContent":["import { readFileSync, readdirSync, existsSync } from 'node:fs'\nimport { join, dirname } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nconst SKILLS_DIR = join(__dirname, '..', 'skills')\n\nexport interface SkillMetadata {\n name: string\n description: string\n}\n\nexport interface Skill extends SkillMetadata {\n slug: string\n content: string\n path: string\n}\n\n/**\n * Parse SKILL.md frontmatter\n */\nfunction parseFrontmatter(content: string): { data: Record<string, string>; content: string } {\n const match = content.match(/^---\\n([\\s\\S]*?)\\n---\\n([\\s\\S]*)$/)\n if (!match) {\n return { data: {}, content }\n }\n\n const data: Record<string, string> = {}\n const lines = match[1].split('\\n')\n for (const line of lines) {\n const colonIndex = line.indexOf(':')\n if (colonIndex > 0) {\n const key = line.slice(0, colonIndex).trim()\n const value = line.slice(colonIndex + 1).trim()\n data[key] = value\n }\n }\n\n return { data, content: match[2] }\n}\n\n/**\n * Get all available skills\n */\nexport function getAllSkills(): Skill[] {\n if (!existsSync(SKILLS_DIR)) {\n return []\n }\n\n const skillDirs = readdirSync(SKILLS_DIR, { withFileTypes: true })\n .filter(dirent => dirent.isDirectory())\n .map(dirent => dirent.name)\n\n const skills: Skill[] = []\n\n for (const dir of skillDirs) {\n const skillPath = join(SKILLS_DIR, dir, 'SKILL.md')\n if (!existsSync(skillPath)) continue\n\n const rawContent = readFileSync(skillPath, 'utf-8')\n const { data, content } = parseFrontmatter(rawContent)\n\n skills.push({\n slug: dir,\n name: data.name || dir,\n description: data.description || '',\n content,\n path: skillPath,\n })\n }\n\n return skills\n}\n\n/**\n * Get a single skill by slug\n */\nexport function getSkill(slug: string): Skill | null {\n const skillPath = join(SKILLS_DIR, slug, 'SKILL.md')\n if (!existsSync(skillPath)) return null\n\n const rawContent = readFileSync(skillPath, 'utf-8')\n const { data, content } = parseFrontmatter(rawContent)\n\n return {\n slug,\n name: data.name || slug,\n description: data.description || '',\n content,\n path: skillPath,\n }\n}\n\n/**\n * Get skill names only (for listing)\n */\nexport function getSkillNames(): string[] {\n if (!existsSync(SKILLS_DIR)) {\n return []\n }\n\n return readdirSync(SKILLS_DIR, { withFileTypes: true })\n .filter(dirent => dirent.isDirectory())\n .filter(dirent => existsSync(join(SKILLS_DIR, dirent.name, 'SKILL.md')))\n .map(dirent => dirent.name)\n}\n\n/**\n * Get the path to skills directory\n */\nexport function getSkillsDir(): string {\n return SKILLS_DIR\n}\n","import { program } from 'commander'\nimport pc from 'picocolors'\nimport { existsSync, mkdirSync, cpSync, readdirSync } from 'node:fs'\nimport { join, dirname } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { getAllSkills, getSkill, getSkillsDir } from './index.js'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nconst SKILLS_SOURCE = join(__dirname, '..', 'skills')\n\nprogram\n .name('recur-skills')\n .description('Claude Code skills for Recur payment integration')\n .version('0.0.1')\n\nprogram\n .command('list')\n .description('List all available skills')\n .action(() => {\n const skills = getAllSkills()\n\n if (skills.length === 0) {\n console.log(pc.yellow('No skills found.'))\n return\n }\n\n console.log(pc.bold('\\nš¦ Available Recur Skills\\n'))\n\n for (const skill of skills) {\n console.log(pc.cyan(` ${skill.name}`))\n console.log(pc.dim(` ${skill.description}\\n`))\n }\n\n console.log(pc.dim(`Total: ${skills.length} skills\\n`))\n })\n\nprogram\n .command('info <skill>')\n .description('Show detailed information about a skill')\n .action((skillName: string) => {\n const skill = getSkill(skillName)\n\n if (!skill) {\n console.log(pc.red(`Skill \"${skillName}\" not found.`))\n console.log(pc.dim('\\nRun `recur-skills list` to see available skills.'))\n process.exit(1)\n }\n\n console.log(pc.bold(`\\nš¦ ${skill.name}\\n`))\n console.log(pc.dim('Description:'))\n console.log(` ${skill.description}\\n`)\n console.log(pc.dim('Path:'))\n console.log(` ${skill.path}\\n`)\n })\n\nprogram\n .command('install [skills...]')\n .description('Install skills to your Claude Code skills directory')\n .option('-g, --global', 'Install to global ~/.claude/skills/', false)\n .option('-p, --project', 'Install to project .claude/skills/', false)\n .option('-a, --all', 'Install all skills', false)\n .action((skillNames: string[], options: { global: boolean; project: boolean; all: boolean }) => {\n // Determine target directory\n let targetDir: string\n if (options.global) {\n targetDir = join(process.env.HOME || '~', '.claude', 'skills')\n } else if (options.project) {\n targetDir = join(process.cwd(), '.claude', 'skills')\n } else {\n // Default to global\n targetDir = join(process.env.HOME || '~', '.claude', 'skills')\n }\n\n // Determine which skills to install\n let toInstall: string[]\n if (options.all) {\n toInstall = readdirSync(SKILLS_SOURCE, { withFileTypes: true })\n .filter(d => d.isDirectory())\n .map(d => d.name)\n } else if (skillNames.length === 0) {\n console.log(pc.yellow('Please specify skills to install or use --all'))\n console.log(pc.dim('\\nExamples:'))\n console.log(pc.dim(' recur-skills install recur-quickstart'))\n console.log(pc.dim(' recur-skills install recur-checkout recur-webhooks'))\n console.log(pc.dim(' recur-skills install --all'))\n process.exit(1)\n } else {\n toInstall = skillNames\n }\n\n // Create target directory\n if (!existsSync(targetDir)) {\n mkdirSync(targetDir, { recursive: true })\n }\n\n console.log(pc.bold(`\\nš¦ Installing skills to ${targetDir}\\n`))\n\n let installed = 0\n for (const skillName of toInstall) {\n const sourcePath = join(SKILLS_SOURCE, skillName)\n const destPath = join(targetDir, skillName)\n\n if (!existsSync(sourcePath)) {\n console.log(pc.red(` ā ${skillName} - not found`))\n continue\n }\n\n try {\n cpSync(sourcePath, destPath, { recursive: true })\n console.log(pc.green(` ā ${skillName}`))\n installed++\n } catch (error) {\n console.log(pc.red(` ā ${skillName} - ${error}`))\n }\n }\n\n console.log(pc.dim(`\\nInstalled ${installed}/${toInstall.length} skills\\n`))\n\n if (installed > 0) {\n console.log(pc.cyan('Skills are now available in Claude Code!'))\n console.log(pc.dim('Claude will automatically use them when relevant,'))\n console.log(pc.dim('or you can invoke them directly with /skill-name\\n'))\n }\n })\n\nprogram\n .command('path')\n .description('Show the path to the skills directory')\n .action(() => {\n console.log(getSkillsDir())\n })\n\nprogram.parse()\n"],"mappings":";;;;;;;;;AAKA,MAAM,aAAa,KADD,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC,EACtB,MAAM,SAAS;;;;AAgBlD,SAAS,iBAAiB,SAAoE;CAC5F,MAAM,QAAQ,QAAQ,MAAM,oCAAoC;AAChE,KAAI,CAAC,MACH,QAAO;EAAE,MAAM,EAAE;EAAE;EAAS;CAG9B,MAAMA,OAA+B,EAAE;CACvC,MAAM,QAAQ,MAAM,GAAG,MAAM,KAAK;AAClC,MAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,aAAa,KAAK,QAAQ,IAAI;AACpC,MAAI,aAAa,GAAG;GAClB,MAAM,MAAM,KAAK,MAAM,GAAG,WAAW,CAAC,MAAM;AAE5C,QAAK,OADS,KAAK,MAAM,aAAa,EAAE,CAAC,MAAM;;;AAKnD,QAAO;EAAE;EAAM,SAAS,MAAM;EAAI;;;;;AAMpC,SAAgB,eAAwB;AACtC,KAAI,CAAC,WAAW,WAAW,CACzB,QAAO,EAAE;CAGX,MAAM,YAAY,YAAY,YAAY,EAAE,eAAe,MAAM,CAAC,CAC/D,QAAO,WAAU,OAAO,aAAa,CAAC,CACtC,KAAI,WAAU,OAAO,KAAK;CAE7B,MAAMC,SAAkB,EAAE;AAE1B,MAAK,MAAM,OAAO,WAAW;EAC3B,MAAM,YAAY,KAAK,YAAY,KAAK,WAAW;AACnD,MAAI,CAAC,WAAW,UAAU,CAAE;EAG5B,MAAM,EAAE,MAAM,YAAY,iBADP,aAAa,WAAW,QAAQ,CACG;AAEtD,SAAO,KAAK;GACV,MAAM;GACN,MAAM,KAAK,QAAQ;GACnB,aAAa,KAAK,eAAe;GACjC;GACA,MAAM;GACP,CAAC;;AAGJ,QAAO;;;;;AAMT,SAAgB,SAAS,MAA4B;CACnD,MAAM,YAAY,KAAK,YAAY,MAAM,WAAW;AACpD,KAAI,CAAC,WAAW,UAAU,CAAE,QAAO;CAGnC,MAAM,EAAE,MAAM,YAAY,iBADP,aAAa,WAAW,QAAQ,CACG;AAEtD,QAAO;EACL;EACA,MAAM,KAAK,QAAQ;EACnB,aAAa,KAAK,eAAe;EACjC;EACA,MAAM;EACP;;;;;AAoBH,SAAgB,eAAuB;AACrC,QAAO;;;;;ACvGT,MAAM,gBAAgB,KADJ,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC,EACnB,MAAM,SAAS;AAErD,QACG,KAAK,eAAe,CACpB,YAAY,mDAAmD,CAC/D,QAAQ,QAAQ;AAEnB,QACG,QAAQ,OAAO,CACf,YAAY,4BAA4B,CACxC,aAAa;CACZ,MAAM,SAAS,cAAc;AAE7B,KAAI,OAAO,WAAW,GAAG;AACvB,UAAQ,IAAI,GAAG,OAAO,mBAAmB,CAAC;AAC1C;;AAGF,SAAQ,IAAI,GAAG,KAAK,gCAAgC,CAAC;AAErD,MAAK,MAAM,SAAS,QAAQ;AAC1B,UAAQ,IAAI,GAAG,KAAK,KAAK,MAAM,OAAO,CAAC;AACvC,UAAQ,IAAI,GAAG,IAAI,QAAQ,MAAM,YAAY,IAAI,CAAC;;AAGpD,SAAQ,IAAI,GAAG,IAAI,UAAU,OAAO,OAAO,WAAW,CAAC;EACvD;AAEJ,QACG,QAAQ,eAAe,CACvB,YAAY,0CAA0C,CACtD,QAAQ,cAAsB;CAC7B,MAAM,QAAQ,SAAS,UAAU;AAEjC,KAAI,CAAC,OAAO;AACV,UAAQ,IAAI,GAAG,IAAI,UAAU,UAAU,cAAc,CAAC;AACtD,UAAQ,IAAI,GAAG,IAAI,qDAAqD,CAAC;AACzE,UAAQ,KAAK,EAAE;;AAGjB,SAAQ,IAAI,GAAG,KAAK,QAAQ,MAAM,KAAK,IAAI,CAAC;AAC5C,SAAQ,IAAI,GAAG,IAAI,eAAe,CAAC;AACnC,SAAQ,IAAI,KAAK,MAAM,YAAY,IAAI;AACvC,SAAQ,IAAI,GAAG,IAAI,QAAQ,CAAC;AAC5B,SAAQ,IAAI,KAAK,MAAM,KAAK,IAAI;EAChC;AAEJ,QACG,QAAQ,sBAAsB,CAC9B,YAAY,sDAAsD,CAClE,OAAO,gBAAgB,uCAAuC,MAAM,CACpE,OAAO,iBAAiB,sCAAsC,MAAM,CACpE,OAAO,aAAa,sBAAsB,MAAM,CAChD,QAAQ,YAAsB,YAAiE;CAE9F,IAAIC;AACJ,KAAI,QAAQ,OACV,aAAY,KAAK,QAAQ,IAAI,QAAQ,KAAK,WAAW,SAAS;UACrD,QAAQ,QACjB,aAAY,KAAK,QAAQ,KAAK,EAAE,WAAW,SAAS;KAGpD,aAAY,KAAK,QAAQ,IAAI,QAAQ,KAAK,WAAW,SAAS;CAIhE,IAAIC;AACJ,KAAI,QAAQ,IACV,aAAY,YAAY,eAAe,EAAE,eAAe,MAAM,CAAC,CAC5D,QAAO,MAAK,EAAE,aAAa,CAAC,CAC5B,KAAI,MAAK,EAAE,KAAK;UACV,WAAW,WAAW,GAAG;AAClC,UAAQ,IAAI,GAAG,OAAO,gDAAgD,CAAC;AACvE,UAAQ,IAAI,GAAG,IAAI,cAAc,CAAC;AAClC,UAAQ,IAAI,GAAG,IAAI,0CAA0C,CAAC;AAC9D,UAAQ,IAAI,GAAG,IAAI,uDAAuD,CAAC;AAC3E,UAAQ,IAAI,GAAG,IAAI,+BAA+B,CAAC;AACnD,UAAQ,KAAK,EAAE;OAEf,aAAY;AAId,KAAI,CAAC,WAAW,UAAU,CACxB,WAAU,WAAW,EAAE,WAAW,MAAM,CAAC;AAG3C,SAAQ,IAAI,GAAG,KAAK,6BAA6B,UAAU,IAAI,CAAC;CAEhE,IAAI,YAAY;AAChB,MAAK,MAAM,aAAa,WAAW;EACjC,MAAM,aAAa,KAAK,eAAe,UAAU;EACjD,MAAM,WAAW,KAAK,WAAW,UAAU;AAE3C,MAAI,CAAC,WAAW,WAAW,EAAE;AAC3B,WAAQ,IAAI,GAAG,IAAI,OAAO,UAAU,cAAc,CAAC;AACnD;;AAGF,MAAI;AACF,UAAO,YAAY,UAAU,EAAE,WAAW,MAAM,CAAC;AACjD,WAAQ,IAAI,GAAG,MAAM,OAAO,YAAY,CAAC;AACzC;WACO,OAAO;AACd,WAAQ,IAAI,GAAG,IAAI,OAAO,UAAU,KAAK,QAAQ,CAAC;;;AAItD,SAAQ,IAAI,GAAG,IAAI,eAAe,UAAU,GAAG,UAAU,OAAO,WAAW,CAAC;AAE5E,KAAI,YAAY,GAAG;AACjB,UAAQ,IAAI,GAAG,KAAK,2CAA2C,CAAC;AAChE,UAAQ,IAAI,GAAG,IAAI,oDAAoD,CAAC;AACxE,UAAQ,IAAI,GAAG,IAAI,qDAAqD,CAAC;;EAE3E;AAEJ,QACG,QAAQ,OAAO,CACf,YAAY,wCAAwC,CACpD,aAAa;AACZ,SAAQ,IAAI,cAAc,CAAC;EAC3B;AAEJ,QAAQ,OAAO"}
|
|
1
|
+
{"version":3,"file":"cli.mjs","names":["data: Record<string, string>","skills: Skill[]","targetDir: string","toInstall: string[]"],"sources":["../src/index.ts","../src/cli.ts"],"sourcesContent":["import { readFileSync, readdirSync, existsSync } from 'node:fs'\nimport { join, dirname } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nconst SKILLS_DIR = join(__dirname, '..', 'skills')\n\nexport interface SkillMetadata {\n name: string\n description: string\n}\n\nexport interface Skill extends SkillMetadata {\n slug: string\n content: string\n path: string\n}\n\n/**\n * Parse SKILL.md frontmatter\n */\nfunction parseFrontmatter(content: string): { data: Record<string, string>; content: string } {\n const match = content.match(/^---\\n([\\s\\S]*?)\\n---\\n([\\s\\S]*)$/)\n if (!match) {\n return { data: {}, content }\n }\n\n const data: Record<string, string> = {}\n const lines = match[1].split('\\n')\n for (const line of lines) {\n const colonIndex = line.indexOf(':')\n if (colonIndex > 0) {\n const key = line.slice(0, colonIndex).trim()\n const value = line.slice(colonIndex + 1).trim()\n data[key] = value\n }\n }\n\n return { data, content: match[2] }\n}\n\n/**\n * Get all available skills\n */\nexport function getAllSkills(): Skill[] {\n if (!existsSync(SKILLS_DIR)) {\n return []\n }\n\n const skillDirs = readdirSync(SKILLS_DIR, { withFileTypes: true })\n .filter(dirent => dirent.isDirectory())\n .map(dirent => dirent.name)\n\n const skills: Skill[] = []\n\n for (const dir of skillDirs) {\n const skillPath = join(SKILLS_DIR, dir, 'SKILL.md')\n if (!existsSync(skillPath)) continue\n\n const rawContent = readFileSync(skillPath, 'utf-8')\n const { data, content } = parseFrontmatter(rawContent)\n\n skills.push({\n slug: dir,\n name: data.name || dir,\n description: data.description || '',\n content,\n path: skillPath,\n })\n }\n\n return skills\n}\n\n/**\n * Get a single skill by slug\n */\nexport function getSkill(slug: string): Skill | null {\n const skillPath = join(SKILLS_DIR, slug, 'SKILL.md')\n if (!existsSync(skillPath)) return null\n\n const rawContent = readFileSync(skillPath, 'utf-8')\n const { data, content } = parseFrontmatter(rawContent)\n\n return {\n slug,\n name: data.name || slug,\n description: data.description || '',\n content,\n path: skillPath,\n }\n}\n\n/**\n * Get skill names only (for listing)\n */\nexport function getSkillNames(): string[] {\n if (!existsSync(SKILLS_DIR)) {\n return []\n }\n\n return readdirSync(SKILLS_DIR, { withFileTypes: true })\n .filter(dirent => dirent.isDirectory())\n .filter(dirent => existsSync(join(SKILLS_DIR, dirent.name, 'SKILL.md')))\n .map(dirent => dirent.name)\n}\n\n/**\n * Get the path to skills directory\n */\nexport function getSkillsDir(): string {\n return SKILLS_DIR\n}\n","import { program } from 'commander'\nimport pc from 'picocolors'\nimport { existsSync, mkdirSync, cpSync, readdirSync, readFileSync } from 'node:fs'\nimport { join, dirname } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { getAllSkills, getSkill, getSkillsDir } from './index.js'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nconst SKILLS_SOURCE = join(__dirname, '..', 'skills')\nconst { version } = JSON.parse(\n readFileSync(join(__dirname, '..', 'package.json'), 'utf8')\n) as { version: string }\n\nprogram\n .name('recur-skills')\n .description('Claude Code skills for Recur payment integration')\n .version(version)\n\nprogram\n .command('list')\n .description('List all available skills')\n .action(() => {\n const skills = getAllSkills()\n\n if (skills.length === 0) {\n console.log(pc.yellow('No skills found.'))\n return\n }\n\n console.log(pc.bold('\\nš¦ Available Recur Skills\\n'))\n\n for (const skill of skills) {\n console.log(pc.cyan(` ${skill.name}`))\n console.log(pc.dim(` ${skill.description}\\n`))\n }\n\n console.log(pc.dim(`Total: ${skills.length} skills\\n`))\n })\n\nprogram\n .command('info <skill>')\n .description('Show detailed information about a skill')\n .action((skillName: string) => {\n const skill = getSkill(skillName)\n\n if (!skill) {\n console.log(pc.red(`Skill \"${skillName}\" not found.`))\n console.log(pc.dim('\\nRun `recur-skills list` to see available skills.'))\n process.exit(1)\n }\n\n console.log(pc.bold(`\\nš¦ ${skill.name}\\n`))\n console.log(pc.dim('Description:'))\n console.log(` ${skill.description}\\n`)\n console.log(pc.dim('Path:'))\n console.log(` ${skill.path}\\n`)\n })\n\nprogram\n .command('install [skills...]')\n .description('Install skills to your Claude Code skills directory')\n .option('-g, --global', 'Install to global ~/.claude/skills/', false)\n .option('-p, --project', 'Install to project .claude/skills/', false)\n .option('-a, --all', 'Install all skills', false)\n .action((skillNames: string[], options: { global: boolean; project: boolean; all: boolean }) => {\n // Determine target directory\n let targetDir: string\n if (options.global) {\n targetDir = join(process.env.HOME || '~', '.claude', 'skills')\n } else if (options.project) {\n targetDir = join(process.cwd(), '.claude', 'skills')\n } else {\n // Default to global\n targetDir = join(process.env.HOME || '~', '.claude', 'skills')\n }\n\n // Determine which skills to install\n let toInstall: string[]\n if (options.all) {\n toInstall = readdirSync(SKILLS_SOURCE, { withFileTypes: true })\n .filter(d => d.isDirectory())\n .map(d => d.name)\n } else if (skillNames.length === 0) {\n console.log(pc.yellow('Please specify skills to install or use --all'))\n console.log(pc.dim('\\nExamples:'))\n console.log(pc.dim(' recur-skills install recur-quickstart'))\n console.log(pc.dim(' recur-skills install recur-checkout recur-webhooks'))\n console.log(pc.dim(' recur-skills install --all'))\n process.exit(1)\n } else {\n toInstall = skillNames\n }\n\n // Create target directory\n if (!existsSync(targetDir)) {\n mkdirSync(targetDir, { recursive: true })\n }\n\n console.log(pc.bold(`\\nš¦ Installing skills to ${targetDir}\\n`))\n\n let installed = 0\n for (const skillName of toInstall) {\n const sourcePath = join(SKILLS_SOURCE, skillName)\n const destPath = join(targetDir, skillName)\n\n if (!existsSync(sourcePath)) {\n console.log(pc.red(` ā ${skillName} - not found`))\n continue\n }\n\n try {\n cpSync(sourcePath, destPath, { recursive: true })\n console.log(pc.green(` ā ${skillName}`))\n installed++\n } catch (error) {\n console.log(pc.red(` ā ${skillName} - ${error}`))\n }\n }\n\n console.log(pc.dim(`\\nInstalled ${installed}/${toInstall.length} skills\\n`))\n\n if (installed > 0) {\n console.log(pc.cyan('Skills are now available in Claude Code!'))\n console.log(pc.dim('Claude will automatically use them when relevant,'))\n console.log(pc.dim('or you can invoke them directly with /skill-name\\n'))\n }\n })\n\nprogram\n .command('path')\n .description('Show the path to the skills directory')\n .action(() => {\n console.log(getSkillsDir())\n })\n\nprogram.parse()\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "recur-skills",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"description": "Claude Code skills for Recur - Taiwan's subscription payment platform",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"recur",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"dev": "tsdown --watch",
|
|
46
46
|
"lint": "eslint src/",
|
|
47
47
|
"typecheck": "tsc --noEmit",
|
|
48
|
+
"check:examples": "node scripts/typecheck-examples.mjs",
|
|
48
49
|
"prepublishOnly": "pnpm build",
|
|
49
50
|
"version": "node scripts/sync-version.js && git add .claude-plugin/marketplace.json skills/*/SKILL.md"
|
|
50
51
|
},
|
|
@@ -53,7 +54,15 @@
|
|
|
53
54
|
"picocolors": "^1.1.1"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
57
|
+
"@types/express": "^5.0.6",
|
|
56
58
|
"@types/node": "^22.0.0",
|
|
59
|
+
"@types/react": "^19.2.17",
|
|
60
|
+
"@types/react-dom": "^19.2.3",
|
|
61
|
+
"express": "^5.2.1",
|
|
62
|
+
"next": "^16.2.10",
|
|
63
|
+
"react": "^19.2.7",
|
|
64
|
+
"react-dom": "^19.2.7",
|
|
65
|
+
"recur-tw": "^0.16.1",
|
|
57
66
|
"tsdown": "^0.18.4",
|
|
58
67
|
"typescript": "^5.7.3"
|
|
59
68
|
},
|
|
@@ -1,249 +1,294 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: recur-checkout
|
|
3
|
-
description: Implement Recur checkout flows including
|
|
3
|
+
description: Implement Recur checkout flows including hosted, embedded, and modal modes. Use when adding payment buttons, checkout forms, subscription purchase flows, or when user mentions "checkout", "ēµåø³", "ä»ę¬¾ęé", "embedded checkout".
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: recur
|
|
7
|
-
version: "0.0.
|
|
7
|
+
version: "0.0.8"
|
|
8
|
+
verified-against: recur-tw@0.16.1
|
|
8
9
|
---
|
|
9
10
|
|
|
10
11
|
# Recur Checkout Integration
|
|
11
12
|
|
|
12
13
|
You are helping implement Recur checkout flows. Recur supports multiple checkout modes for different use cases.
|
|
13
14
|
|
|
14
|
-
## Checkout
|
|
15
|
+
## Choosing a Checkout Mode
|
|
15
16
|
|
|
16
|
-
| Mode |
|
|
17
|
-
|
|
18
|
-
| `
|
|
19
|
-
|
|
|
20
|
-
| `
|
|
17
|
+
| Mode | API | Works on localhost | Best for |
|
|
18
|
+
|------|-----|--------------------|----------|
|
|
19
|
+
| **Hosted Checkout** (recommended) | `redirectToCheckout()` | ā
Yes | Most apps ā simplest, works on any domain |
|
|
20
|
+
| On-page (embedded/modal) | `checkout()` | ā No ā requires a registered domain | Polished production UX on your own domain |
|
|
21
|
+
| Payment Link | `recur.paymentLinks.create()` (server) | ā
Yes | No-frontend flows: emails, DMs, invoices |
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
**Default to Hosted Checkout.** The on-page `checkout()` renders the card
|
|
24
|
+
form via the PAYUNi SDK and only works on domains registered with Recur ā
|
|
25
|
+
it will fail during local development.
|
|
23
26
|
|
|
24
|
-
|
|
27
|
+
## Hosted Checkout (recommended)
|
|
28
|
+
|
|
29
|
+
Redirects to `checkout.recur.tw`; the customer pays and is redirected back
|
|
30
|
+
to your `successUrl`.
|
|
25
31
|
|
|
26
32
|
```tsx
|
|
33
|
+
'use client'
|
|
34
|
+
|
|
27
35
|
import { useRecur } from 'recur-tw'
|
|
28
36
|
|
|
29
|
-
function
|
|
30
|
-
const {
|
|
37
|
+
export function SubscribeButton({ productId }: { productId: string }) {
|
|
38
|
+
const { redirectToCheckout, isCheckingOut } = useRecur()
|
|
31
39
|
|
|
32
40
|
const handleClick = async () => {
|
|
33
|
-
await
|
|
34
|
-
productId,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
// Optional:
|
|
41
|
+
await redirectToCheckout({
|
|
42
|
+
productId, // or productSlug: 'pro-plan'
|
|
43
|
+
successUrl: `${window.location.origin}/dashboard?welcome=1`,
|
|
44
|
+
cancelUrl: `${window.location.origin}/pricing`,
|
|
45
|
+
// Optional: pre-fill and link to your user system
|
|
38
46
|
customerEmail: 'user@example.com',
|
|
39
|
-
customerName: 'John Doe',
|
|
40
|
-
|
|
41
|
-
// Optional: Link to your user system
|
|
42
47
|
externalCustomerId: 'user_123',
|
|
43
|
-
|
|
44
|
-
// Callbacks
|
|
45
|
-
onPaymentComplete: (result) => {
|
|
46
|
-
console.log('Success!', result)
|
|
47
|
-
// result.id - Subscription/Order ID
|
|
48
|
-
// result.status - 'ACTIVE', 'TRIALING', etc.
|
|
49
|
-
},
|
|
50
|
-
onPaymentFailed: (error) => {
|
|
51
|
-
console.error('Failed:', error)
|
|
52
|
-
return { action: 'retry' } // or 'close' or 'custom'
|
|
53
|
-
},
|
|
54
|
-
onPaymentCancel: () => {
|
|
55
|
-
console.log('User cancelled')
|
|
56
|
-
},
|
|
57
48
|
})
|
|
58
49
|
}
|
|
59
50
|
|
|
60
51
|
return (
|
|
61
|
-
<button onClick={handleClick} disabled={
|
|
62
|
-
{
|
|
52
|
+
<button onClick={handleClick} disabled={isCheckingOut}>
|
|
53
|
+
{isCheckingOut ? 'čēäøā¦' : 'čØé±'}
|
|
63
54
|
</button>
|
|
64
55
|
)
|
|
65
56
|
}
|
|
66
57
|
```
|
|
67
58
|
|
|
68
|
-
|
|
59
|
+
There are no client-side success callbacks in this mode ā handle success on
|
|
60
|
+
the `successUrl` page (and authoritatively via webhooks / entitlements).
|
|
61
|
+
|
|
62
|
+
To control the redirect yourself (e.g. open in a new tab):
|
|
69
63
|
|
|
70
64
|
```tsx
|
|
71
|
-
|
|
65
|
+
'use client'
|
|
66
|
+
|
|
67
|
+
import { useRecur } from 'recur-tw'
|
|
72
68
|
|
|
73
|
-
function
|
|
74
|
-
const {
|
|
69
|
+
export function OpenCheckoutInNewTab({ productId }: { productId: string }) {
|
|
70
|
+
const { createCheckoutSession } = useRecur()
|
|
75
71
|
|
|
76
|
-
const handleClick = () => {
|
|
77
|
-
|
|
72
|
+
const handleClick = async () => {
|
|
73
|
+
const session = await createCheckoutSession({
|
|
78
74
|
productId,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
router.push('/dashboard')
|
|
82
|
-
},
|
|
75
|
+
successUrl: `${window.location.origin}/success`,
|
|
76
|
+
cancelUrl: `${window.location.origin}/pricing`,
|
|
83
77
|
})
|
|
78
|
+
window.open(session.url, '_blank')
|
|
84
79
|
}
|
|
85
80
|
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
return <button onClick={handleClick}>åå¾ēµåø³</button>
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## On-page Checkout (embedded / modal)
|
|
86
|
+
|
|
87
|
+
ā ļø Requires a domain registered with Recur. Does NOT work on localhost ā
|
|
88
|
+
use Hosted Checkout for local development.
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
'use client'
|
|
92
|
+
|
|
93
|
+
import { useRecur } from 'recur-tw'
|
|
94
|
+
|
|
95
|
+
export function CheckoutButton({ productId }: { productId: string }) {
|
|
96
|
+
const { checkout, isCheckingOut } = useRecur()
|
|
97
|
+
|
|
98
|
+
const handleClick = async () => {
|
|
99
|
+
await checkout({
|
|
100
|
+
productId, // or productSlug: 'pro-plan'
|
|
101
|
+
customerEmail: 'user@example.com',
|
|
102
|
+
externalCustomerId: 'user_123',
|
|
103
|
+
onPaymentComplete: (subscription) => {
|
|
104
|
+
// subscription.id, subscription.status,
|
|
105
|
+
// subscription.currentPeriodEnd, subscription.trialEndsAt
|
|
106
|
+
console.log('Payment successful!', subscription.id)
|
|
107
|
+
},
|
|
108
|
+
onPaymentFailed: (error) => {
|
|
109
|
+
console.error('Failed:', error.message)
|
|
110
|
+
return { action: 'retry' } // or 'close' or 'custom'
|
|
111
|
+
},
|
|
112
|
+
onPaymentCancel: () => {
|
|
113
|
+
console.log('User cancelled')
|
|
114
|
+
},
|
|
115
|
+
})
|
|
88
116
|
}
|
|
89
117
|
|
|
90
118
|
return (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
</button>
|
|
95
|
-
{error && <p className="error">{error.message}</p>}
|
|
96
|
-
</>
|
|
119
|
+
<button onClick={handleClick} disabled={isCheckingOut}>
|
|
120
|
+
{isCheckingOut ? 'Processing...' : 'Subscribe'}
|
|
121
|
+
</button>
|
|
97
122
|
)
|
|
98
123
|
}
|
|
99
124
|
```
|
|
100
125
|
|
|
101
|
-
|
|
126
|
+
### Embedded mode container
|
|
102
127
|
|
|
103
|
-
|
|
128
|
+
Embedded mode renders the card form into a container element on your page:
|
|
104
129
|
|
|
105
|
-
```tsx
|
|
130
|
+
```tsx no-check
|
|
106
131
|
// In RecurProvider config
|
|
107
132
|
<RecurProvider
|
|
108
133
|
config={{
|
|
109
134
|
publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY,
|
|
110
|
-
checkoutMode: 'embedded',
|
|
135
|
+
checkoutMode: 'embedded', // 'embedded' (default) | 'modal'
|
|
111
136
|
containerElementId: 'recur-checkout-container',
|
|
112
137
|
}}
|
|
113
138
|
>
|
|
114
139
|
{children}
|
|
115
140
|
</RecurProvider>
|
|
141
|
+
```
|
|
116
142
|
|
|
117
|
-
|
|
118
|
-
function CheckoutPage() {
|
|
143
|
+
```tsx
|
|
144
|
+
export function CheckoutPage() {
|
|
119
145
|
return (
|
|
120
146
|
<div>
|
|
121
147
|
<h1>Complete Your Purchase</h1>
|
|
122
|
-
{/* Recur
|
|
148
|
+
{/* Recur renders the payment form here */}
|
|
123
149
|
<div id="recur-checkout-container" />
|
|
124
150
|
</div>
|
|
125
151
|
)
|
|
126
152
|
}
|
|
127
153
|
```
|
|
128
154
|
|
|
129
|
-
|
|
155
|
+
### Payment failure handling
|
|
130
156
|
|
|
131
|
-
|
|
157
|
+
Failure details live in `error.details` (`failure_code`, `can_retry`):
|
|
132
158
|
|
|
133
159
|
```tsx
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
})
|
|
160
|
+
'use client'
|
|
161
|
+
|
|
162
|
+
import { useRecur, type PaymentFailureDetails } from 'recur-tw'
|
|
163
|
+
|
|
164
|
+
export function CheckoutWithErrorHandling({ productId }: { productId: string }) {
|
|
165
|
+
const { checkout } = useRecur()
|
|
166
|
+
|
|
167
|
+
const handleClick = () =>
|
|
168
|
+
checkout({
|
|
169
|
+
productId,
|
|
170
|
+
onPaymentFailed: (error) => {
|
|
171
|
+
const details = error.details as PaymentFailureDetails | undefined
|
|
172
|
+
switch (details?.failure_code) {
|
|
173
|
+
case 'INSUFFICIENT_FUNDS':
|
|
174
|
+
return {
|
|
175
|
+
action: 'custom' as const,
|
|
176
|
+
customTitle: 'é¤é”äøč¶³',
|
|
177
|
+
customMessage: 'č«ä½æēØå
¶ä»ä»ę¬¾ę¹å¼',
|
|
178
|
+
}
|
|
179
|
+
case 'NETWORK_ERROR':
|
|
180
|
+
case 'TIMEOUT':
|
|
181
|
+
return { action: 'retry' as const }
|
|
182
|
+
default:
|
|
183
|
+
// undefined = use the SDK's default handling
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
return <button onClick={handleClick}>Subscribe</button>
|
|
190
|
+
}
|
|
140
191
|
```
|
|
141
192
|
|
|
142
|
-
##
|
|
193
|
+
## useSubscribe Hook (with state management)
|
|
143
194
|
|
|
144
|
-
|
|
195
|
+
`useSubscribe()` returns `{ subscribe, isLoading, error, reset }` ā success
|
|
196
|
+
is reported through the `onPaymentComplete` option, not a return value.
|
|
145
197
|
|
|
146
198
|
```tsx
|
|
147
|
-
|
|
148
|
-
checkout({ productId: 'prod_subscription_xxx' })
|
|
199
|
+
'use client'
|
|
149
200
|
|
|
150
|
-
|
|
151
|
-
|
|
201
|
+
import { useState } from 'react'
|
|
202
|
+
import { useSubscribe, type SubscriptionResult } from 'recur-tw'
|
|
152
203
|
|
|
153
|
-
|
|
154
|
-
|
|
204
|
+
export function SubscribeWithState({ productId }: { productId: string }) {
|
|
205
|
+
const [subscription, setSubscription] = useState<SubscriptionResult | null>(null)
|
|
206
|
+
const { subscribe, isLoading, error } = useSubscribe({
|
|
207
|
+
onPaymentComplete: (sub) => {
|
|
208
|
+
setSubscription(sub)
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
if (subscription) {
|
|
213
|
+
return <p>Subscribed! ID: {subscription.id}</p>
|
|
214
|
+
}
|
|
155
215
|
|
|
156
|
-
|
|
157
|
-
|
|
216
|
+
return (
|
|
217
|
+
<>
|
|
218
|
+
<button onClick={() => subscribe({ productId })} disabled={isLoading}>
|
|
219
|
+
Subscribe
|
|
220
|
+
</button>
|
|
221
|
+
{error && <p className="error">{error.message}</p>}
|
|
222
|
+
</>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
158
225
|
```
|
|
159
226
|
|
|
160
227
|
## Listing Products
|
|
161
228
|
|
|
229
|
+
`useProducts()` returns `{ data, isLoading, error, refetch }`. Prices are in
|
|
230
|
+
the smallest currency unit (`29900` = NT$299).
|
|
231
|
+
|
|
162
232
|
```tsx
|
|
233
|
+
'use client'
|
|
234
|
+
|
|
163
235
|
import { useProducts } from 'recur-tw'
|
|
164
236
|
|
|
165
|
-
function PricingPage() {
|
|
166
|
-
const { products, isLoading } = useProducts({
|
|
167
|
-
type: 'SUBSCRIPTION', //
|
|
237
|
+
export function PricingPage() {
|
|
238
|
+
const { data: products, isLoading } = useProducts({
|
|
239
|
+
type: 'SUBSCRIPTION', // 'SUBSCRIPTION' | 'ONE_TIME' | 'CREDITS' | 'DONATION'
|
|
168
240
|
})
|
|
169
241
|
|
|
170
242
|
if (isLoading) return <div>Loading...</div>
|
|
171
243
|
|
|
172
244
|
return (
|
|
173
245
|
<div className="pricing-grid">
|
|
174
|
-
{products
|
|
175
|
-
<
|
|
246
|
+
{products?.map((product) => (
|
|
247
|
+
<div key={product.id}>
|
|
248
|
+
<h2>{product.name}</h2>
|
|
249
|
+
<p>
|
|
250
|
+
NT${(product.price / 100).toLocaleString('zh-TW')}
|
|
251
|
+
{product.billingPeriod === 'MONTHLY' && ' / ę'}
|
|
252
|
+
{product.billingPeriod === 'YEARLY' && ' / 幓'}
|
|
253
|
+
</p>
|
|
254
|
+
{product.trialDays ? <p>å
č²»č©¦ēØ {product.trialDays} 天</p> : null}
|
|
255
|
+
</div>
|
|
176
256
|
))}
|
|
177
257
|
</div>
|
|
178
258
|
)
|
|
179
259
|
}
|
|
180
260
|
```
|
|
181
261
|
|
|
182
|
-
|
|
262
|
+
Product types: `SUBSCRIPTION` (recurring), `ONE_TIME`, `CREDITS` (prepaid
|
|
263
|
+
wallet), `DONATION` (variable amount). All check out the same way.
|
|
183
264
|
|
|
184
|
-
|
|
185
|
-
onPaymentFailed: (error) => {
|
|
186
|
-
// error.code tells you what went wrong
|
|
187
|
-
switch (error.code) {
|
|
188
|
-
case 'CARD_DECLINED':
|
|
189
|
-
return { action: 'retry' }
|
|
190
|
-
case 'INSUFFICIENT_FUNDS':
|
|
191
|
-
return {
|
|
192
|
-
action: 'custom',
|
|
193
|
-
customTitle: 'é¤é”äøč¶³',
|
|
194
|
-
customMessage: 'č«ä½æēØå
¶ä»ä»ę¬¾ę¹å¼',
|
|
195
|
-
}
|
|
196
|
-
default:
|
|
197
|
-
return { action: 'close' }
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
```
|
|
265
|
+
## Payment Links (server-side, no frontend needed)
|
|
201
266
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
For server-rendered apps or custom flows:
|
|
267
|
+
Generate a shareable checkout URL from the server:
|
|
205
268
|
|
|
206
269
|
```typescript
|
|
207
|
-
|
|
208
|
-
const response = await fetch('https://api.recur.tw/v1/checkouts', {
|
|
209
|
-
method: 'POST',
|
|
210
|
-
headers: {
|
|
211
|
-
'X-Recur-Secret-Key': process.env.RECUR_SECRET_KEY,
|
|
212
|
-
'Content-Type': 'application/json',
|
|
213
|
-
},
|
|
214
|
-
body: JSON.stringify({
|
|
215
|
-
productId: 'prod_xxx',
|
|
216
|
-
customerEmail: 'user@example.com',
|
|
217
|
-
successUrl: 'https://yourapp.com/success',
|
|
218
|
-
cancelUrl: 'https://yourapp.com/cancel',
|
|
219
|
-
}),
|
|
220
|
-
})
|
|
270
|
+
import { Recur } from 'recur-tw/server'
|
|
221
271
|
|
|
222
|
-
const
|
|
223
|
-
// Redirect user to checkoutUrl
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
## Checkout Result Structure
|
|
272
|
+
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
|
|
227
273
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
billingPeriod?: string // 'MONTHLY', 'YEARLY' for subscriptions
|
|
235
|
-
currentPeriodEnd?: string // ISO date
|
|
236
|
-
trialEndsAt?: string // ISO date if trial
|
|
274
|
+
export async function createLink() {
|
|
275
|
+
const link = await recur.paymentLinks.create({
|
|
276
|
+
productSlug: 'pro-plan',
|
|
277
|
+
successUrl: 'https://yourapp.com/success',
|
|
278
|
+
})
|
|
279
|
+
return link.url // share via email, DM, invoice, ...
|
|
237
280
|
}
|
|
238
281
|
```
|
|
239
282
|
|
|
240
283
|
## Best Practices
|
|
241
284
|
|
|
242
|
-
1. **
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
285
|
+
1. **Default to Hosted Checkout** ā on-page `checkout()` fails outside
|
|
286
|
+
registered domains, including localhost.
|
|
287
|
+
2. **Success is confirmed by the server, not the browser** ā treat the
|
|
288
|
+
`successUrl` page as UX only; gate access via webhooks/entitlements.
|
|
289
|
+
3. **Show loading states** ā use `isCheckingOut` to disable buttons.
|
|
290
|
+
4. **Use `externalCustomerId`** ā links Recur customers to your user system.
|
|
291
|
+
5. **Test in sandbox first** ā use `pk_test_` keys during development.
|
|
247
292
|
|
|
248
293
|
## Related Skills
|
|
249
294
|
|