@skillsmanager/cli 0.0.1
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/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/auth.d.ts +5 -0
- package/dist/auth.js +68 -0
- package/dist/backends/gdrive.d.ts +24 -0
- package/dist/backends/gdrive.js +371 -0
- package/dist/backends/interface.d.ts +14 -0
- package/dist/backends/interface.js +1 -0
- package/dist/backends/local.d.ts +20 -0
- package/dist/backends/local.js +159 -0
- package/dist/bm25.d.ts +20 -0
- package/dist/bm25.js +65 -0
- package/dist/cache.d.ts +21 -0
- package/dist/cache.js +59 -0
- package/dist/commands/add.d.ts +3 -0
- package/dist/commands/add.js +62 -0
- package/dist/commands/collection.d.ts +1 -0
- package/dist/commands/collection.js +42 -0
- package/dist/commands/fetch.d.ts +5 -0
- package/dist/commands/fetch.js +46 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +45 -0
- package/dist/commands/install.d.ts +8 -0
- package/dist/commands/install.js +89 -0
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.js +38 -0
- package/dist/commands/refresh.d.ts +1 -0
- package/dist/commands/refresh.js +46 -0
- package/dist/commands/registry.d.ts +14 -0
- package/dist/commands/registry.js +258 -0
- package/dist/commands/search.d.ts +1 -0
- package/dist/commands/search.js +37 -0
- package/dist/commands/setup/google.d.ts +1 -0
- package/dist/commands/setup/google.js +281 -0
- package/dist/commands/update.d.ts +3 -0
- package/dist/commands/update.js +83 -0
- package/dist/config.d.ts +28 -0
- package/dist/config.js +136 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +128 -0
- package/dist/ready.d.ts +15 -0
- package/dist/ready.js +39 -0
- package/dist/registry.d.ts +10 -0
- package/dist/registry.js +58 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.js +13 -0
- package/package.json +56 -0
- package/skills/skillsmanager/SKILL.md +139 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ajay Prakash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Skills Manager
|
|
2
|
+
|
|
3
|
+
A CLI for AI agents to discover, fetch, and share agent skills stored in Google Drive.
|
|
4
|
+
|
|
5
|
+
## What is it?
|
|
6
|
+
|
|
7
|
+
Skills Manager lets you maintain a personal library of agent skills in Google Drive and install them into any supported AI agent (Claude, Cursor, Windsurf, Copilot, etc.) with a single command.
|
|
8
|
+
|
|
9
|
+
Skills are downloaded to a local cache (`~/.skillsmanager/cache/`) and symlinked into the agent's skills directory. No duplication — one copy, many agents.
|
|
10
|
+
|
|
11
|
+
## Supported agents
|
|
12
|
+
|
|
13
|
+
`claude`, `codex`, `cursor`, `windsurf`, `copilot`, `gemini`, `roo`, `agents`
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g skillsmanager
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Google Drive setup
|
|
22
|
+
|
|
23
|
+
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
|
24
|
+
2. Create a project → enable the **Google Drive API**
|
|
25
|
+
3. Create OAuth 2.0 credentials (Desktop app)
|
|
26
|
+
4. Download `credentials.json` and save it to `~/.skillsmanager/credentials.json`
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Authenticate and discover registries
|
|
32
|
+
skillsmanager init
|
|
33
|
+
|
|
34
|
+
# List all available skills
|
|
35
|
+
skillsmanager list
|
|
36
|
+
|
|
37
|
+
# Search skills by name or description
|
|
38
|
+
skillsmanager search <query>
|
|
39
|
+
|
|
40
|
+
# Download a skill and install it for an agent
|
|
41
|
+
skillsmanager fetch <skill-name> --agent claude
|
|
42
|
+
|
|
43
|
+
# Add a local skill to your registry
|
|
44
|
+
skillsmanager add ./my-skill
|
|
45
|
+
|
|
46
|
+
# Push local changes to an existing skill back to Drive
|
|
47
|
+
skillsmanager update <skill-name>
|
|
48
|
+
|
|
49
|
+
# Re-scan Drive for new registries
|
|
50
|
+
skillsmanager refresh
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Registry format
|
|
54
|
+
|
|
55
|
+
Skills are indexed by a `SKILLS_SYNC.yaml` file inside any Google Drive folder you own:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
name: my-skills
|
|
59
|
+
owner: you@example.com
|
|
60
|
+
skills:
|
|
61
|
+
- name: code-review
|
|
62
|
+
path: code-review/
|
|
63
|
+
description: Reviews code for bugs, style, and security issues
|
|
64
|
+
- name: write-tests
|
|
65
|
+
path: write-tests/
|
|
66
|
+
description: Generates unit tests for a given function or module
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Each skill is a directory containing a `SKILL.md` file with YAML frontmatter:
|
|
70
|
+
|
|
71
|
+
```markdown
|
|
72
|
+
---
|
|
73
|
+
name: code-review
|
|
74
|
+
description: Reviews code for bugs, style, and security issues
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
... skill instructions ...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Skills Manager auto-discovers any `SKILLS_SYNC.yaml` file owned by your Google account, so registries are found automatically on `skillsmanager init` or `skillsmanager refresh`.
|
|
81
|
+
|
|
82
|
+
## Design doc
|
|
83
|
+
|
|
84
|
+
See [WRITEUP.md](./WRITEUP.md) for the full design.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OAuth2Client } from "google-auth-library";
|
|
2
|
+
export declare function runAuthFlow(): Promise<OAuth2Client>;
|
|
3
|
+
export declare function getAuthClient(): OAuth2Client;
|
|
4
|
+
export declare function hasToken(): boolean;
|
|
5
|
+
export declare function ensureAuth(): Promise<OAuth2Client>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import readline from "readline";
|
|
3
|
+
import { google } from "googleapis";
|
|
4
|
+
import { TOKEN_PATH, ensureConfigDir, readCredentials, credentialsExist } from "./config.js";
|
|
5
|
+
const SCOPES = ["https://www.googleapis.com/auth/drive"];
|
|
6
|
+
function createOAuth2Client() {
|
|
7
|
+
const { client_id, client_secret } = readCredentials();
|
|
8
|
+
return new google.auth.OAuth2(client_id, client_secret, "urn:ietf:wg:oauth:2.0:oob");
|
|
9
|
+
}
|
|
10
|
+
function saveToken(client) {
|
|
11
|
+
ensureConfigDir();
|
|
12
|
+
fs.writeFileSync(TOKEN_PATH, JSON.stringify(client.credentials, null, 2));
|
|
13
|
+
}
|
|
14
|
+
function prompt(question) {
|
|
15
|
+
const rl = readline.createInterface({
|
|
16
|
+
input: process.stdin,
|
|
17
|
+
output: process.stdout,
|
|
18
|
+
});
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
rl.question(question, (answer) => {
|
|
21
|
+
rl.close();
|
|
22
|
+
resolve(answer.trim());
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export async function runAuthFlow() {
|
|
27
|
+
const client = createOAuth2Client();
|
|
28
|
+
const authUrl = client.generateAuthUrl({
|
|
29
|
+
access_type: "offline",
|
|
30
|
+
scope: SCOPES,
|
|
31
|
+
prompt: "consent",
|
|
32
|
+
});
|
|
33
|
+
console.log("\nOpen this URL in your browser to authorize Skills Manager:\n");
|
|
34
|
+
console.log(authUrl);
|
|
35
|
+
console.log();
|
|
36
|
+
const code = await prompt("Paste the authorization code here: ");
|
|
37
|
+
const { tokens } = await client.getToken(code);
|
|
38
|
+
client.setCredentials(tokens);
|
|
39
|
+
saveToken(client);
|
|
40
|
+
return client;
|
|
41
|
+
}
|
|
42
|
+
export function getAuthClient() {
|
|
43
|
+
if (!fs.existsSync(TOKEN_PATH)) {
|
|
44
|
+
throw new Error(`Not authenticated. Run "skillsmanager init" first.`);
|
|
45
|
+
}
|
|
46
|
+
const client = createOAuth2Client();
|
|
47
|
+
const token = JSON.parse(fs.readFileSync(TOKEN_PATH, "utf-8"));
|
|
48
|
+
client.setCredentials(token);
|
|
49
|
+
client.on("tokens", (newTokens) => {
|
|
50
|
+
const merged = { ...client.credentials, ...newTokens };
|
|
51
|
+
client.setCredentials(merged);
|
|
52
|
+
saveToken(client);
|
|
53
|
+
});
|
|
54
|
+
return client;
|
|
55
|
+
}
|
|
56
|
+
export function hasToken() {
|
|
57
|
+
return fs.existsSync(TOKEN_PATH);
|
|
58
|
+
}
|
|
59
|
+
export async function ensureAuth() {
|
|
60
|
+
if (!credentialsExist()) {
|
|
61
|
+
throw new Error("No credentials found. Run: skillsmanager setup google");
|
|
62
|
+
}
|
|
63
|
+
if (!hasToken()) {
|
|
64
|
+
console.log("Not authenticated — launching login...\n");
|
|
65
|
+
return runAuthFlow();
|
|
66
|
+
}
|
|
67
|
+
return getAuthClient();
|
|
68
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { OAuth2Client } from "google-auth-library";
|
|
2
|
+
import type { StorageBackend } from "./interface.js";
|
|
3
|
+
import type { CollectionFile, CollectionInfo, RegistryCollectionRef, RegistryFile, RegistryInfo } from "../types.js";
|
|
4
|
+
export declare class GDriveBackend implements StorageBackend {
|
|
5
|
+
private drive;
|
|
6
|
+
private oauth2;
|
|
7
|
+
constructor(auth: OAuth2Client);
|
|
8
|
+
getOwner(): Promise<string>;
|
|
9
|
+
getOwnerEmail(): Promise<string>;
|
|
10
|
+
discoverCollections(): Promise<Omit<CollectionInfo, "id">[]>;
|
|
11
|
+
readCollection(collection: CollectionInfo): Promise<CollectionFile>;
|
|
12
|
+
writeCollection(collection: CollectionInfo, data: CollectionFile): Promise<void>;
|
|
13
|
+
downloadSkill(collection: CollectionInfo, skillName: string, destDir: string): Promise<void>;
|
|
14
|
+
private downloadFolder;
|
|
15
|
+
createCollection(folderName: string): Promise<CollectionInfo>;
|
|
16
|
+
uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<void>;
|
|
17
|
+
discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
|
|
18
|
+
readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
|
|
19
|
+
writeRegistry(registry: RegistryInfo, data: RegistryFile): Promise<void>;
|
|
20
|
+
resolveCollectionRef(ref: RegistryCollectionRef): Promise<Omit<CollectionInfo, "id"> | null>;
|
|
21
|
+
createRegistry(name?: string): Promise<RegistryInfo>;
|
|
22
|
+
private findFolder;
|
|
23
|
+
private uploadFolder;
|
|
24
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { google } from "googleapis";
|
|
4
|
+
import { parseCollection, serializeCollection, parseRegistryFile, serializeRegistryFile, COLLECTION_FILENAME, LEGACY_COLLECTION_FILENAME, REGISTRY_FILENAME, } from "../registry.js";
|
|
5
|
+
import { Readable } from "stream";
|
|
6
|
+
import { randomUUID } from "crypto";
|
|
7
|
+
const FOLDER_MIME = "application/vnd.google-apps.folder";
|
|
8
|
+
export class GDriveBackend {
|
|
9
|
+
drive;
|
|
10
|
+
oauth2;
|
|
11
|
+
constructor(auth) {
|
|
12
|
+
this.drive = google.drive({ version: "v3", auth });
|
|
13
|
+
this.oauth2 = google.oauth2({ version: "v2", auth });
|
|
14
|
+
}
|
|
15
|
+
async getOwner() {
|
|
16
|
+
const res = await this.oauth2.userinfo.get();
|
|
17
|
+
return res.data.email ?? "";
|
|
18
|
+
}
|
|
19
|
+
// Alias for backwards compat
|
|
20
|
+
async getOwnerEmail() {
|
|
21
|
+
return this.getOwner();
|
|
22
|
+
}
|
|
23
|
+
// ── Collection operations ────────────────────────────────────────────────
|
|
24
|
+
async discoverCollections() {
|
|
25
|
+
const collections = [];
|
|
26
|
+
let pageToken;
|
|
27
|
+
do {
|
|
28
|
+
const res = await this.drive.files.list({
|
|
29
|
+
q: `(name='${COLLECTION_FILENAME}' or name='${LEGACY_COLLECTION_FILENAME}') and 'me' in owners and trashed=false`,
|
|
30
|
+
fields: "nextPageToken, files(id, name, parents)",
|
|
31
|
+
pageSize: 100,
|
|
32
|
+
...(pageToken ? { pageToken } : {}),
|
|
33
|
+
});
|
|
34
|
+
pageToken = res.data.nextPageToken ?? undefined;
|
|
35
|
+
for (const file of res.data.files ?? []) {
|
|
36
|
+
const parentId = file.parents?.[0];
|
|
37
|
+
if (!parentId)
|
|
38
|
+
continue;
|
|
39
|
+
const parent = await this.drive.files.get({
|
|
40
|
+
fileId: parentId,
|
|
41
|
+
fields: "id, name",
|
|
42
|
+
});
|
|
43
|
+
const rawName = parent.data.name ?? "unknown";
|
|
44
|
+
collections.push({
|
|
45
|
+
name: rawName.replace(/^SKILLS_/i, ""),
|
|
46
|
+
backend: "gdrive",
|
|
47
|
+
folderId: parentId,
|
|
48
|
+
registryFileId: file.id ?? undefined,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
} while (pageToken);
|
|
52
|
+
return collections;
|
|
53
|
+
}
|
|
54
|
+
async readCollection(collection) {
|
|
55
|
+
let fileId = collection.registryFileId;
|
|
56
|
+
if (!fileId) {
|
|
57
|
+
// Try new filename first, fall back to legacy
|
|
58
|
+
for (const filename of [COLLECTION_FILENAME, LEGACY_COLLECTION_FILENAME]) {
|
|
59
|
+
const res = await this.drive.files.list({
|
|
60
|
+
q: `name='${filename}' and '${collection.folderId}' in parents and trashed=false`,
|
|
61
|
+
fields: "files(id)",
|
|
62
|
+
pageSize: 1,
|
|
63
|
+
});
|
|
64
|
+
fileId = res.data.files?.[0]?.id ?? undefined;
|
|
65
|
+
if (fileId)
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
if (!fileId) {
|
|
69
|
+
throw new Error(`Collection file not found in "${collection.name}"`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const res = await this.drive.files.get({ fileId, alt: "media" }, { responseType: "text" });
|
|
73
|
+
return parseCollection(res.data);
|
|
74
|
+
}
|
|
75
|
+
async writeCollection(collection, data) {
|
|
76
|
+
const content = serializeCollection(data);
|
|
77
|
+
const media = {
|
|
78
|
+
mimeType: "text/yaml",
|
|
79
|
+
body: Readable.from(content),
|
|
80
|
+
};
|
|
81
|
+
if (collection.registryFileId) {
|
|
82
|
+
await this.drive.files.update({
|
|
83
|
+
fileId: collection.registryFileId,
|
|
84
|
+
media,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
// Try to find existing file (new or legacy name)
|
|
89
|
+
let existingId;
|
|
90
|
+
for (const filename of [COLLECTION_FILENAME, LEGACY_COLLECTION_FILENAME]) {
|
|
91
|
+
const res = await this.drive.files.list({
|
|
92
|
+
q: `name='${filename}' and '${collection.folderId}' in parents and trashed=false`,
|
|
93
|
+
fields: "files(id)",
|
|
94
|
+
pageSize: 1,
|
|
95
|
+
});
|
|
96
|
+
existingId = res.data.files?.[0]?.id ?? undefined;
|
|
97
|
+
if (existingId)
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
if (existingId) {
|
|
101
|
+
await this.drive.files.update({ fileId: existingId, media });
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
await this.drive.files.create({
|
|
105
|
+
requestBody: {
|
|
106
|
+
name: COLLECTION_FILENAME,
|
|
107
|
+
parents: [collection.folderId],
|
|
108
|
+
},
|
|
109
|
+
media,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async downloadSkill(collection, skillName, destDir) {
|
|
115
|
+
const res = await this.drive.files.list({
|
|
116
|
+
q: `name='${skillName}' and '${collection.folderId}' in parents and mimeType='${FOLDER_MIME}' and trashed=false`,
|
|
117
|
+
fields: "files(id, name)",
|
|
118
|
+
pageSize: 1,
|
|
119
|
+
});
|
|
120
|
+
const skillFolder = res.data.files?.[0];
|
|
121
|
+
if (!skillFolder?.id) {
|
|
122
|
+
throw new Error(`Skill folder "${skillName}" not found in collection "${collection.name}"`);
|
|
123
|
+
}
|
|
124
|
+
await this.downloadFolder(skillFolder.id, destDir);
|
|
125
|
+
}
|
|
126
|
+
async downloadFolder(folderId, destDir) {
|
|
127
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
128
|
+
let pageToken;
|
|
129
|
+
do {
|
|
130
|
+
const res = await this.drive.files.list({
|
|
131
|
+
q: `'${folderId}' in parents and trashed=false`,
|
|
132
|
+
fields: "nextPageToken, files(id, name, mimeType)",
|
|
133
|
+
pageSize: 100,
|
|
134
|
+
...(pageToken ? { pageToken } : {}),
|
|
135
|
+
});
|
|
136
|
+
pageToken = res.data.nextPageToken ?? undefined;
|
|
137
|
+
for (const file of res.data.files ?? []) {
|
|
138
|
+
if (file.mimeType === FOLDER_MIME) {
|
|
139
|
+
await this.downloadFolder(file.id, path.join(destDir, file.name));
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const filePath = path.join(destDir, file.name);
|
|
143
|
+
const fileRes = await this.drive.files.get({ fileId: file.id, alt: "media" }, { responseType: "stream" });
|
|
144
|
+
await new Promise((resolve, reject) => {
|
|
145
|
+
const dest = fs.createWriteStream(filePath);
|
|
146
|
+
fileRes.data.pipe(dest);
|
|
147
|
+
dest.on("finish", resolve);
|
|
148
|
+
dest.on("error", reject);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} while (pageToken);
|
|
153
|
+
}
|
|
154
|
+
async createCollection(folderName) {
|
|
155
|
+
const folderRes = await this.drive.files.create({
|
|
156
|
+
requestBody: {
|
|
157
|
+
name: folderName,
|
|
158
|
+
mimeType: FOLDER_MIME,
|
|
159
|
+
},
|
|
160
|
+
fields: "id, name",
|
|
161
|
+
});
|
|
162
|
+
const folderId = folderRes.data.id;
|
|
163
|
+
const owner = await this.getOwnerEmail();
|
|
164
|
+
const logicalName = folderName.replace(/^SKILLS_/i, "");
|
|
165
|
+
const emptyCollection = { name: logicalName, owner, skills: [] };
|
|
166
|
+
const content = serializeCollection(emptyCollection);
|
|
167
|
+
const fileRes = await this.drive.files.create({
|
|
168
|
+
requestBody: {
|
|
169
|
+
name: COLLECTION_FILENAME,
|
|
170
|
+
parents: [folderId],
|
|
171
|
+
},
|
|
172
|
+
media: { mimeType: "text/yaml", body: Readable.from(content) },
|
|
173
|
+
fields: "id",
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
id: randomUUID(),
|
|
177
|
+
name: logicalName,
|
|
178
|
+
backend: "gdrive",
|
|
179
|
+
folderId,
|
|
180
|
+
registryFileId: fileRes.data.id ?? undefined,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
async uploadSkill(collection, localPath, skillName) {
|
|
184
|
+
let folderId = await this.findFolder(skillName, collection.folderId);
|
|
185
|
+
if (!folderId) {
|
|
186
|
+
const res = await this.drive.files.create({
|
|
187
|
+
requestBody: {
|
|
188
|
+
name: skillName,
|
|
189
|
+
mimeType: FOLDER_MIME,
|
|
190
|
+
parents: [collection.folderId],
|
|
191
|
+
},
|
|
192
|
+
fields: "id",
|
|
193
|
+
});
|
|
194
|
+
folderId = res.data.id;
|
|
195
|
+
}
|
|
196
|
+
await this.uploadFolder(localPath, folderId);
|
|
197
|
+
}
|
|
198
|
+
// ── Registry operations ──────────────────────────────────────────────────
|
|
199
|
+
async discoverRegistries() {
|
|
200
|
+
const registries = [];
|
|
201
|
+
let pageToken;
|
|
202
|
+
do {
|
|
203
|
+
const res = await this.drive.files.list({
|
|
204
|
+
q: `name='${REGISTRY_FILENAME}' and 'me' in owners and trashed=false`,
|
|
205
|
+
fields: "nextPageToken, files(id, name, parents)",
|
|
206
|
+
pageSize: 100,
|
|
207
|
+
...(pageToken ? { pageToken } : {}),
|
|
208
|
+
});
|
|
209
|
+
pageToken = res.data.nextPageToken ?? undefined;
|
|
210
|
+
for (const file of res.data.files ?? []) {
|
|
211
|
+
const parentId = file.parents?.[0];
|
|
212
|
+
if (!parentId)
|
|
213
|
+
continue;
|
|
214
|
+
const parent = await this.drive.files.get({
|
|
215
|
+
fileId: parentId,
|
|
216
|
+
fields: "id, name",
|
|
217
|
+
});
|
|
218
|
+
registries.push({
|
|
219
|
+
name: (parent.data.name ?? "unknown").replace(/^SKILLS_/i, ""),
|
|
220
|
+
backend: "gdrive",
|
|
221
|
+
folderId: parentId,
|
|
222
|
+
fileId: file.id ?? undefined,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
} while (pageToken);
|
|
226
|
+
return registries;
|
|
227
|
+
}
|
|
228
|
+
async readRegistry(registry) {
|
|
229
|
+
let fileId = registry.fileId;
|
|
230
|
+
if (!fileId) {
|
|
231
|
+
const res = await this.drive.files.list({
|
|
232
|
+
q: `name='${REGISTRY_FILENAME}' and '${registry.folderId}' in parents and trashed=false`,
|
|
233
|
+
fields: "files(id)",
|
|
234
|
+
pageSize: 1,
|
|
235
|
+
});
|
|
236
|
+
fileId = res.data.files?.[0]?.id ?? undefined;
|
|
237
|
+
if (!fileId) {
|
|
238
|
+
throw new Error(`Registry file not found for "${registry.name}"`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const res = await this.drive.files.get({ fileId, alt: "media" }, { responseType: "text" });
|
|
242
|
+
return parseRegistryFile(res.data);
|
|
243
|
+
}
|
|
244
|
+
async writeRegistry(registry, data) {
|
|
245
|
+
const content = serializeRegistryFile(data);
|
|
246
|
+
const media = { mimeType: "text/yaml", body: Readable.from(content) };
|
|
247
|
+
if (registry.fileId) {
|
|
248
|
+
await this.drive.files.update({ fileId: registry.fileId, media });
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
const res = await this.drive.files.list({
|
|
252
|
+
q: `name='${REGISTRY_FILENAME}' and '${registry.folderId}' in parents and trashed=false`,
|
|
253
|
+
fields: "files(id)",
|
|
254
|
+
pageSize: 1,
|
|
255
|
+
});
|
|
256
|
+
const existingId = res.data.files?.[0]?.id;
|
|
257
|
+
if (existingId) {
|
|
258
|
+
await this.drive.files.update({ fileId: existingId, media });
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
await this.drive.files.create({
|
|
262
|
+
requestBody: { name: REGISTRY_FILENAME, parents: [registry.folderId] },
|
|
263
|
+
media,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async resolveCollectionRef(ref) {
|
|
269
|
+
if (ref.backend !== "gdrive")
|
|
270
|
+
return null;
|
|
271
|
+
// Search for folder by name (try with SKILLS_ prefix and without)
|
|
272
|
+
const names = ref.ref.startsWith("SKILLS_") ? [ref.ref] : [`SKILLS_${ref.ref}`, ref.ref];
|
|
273
|
+
for (const name of names) {
|
|
274
|
+
const res = await this.drive.files.list({
|
|
275
|
+
q: `name='${name}' and mimeType='${FOLDER_MIME}' and 'me' in owners and trashed=false`,
|
|
276
|
+
fields: "files(id, name)",
|
|
277
|
+
pageSize: 1,
|
|
278
|
+
});
|
|
279
|
+
const folder = res.data.files?.[0];
|
|
280
|
+
if (folder?.id) {
|
|
281
|
+
return {
|
|
282
|
+
name: (folder.name ?? name).replace(/^SKILLS_/i, ""),
|
|
283
|
+
backend: "gdrive",
|
|
284
|
+
folderId: folder.id,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
async createRegistry(name) {
|
|
291
|
+
const folderName = name ? `SKILLS_REGISTRY_${name}` : "SKILLS_REGISTRY";
|
|
292
|
+
const folderRes = await this.drive.files.create({
|
|
293
|
+
requestBody: { name: folderName, mimeType: FOLDER_MIME },
|
|
294
|
+
fields: "id, name",
|
|
295
|
+
});
|
|
296
|
+
const folderId = folderRes.data.id;
|
|
297
|
+
const owner = await this.getOwnerEmail();
|
|
298
|
+
const registryData = {
|
|
299
|
+
name: name ?? "default",
|
|
300
|
+
owner,
|
|
301
|
+
source: "gdrive",
|
|
302
|
+
collections: [],
|
|
303
|
+
};
|
|
304
|
+
const fileRes = await this.drive.files.create({
|
|
305
|
+
requestBody: { name: REGISTRY_FILENAME, parents: [folderId] },
|
|
306
|
+
media: { mimeType: "text/yaml", body: Readable.from(serializeRegistryFile(registryData)) },
|
|
307
|
+
fields: "id",
|
|
308
|
+
});
|
|
309
|
+
return {
|
|
310
|
+
id: randomUUID(),
|
|
311
|
+
name: name ?? "default",
|
|
312
|
+
backend: "gdrive",
|
|
313
|
+
folderId,
|
|
314
|
+
fileId: fileRes.data.id ?? undefined,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
// ── Private helpers ──────────────────────────────────────────────────────
|
|
318
|
+
async findFolder(name, parentId) {
|
|
319
|
+
const res = await this.drive.files.list({
|
|
320
|
+
q: `name='${name}' and '${parentId}' in parents and mimeType='${FOLDER_MIME}' and trashed=false`,
|
|
321
|
+
fields: "files(id)",
|
|
322
|
+
pageSize: 1,
|
|
323
|
+
});
|
|
324
|
+
return res.data.files?.[0]?.id ?? null;
|
|
325
|
+
}
|
|
326
|
+
async uploadFolder(localDir, parentId) {
|
|
327
|
+
for (const entry of fs.readdirSync(localDir, { withFileTypes: true })) {
|
|
328
|
+
if (entry.name.startsWith("."))
|
|
329
|
+
continue;
|
|
330
|
+
const fullPath = path.join(localDir, entry.name);
|
|
331
|
+
if (entry.isDirectory()) {
|
|
332
|
+
let subFolderId = await this.findFolder(entry.name, parentId);
|
|
333
|
+
if (!subFolderId) {
|
|
334
|
+
const res = await this.drive.files.create({
|
|
335
|
+
requestBody: {
|
|
336
|
+
name: entry.name,
|
|
337
|
+
mimeType: FOLDER_MIME,
|
|
338
|
+
parents: [parentId],
|
|
339
|
+
},
|
|
340
|
+
fields: "id",
|
|
341
|
+
});
|
|
342
|
+
subFolderId = res.data.id;
|
|
343
|
+
}
|
|
344
|
+
await this.uploadFolder(fullPath, subFolderId);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
const existing = await this.drive.files.list({
|
|
348
|
+
q: `name='${entry.name}' and '${parentId}' in parents and trashed=false`,
|
|
349
|
+
fields: "files(id)",
|
|
350
|
+
pageSize: 1,
|
|
351
|
+
});
|
|
352
|
+
const media = {
|
|
353
|
+
mimeType: "application/octet-stream",
|
|
354
|
+
body: fs.createReadStream(fullPath),
|
|
355
|
+
};
|
|
356
|
+
if (existing.data.files?.[0]?.id) {
|
|
357
|
+
await this.drive.files.update({
|
|
358
|
+
fileId: existing.data.files[0].id,
|
|
359
|
+
media,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
await this.drive.files.create({
|
|
364
|
+
requestBody: { name: entry.name, parents: [parentId] },
|
|
365
|
+
media,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CollectionFile, CollectionInfo, RegistryCollectionRef, RegistryFile, RegistryInfo } from "../types.js";
|
|
2
|
+
export interface StorageBackend {
|
|
3
|
+
getOwner(): Promise<string>;
|
|
4
|
+
discoverCollections(): Promise<Omit<CollectionInfo, "id">[]>;
|
|
5
|
+
readCollection(collection: CollectionInfo): Promise<CollectionFile>;
|
|
6
|
+
writeCollection(collection: CollectionInfo, data: CollectionFile): Promise<void>;
|
|
7
|
+
downloadSkill(collection: CollectionInfo, skillName: string, destDir: string): Promise<void>;
|
|
8
|
+
uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<void>;
|
|
9
|
+
discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
|
|
10
|
+
readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
|
|
11
|
+
writeRegistry(registry: RegistryInfo, data: RegistryFile): Promise<void>;
|
|
12
|
+
resolveCollectionRef(ref: RegistryCollectionRef): Promise<Omit<CollectionInfo, "id"> | null>;
|
|
13
|
+
createRegistry(name?: string): Promise<RegistryInfo>;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { StorageBackend } from "./interface.js";
|
|
2
|
+
import type { CollectionFile, CollectionInfo, RegistryCollectionRef, RegistryFile, RegistryInfo } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Local filesystem backend — stores collections and the registry under ~/.skillsmanager/.
|
|
5
|
+
* Works with zero setup, no auth, no internet. This is the default backend.
|
|
6
|
+
*/
|
|
7
|
+
export declare class LocalBackend implements StorageBackend {
|
|
8
|
+
getOwner(): Promise<string>;
|
|
9
|
+
discoverCollections(): Promise<Omit<CollectionInfo, "id">[]>;
|
|
10
|
+
readCollection(collection: CollectionInfo): Promise<CollectionFile>;
|
|
11
|
+
writeCollection(collection: CollectionInfo, data: CollectionFile): Promise<void>;
|
|
12
|
+
downloadSkill(collection: CollectionInfo, skillName: string, destDir: string): Promise<void>;
|
|
13
|
+
uploadSkill(collection: CollectionInfo, localPath: string, skillName: string): Promise<void>;
|
|
14
|
+
discoverRegistries(): Promise<Omit<RegistryInfo, "id">[]>;
|
|
15
|
+
readRegistry(registry: RegistryInfo): Promise<RegistryFile>;
|
|
16
|
+
writeRegistry(registry: RegistryInfo, data: RegistryFile): Promise<void>;
|
|
17
|
+
resolveCollectionRef(ref: RegistryCollectionRef): Promise<Omit<CollectionInfo, "id"> | null>;
|
|
18
|
+
createRegistry(name?: string): Promise<RegistryInfo>;
|
|
19
|
+
createCollection(name: string): Promise<CollectionInfo>;
|
|
20
|
+
}
|