skillflow-ai 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,38 @@
1
- # Temporary Holding Version
1
+ # skillflow-ai
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Install [SkillFlow AI](https://directory.skillflow.ai.id) skills into Claude Code, Codex, or any agent's skills folder with
4
+ one command, and keep them up to date.
5
+
6
+ Needs a SkillFlow **Pro** account and Node.js 18 or newer.
7
+
8
+ ## Start
9
+
10
+ 1. Open the SkillFlow directory, go to **CLI token**, and make a token (it is shown once).
11
+ 2. On your computer:
12
+
13
+ ```bash
14
+ npx skillflow-ai login
15
+ ```
16
+
17
+ Paste the token. It is saved in `~/.skillflow/config.json`, readable only by you.
18
+
19
+ ## Commands
20
+
21
+ ```bash
22
+ npx skillflow-ai list # skills you can install
23
+ npx skillflow-ai add carousel-ringkasan-buku # into ~/.claude/skills
24
+ npx skillflow-ai add <slug> --agent codex # into ~/.codex/skills
25
+ npx skillflow-ai add <slug> --project # into ./.claude/skills of this folder
26
+ npx skillflow-ai add <slug> --dir <folder> # any folder (Antigravity and others)
27
+ npx skillflow-ai update # update everything you installed
28
+ npx skillflow-ai themes # colour themes for the result
29
+ npx skillflow-ai add <slug> --theme hijau-daun # result in that theme (THEME.md in the skill)
30
+ npx skillflow-ai add <slug> --colors "#1F5E3B,#F2B33D" # or your own two colours
31
+ npx skillflow-ai remove <slug>
32
+ npx skillflow-ai whoami
33
+ npx skillflow-ai logout
34
+ ```
35
+
36
+ If you have a brand profile on the directory, every skill you add carries it as `BRAND.md`.
37
+
38
+ The token stops working when your account leaves Pro, or when you revoke it on the directory.
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ // SkillFlow CLI: install SkillFlow skills into Claude Code, Codex or any folder with one command.
3
+ // Needs a personal token from the SkillFlow directory (Pro). No dependencies; Node 18+.
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, renameSync, chmodSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { join, resolve, dirname } from 'node:path'
7
+ import { createInterface } from 'node:readline'
8
+ import { readZip } from '../lib/unzip.mjs'
9
+
10
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
11
+ const HOME = process.env.SKILLFLOW_HOME || join(homedir(), '.skillflow')
12
+ const CONFIG = join(HOME, 'config.json')
13
+ const INSTALLED = join(HOME, 'installed.json')
14
+ const DEFAULT_SERVER = 'https://directory.skillflow.ai.id'
15
+ const AGENTS = { claude: join(homedir(), '.claude', 'skills'), codex: join(homedir(), '.codex', 'skills') }
16
+ const PROJECT = { claude: join('.claude', 'skills'), codex: join('.codex', 'skills') }
17
+
18
+ const say = (...a) => console.log(...a)
19
+ const fail = (msg, code = 1) => { console.error('✗ ' + msg); process.exit(code) }
20
+
21
+ function readJson(file, fallback) {
22
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return fallback }
23
+ }
24
+ function writePrivate(file, data) {
25
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
26
+ writeFileSync(file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
27
+ try { chmodSync(file, 0o600) } catch {}
28
+ }
29
+ const config = () => readJson(CONFIG, {})
30
+ const server = () => (process.env.SKILLFLOW_SERVER || config().server || DEFAULT_SERVER).replace(/\/+$/, '')
31
+
32
+ function parse(argv) {
33
+ const args = []; const opts = {}
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const a = argv[i]
36
+ if (a.startsWith('--')) {
37
+ const [k, v] = a.slice(2).split('=')
38
+ if (v !== undefined) opts[k] = v
39
+ else if (argv[i + 1] && !argv[i + 1].startsWith('-') && ['agent', 'dir', 'server', 'kind', 'token', 'theme', 'colors'].includes(k)) opts[k] = argv[++i]
40
+ else opts[k] = true
41
+ } else if (a === '-h') opts.help = true
42
+ else args.push(a)
43
+ }
44
+ return { args, opts }
45
+ }
46
+
47
+ async function api(path, { raw = false, token = config().token } = {}) {
48
+ if (!token) fail('Not logged in. Make a token on the SkillFlow directory (Pro), then run: npx skillflow-ai login')
49
+ let res
50
+ try {
51
+ res = await fetch(server() + path, { headers: { Authorization: 'Bearer ' + token, Accept: raw ? 'application/zip' : 'application/json', 'User-Agent': 'skillflow-cli/' + VERSION } })
52
+ } catch (e) { fail('Could not reach ' + server() + ' (' + e.message + ').') }
53
+ if (!res.ok) {
54
+ let body = {}
55
+ try { body = await res.json() } catch {}
56
+ fail(body.message || `The server answered ${res.status}.`, res.status === 401 || res.status === 403 ? 2 : 1)
57
+ }
58
+ return raw ? { buf: Buffer.from(await res.arrayBuffer()), version: res.headers.get('x-skillflow-version') } : res.json()
59
+ }
60
+
61
+ function ask(question) {
62
+ return new Promise(done => {
63
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
64
+ rl.question(question, a => { rl.close(); done(a.trim()) })
65
+ })
66
+ }
67
+
68
+ function targetDir(opts) {
69
+ if (opts.dir && opts.dir !== true) return resolve(opts.dir)
70
+ const agent = (opts.agent && opts.agent !== true ? opts.agent : 'claude').toLowerCase()
71
+ if (opts.project) {
72
+ if (!PROJECT[agent]) fail(`For --agent ${agent} use --dir <folder>.`)
73
+ return resolve(PROJECT[agent])
74
+ }
75
+ if (!AGENTS[agent]) fail(`No default folder for --agent ${agent}. Use --dir <folder> (the agent's skills folder).`)
76
+ return AGENTS[agent]
77
+ }
78
+
79
+ // Colour theme of the result, sent as the same `?tema=` the directory's ZIP link uses. undefined = not given,
80
+ // '' = no theme (the skill's own colours; also how --theme default clears a saved one).
81
+ function themeQuery(opts) {
82
+ if (opts.colors && opts.colors !== true) {
83
+ const c = String(opts.colors).split(',').map(s => s.trim())
84
+ if (c.length !== 2 || !c.every(x => /^#?[0-9a-fA-F]{6}$/.test(x))) fail('--colors takes two hex colours: --colors "#1F5E3B,#F2B33D"')
85
+ const hex = c.map(x => (x.startsWith('#') ? x : '#' + x).toUpperCase())
86
+ return `?tema=custom&utama=${encodeURIComponent(hex[0])}&aksen=${encodeURIComponent(hex[1])}`
87
+ }
88
+ if (opts.theme === undefined) return undefined
89
+ if (opts.theme === true) fail('Name a theme: --theme <name> (see: npx skillflow-ai themes)')
90
+ if (['default', 'none'].includes(opts.theme)) return ''
91
+ if (!/^[a-z-]{1,40}$/.test(opts.theme)) fail(`"${opts.theme}" is not a theme name. See: npx skillflow-ai themes`)
92
+ return `?tema=${opts.theme}`
93
+ }
94
+
95
+ async function install(slug, dir, theme = '') {
96
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) fail(`"${slug}" is not a skill name.`)
97
+ const { buf, version } = await api(`/api/skills/v1/skills/${slug}/bundle${theme}`, { raw: true })
98
+ const entries = readZip(buf)
99
+ // Bundles hold "<slug>/…". Anything outside that folder is ignored.
100
+ const own = entries.filter(e => e.name === slug + '/' || e.name.startsWith(slug + '/'))
101
+ if (!own.some(e => !e.dir)) fail(`The bundle for ${slug} is empty.`)
102
+ mkdirSync(dir, { recursive: true })
103
+ const final = join(dir, slug)
104
+ const tmp = join(dir, `.${slug}.skillflow-tmp-${process.pid}`)
105
+ rmSync(tmp, { recursive: true, force: true })
106
+ for (const e of own) {
107
+ const path = join(tmp, e.name.slice(slug.length + 1))
108
+ if (!resolve(path).startsWith(resolve(tmp))) fail(`unsafe path in bundle: ${e.name}`)
109
+ if (e.dir) { mkdirSync(path, { recursive: true }); continue }
110
+ mkdirSync(dirname(path), { recursive: true })
111
+ writeFileSync(path, e.data)
112
+ }
113
+ // Swap in one step, so a failed download never leaves a half-written skill.
114
+ rmSync(final, { recursive: true, force: true })
115
+ renameSync(tmp, final)
116
+ const list = readJson(INSTALLED, {})
117
+ list[final] = { slug, version: version || null, dir, theme: theme || null, at: new Date().toISOString() }
118
+ writePrivate(INSTALLED, list)
119
+ return { final, version }
120
+ }
121
+
122
+ const HELP = `skillflow ${VERSION}: install SkillFlow skills into your AI agent
123
+
124
+ npx skillflow-ai login [--token sf_…] [--server URL] save your personal token (make it on the directory, Pro)
125
+ npx skillflow-ai whoami who the token belongs to
126
+ npx skillflow-ai list [--kind video] skills you can install
127
+ npx skillflow-ai add <slug> [<slug>…] install into ~/.claude/skills
128
+ --theme <name> | --colors "#hex,#hex" colour theme of the result (THEME.md)
129
+ --agent codex ~/.codex/skills instead
130
+ --project ./.claude/skills (or ./.codex/skills) in this folder
131
+ --dir <folder> any folder (Antigravity and other agents)
132
+ npx skillflow-ai update [<slug>] update installed skills to the latest version
133
+ --theme <name> | --colors … | --theme default change the colour theme while updating
134
+ npx skillflow-ai themes list the colour themes
135
+ npx skillflow-ai remove <slug> [--dir <folder>] delete an installed skill
136
+ npx skillflow-ai logout forget the token on this computer
137
+ `
138
+
139
+ async function main() {
140
+ const [cmd, ...rest] = process.argv.slice(2)
141
+ const { args, opts } = parse(rest)
142
+ switch (cmd) {
143
+ case 'login': {
144
+ const token = opts.token && opts.token !== true ? opts.token : await ask('Paste your SkillFlow token: ')
145
+ if (!/^sf_[A-Za-z0-9]{20,}$/.test(token)) fail('That does not look like a SkillFlow token (it starts with sf_).')
146
+ const base = opts.server && opts.server !== true ? opts.server.replace(/\/+$/, '') : undefined
147
+ if (base) process.env.SKILLFLOW_SERVER = base
148
+ const me = await api('/api/skills/v1/me', { token })
149
+ writePrivate(CONFIG, { ...config(), token, ...(base ? { server: base } : {}) })
150
+ say(`✓ Logged in as ${me.name} (${me.plan}). Token saved in ${CONFIG}.`)
151
+ if (me.brand_profile) say(` Skills you add carry your brand profile "${me.brand_profile}" as BRAND.md.`)
152
+ break
153
+ }
154
+ case 'logout': {
155
+ const c = config(); delete c.token; writePrivate(CONFIG, c)
156
+ say('✓ Token removed from this computer. (Revoke it on the directory too if it may have leaked.)')
157
+ break
158
+ }
159
+ case 'whoami': {
160
+ const me = await api('/api/skills/v1/me')
161
+ say(`${me.name} · ${me.plan}${me.brand_profile ? ` · brand profile: ${me.brand_profile}` : ''} · ${server()}`)
162
+ break
163
+ }
164
+ case 'list': {
165
+ const { skills } = await api('/api/skills/v1/catalog')
166
+ const installed = new Set(Object.values(readJson(INSTALLED, {})).map(r => r.slug))
167
+ const rows = skills.filter(s => s.installable && (!opts.kind || s.kind === opts.kind))
168
+ for (const s of rows) say(`${installed.has(s.slug) ? '✓' : ' '} ${s.slug.padEnd(42)} ${s.kind.padEnd(12)} v${s.version} ${s.title}`)
169
+ say(`\n${rows.length} skill(s). Install one with: npx skillflow-ai add <slug>`)
170
+ break
171
+ }
172
+ case 'add': {
173
+ if (!args.length) fail('Name a skill: npx skillflow-ai add <slug>')
174
+ const dir = targetDir(opts)
175
+ const theme = themeQuery(opts) ?? ''
176
+ for (const slug of args) {
177
+ const r = await install(slug, dir, theme)
178
+ say(`✓ ${slug}${r.version ? ' v' + r.version : ''} → ${r.final}`)
179
+ }
180
+ break
181
+ }
182
+ case 'update': {
183
+ const list = readJson(INSTALLED, {})
184
+ const want = Object.entries(list).filter(([, r]) => !args.length || args.includes(r.slug))
185
+ if (!want.length) { say('Nothing installed yet.'); break }
186
+ const { skills } = await api('/api/skills/v1/catalog')
187
+ const latest = Object.fromEntries(skills.map(s => [s.slug, s.version]))
188
+ // The theme a skill was installed with is kept; --theme / --colors replaces it (and reinstalls).
189
+ const given = themeQuery(opts)
190
+ let n = 0
191
+ for (const [, r] of want) {
192
+ if (!latest[r.slug]) { say(`- ${r.slug}: no longer in the catalog (left as it is)`); continue }
193
+ const theme = given ?? (r.theme || '')
194
+ if (latest[r.slug] === r.version && !opts.force && theme === (r.theme || '')) continue
195
+ const res = await install(r.slug, r.dir, theme)
196
+ say(`✓ ${r.slug} ${r.version || '?'} → ${res.version || latest[r.slug]}`); n++
197
+ }
198
+ say(n ? `${n} skill(s) updated.` : 'Everything is up to date.')
199
+ break
200
+ }
201
+ case 'themes': {
202
+ const { themes } = await api('/api/skills/v1/catalog')
203
+ if (!themes || !themes.length) { say('This directory has no colour themes yet.'); break }
204
+ for (const t of themes) say(' ' + t)
205
+ say('\nUse one with: npx skillflow-ai add <slug> --theme ' + themes[0])
206
+ say('Or your own two colours: --colors "#1F5E3B,#F2B33D". Without either, the skill keeps its own colours.')
207
+ break
208
+ }
209
+ case 'remove': {
210
+ if (!args.length) fail('Name a skill: npx skillflow-ai remove <slug>')
211
+ const list = readJson(INSTALLED, {})
212
+ for (const slug of args) {
213
+ const keys = Object.keys(list).filter(k => list[k].slug === slug && (!opts.dir || list[k].dir === resolve(opts.dir)))
214
+ if (!keys.length) { say(`- ${slug}: not installed by skillflow`); continue }
215
+ for (const k of keys) { rmSync(k, { recursive: true, force: true }); delete list[k]; say(`✓ removed ${k}`) }
216
+ }
217
+ writePrivate(INSTALLED, list)
218
+ break
219
+ }
220
+ case '--version': case '-v': case 'version': say(VERSION); break
221
+ default: say(HELP); if (cmd && cmd !== 'help' && cmd !== '--help') process.exit(1)
222
+ }
223
+ }
224
+
225
+ main().catch(e => fail(e.message))
package/lib/unzip.mjs ADDED
@@ -0,0 +1,52 @@
1
+ // Minimal ZIP reader (stored + deflate), enough for SkillFlow skill bundles. No dependencies.
2
+ // Every entry name is checked before it becomes a path: no absolute paths, no "..", no drive letters.
3
+ import { inflateRawSync } from 'node:zlib'
4
+
5
+ const EOCD = 0x06054b50
6
+ const CEN = 0x02014b50
7
+ const LOC = 0x04034b50
8
+
9
+ /** @returns {{name: string, dir: boolean, data: Buffer}[]} */
10
+ export function readZip(buf) {
11
+ let eocd = -1
12
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i--) {
13
+ if (buf.readUInt32LE(i) === EOCD) { eocd = i; break }
14
+ }
15
+ if (eocd < 0) throw new Error('not a ZIP file')
16
+ const count = buf.readUInt16LE(eocd + 10)
17
+ let p = buf.readUInt32LE(eocd + 16)
18
+ const out = []
19
+ for (let n = 0; n < count; n++) {
20
+ if (buf.readUInt32LE(p) !== CEN) throw new Error('broken ZIP directory')
21
+ const method = buf.readUInt16LE(p + 10)
22
+ const csize = buf.readUInt32LE(p + 20)
23
+ const nameLen = buf.readUInt16LE(p + 28)
24
+ const extraLen = buf.readUInt16LE(p + 30)
25
+ const commentLen = buf.readUInt16LE(p + 32)
26
+ const local = buf.readUInt32LE(p + 42)
27
+ const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8')
28
+ p += 46 + nameLen + extraLen + commentLen
29
+
30
+ if (buf.readUInt32LE(local) !== LOC) throw new Error('broken ZIP entry')
31
+ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28)
32
+ const raw = buf.subarray(start, start + csize)
33
+ const dir = name.endsWith('/')
34
+ let data = Buffer.alloc(0)
35
+ if (!dir) {
36
+ if (method === 0) data = Buffer.from(raw)
37
+ else if (method === 8) data = inflateRawSync(raw)
38
+ else throw new Error(`unsupported compression in ${name}`)
39
+ }
40
+ out.push({ name: safeName(name), dir, data })
41
+ }
42
+ return out
43
+ }
44
+
45
+ /** A relative POSIX path, or an error. */
46
+ export function safeName(name) {
47
+ const n = name.replace(/\\/g, '/')
48
+ if (!n || n.startsWith('/') || /^[a-zA-Z]:/.test(n) || n.split('/').some(part => part === '..')) {
49
+ throw new Error(`unsafe path in ZIP: ${name}`)
50
+ }
51
+ return n
52
+ }
package/package.json CHANGED
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "name": "skillflow-ai",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Install SkillFlow AI skills into Claude Code, Codex or any agent folder with one command.",
5
+ "type": "module",
6
+ "bin": { "skillflow-ai": "bin/skillflow.mjs", "skillflow": "bin/skillflow.mjs" },
7
+ "files": ["bin", "lib", "README.md"],
8
+ "engines": { "node": ">=18" },
9
+ "keywords": ["skillflow", "claude-code", "codex", "ai-agent", "skills"],
10
+ "homepage": "https://directory.skillflow.ai.id",
11
+ "license": "UNLICENSED",
12
+ "scripts": { "test": "node test/unzip.test.mjs" }
13
+ }