@almyty/skills 1.0.15 → 1.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 +8 -4
- package/dist/installer.js +37 -1
- package/package.json +16 -4
- package/dist/__tests__/client.test.d.ts +0 -1
- package/dist/__tests__/client.test.js +0 -145
package/README.md
CHANGED
|
@@ -116,10 +116,14 @@ Create `.almytyrc` in your project or home directory:
|
|
|
116
116
|
|
|
117
117
|
Requires `npx @almyty/auth login` first. Reads credentials from `~/.almyty/credentials.json`.
|
|
118
118
|
|
|
119
|
-
##
|
|
119
|
+
## About almyty
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
almyty is the full-stack platform for AI agents, agnostic by design: any LLM, any
|
|
122
|
+
API turned into tools, served over MCP, A2A, UTCP, and Agent Skills. Open source,
|
|
123
|
+
no lock-in.
|
|
122
124
|
|
|
123
|
-
|
|
125
|
+
- Website — https://almyty.com
|
|
126
|
+
- Docs — https://docs.almyty.com
|
|
127
|
+
- Source — https://github.com/almyty-inc/almyty
|
|
124
128
|
|
|
125
|
-
|
|
129
|
+
Apache-2.0 © Almyty Inc.
|
package/dist/installer.js
CHANGED
|
@@ -10,12 +10,36 @@
|
|
|
10
10
|
* already present in every SKILL.md we generate.
|
|
11
11
|
*/
|
|
12
12
|
import { writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, readFileSync } from 'fs';
|
|
13
|
-
import { join } from 'path';
|
|
13
|
+
import { join, resolve, sep } from 'path';
|
|
14
14
|
/** Legacy prefix from <=v1.0.9 — older installs still use this dir
|
|
15
15
|
* name. Kept here so `remove`/`installed` continue to find them. */
|
|
16
16
|
const LEGACY_SKILL_PREFIX = 'almyty-';
|
|
17
17
|
/** Marker line we expect inside every SKILL.md frontmatter we wrote. */
|
|
18
18
|
const ALMYTY_MARKER = /^\s*author:\s*almyty\s*$/m;
|
|
19
|
+
/**
|
|
20
|
+
* Skill names come from the backend/gateway and are used to build
|
|
21
|
+
* filesystem paths under skillsDir. A malicious or compromised gateway
|
|
22
|
+
* could return `name: "../../../.ssh/authorized_keys"` and have the
|
|
23
|
+
* installer write — or, via the legacy-dir cleanup, recursively delete —
|
|
24
|
+
* arbitrary files on the user's machine (and the daemon/watch modes do
|
|
25
|
+
* this automatically on a timer). Restrict names to a single safe path
|
|
26
|
+
* segment.
|
|
27
|
+
*/
|
|
28
|
+
const SAFE_SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
29
|
+
function isSafeSkillName(name) {
|
|
30
|
+
return (typeof name === 'string' &&
|
|
31
|
+
name !== '.' &&
|
|
32
|
+
name !== '..' &&
|
|
33
|
+
!name.includes('/') &&
|
|
34
|
+
!name.includes('\\') &&
|
|
35
|
+
SAFE_SKILL_NAME.test(name));
|
|
36
|
+
}
|
|
37
|
+
/** Defence in depth: assert a built path stays inside skillsDir. */
|
|
38
|
+
function isInside(baseDir, candidate) {
|
|
39
|
+
const base = resolve(baseDir);
|
|
40
|
+
const target = resolve(candidate);
|
|
41
|
+
return target === base || target.startsWith(base + sep);
|
|
42
|
+
}
|
|
19
43
|
/**
|
|
20
44
|
* Strip the legacy `name: almyty-<x>` line in the SKILL.md frontmatter
|
|
21
45
|
* to `name: <x>` so the agent-visible identifier is the skill's own
|
|
@@ -51,6 +75,13 @@ export function installSkills(skills, target) {
|
|
|
51
75
|
const files = [];
|
|
52
76
|
mkdirSync(target.skillsDir, { recursive: true });
|
|
53
77
|
for (const skill of skills) {
|
|
78
|
+
// Reject backend-supplied names that aren't a single safe path
|
|
79
|
+
// segment — skip rather than throw so one bad entry can't break the
|
|
80
|
+
// whole install (e.g. in daemon/watch mode).
|
|
81
|
+
if (!isSafeSkillName(skill.name)) {
|
|
82
|
+
console.warn(`Skipping skill with unsafe name: ${JSON.stringify(skill.name)}`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
54
85
|
const dirName = skill.name;
|
|
55
86
|
const skillDir = join(target.skillsDir, dirName);
|
|
56
87
|
const skillFile = join(skillDir, 'SKILL.md');
|
|
@@ -58,6 +89,11 @@ export function installSkills(skills, target) {
|
|
|
58
89
|
// shape, remove it before writing the new shape so the agent
|
|
59
90
|
// doesn't see two copies of the same skill.
|
|
60
91
|
const legacyDir = join(target.skillsDir, `${LEGACY_SKILL_PREFIX}${skill.name}`);
|
|
92
|
+
// Containment guard (defence in depth) before any write/delete.
|
|
93
|
+
if (!isInside(target.skillsDir, skillDir) || !isInside(target.skillsDir, legacyDir)) {
|
|
94
|
+
console.warn(`Skipping skill whose path escapes the skills directory: ${skill.name}`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
61
97
|
if (legacyDir !== skillDir && existsSync(legacyDir)) {
|
|
62
98
|
rmSync(legacyDir, { recursive: true, force: true });
|
|
63
99
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@almyty/skills",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Install any API as a skill into Claude Code, Cursor, Windsurf, Copilot, or Codex \u2014 almyty turns OpenAPI and GraphQL into agent-ready skills.",
|
|
5
8
|
"type": "module",
|
|
6
9
|
"main": "dist/index.js",
|
|
7
10
|
"bin": {
|
|
@@ -30,9 +33,9 @@
|
|
|
30
33
|
"almyty"
|
|
31
34
|
],
|
|
32
35
|
"author": "almyty",
|
|
33
|
-
"license": "
|
|
36
|
+
"license": "Apache-2.0",
|
|
34
37
|
"dependencies": {
|
|
35
|
-
"@almyty/client": "^
|
|
38
|
+
"@almyty/client": "^1.2.0",
|
|
36
39
|
"@clack/prompts": "^1.2.0"
|
|
37
40
|
},
|
|
38
41
|
"devDependencies": {
|
|
@@ -40,5 +43,14 @@
|
|
|
40
43
|
"tsx": "^4.7.0",
|
|
41
44
|
"typescript": "^5.3.0",
|
|
42
45
|
"vitest": "^4.1.0"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://almyty.com",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/almyty-inc/almyty.git",
|
|
51
|
+
"directory": "packages/skills-cli"
|
|
52
|
+
},
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/almyty-inc/almyty/issues"
|
|
43
55
|
}
|
|
44
56
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { parseRef } from '../client.js';
|
|
3
|
-
import { generateMetaSkill } from '../meta-skill.js';
|
|
4
|
-
import { loadConfig } from '../config.js';
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
// parseRef()
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
describe('parseRef', () => {
|
|
9
|
-
it('parses @org/gateway as a gateway ref', () => {
|
|
10
|
-
const result = parseRef('@org/gateway');
|
|
11
|
-
expect(result).toEqual({
|
|
12
|
-
type: 'gateway',
|
|
13
|
-
orgSlug: 'org',
|
|
14
|
-
gatewaySlug: 'gateway',
|
|
15
|
-
raw: '@org/gateway',
|
|
16
|
-
});
|
|
17
|
-
});
|
|
18
|
-
it('parses @org/gateway/skill as a skill ref', () => {
|
|
19
|
-
const result = parseRef('@org/gateway/skill');
|
|
20
|
-
expect(result).toEqual({
|
|
21
|
-
type: 'skill',
|
|
22
|
-
orgSlug: 'org',
|
|
23
|
-
gatewaySlug: 'gateway',
|
|
24
|
-
skillName: 'skill',
|
|
25
|
-
raw: '@org/gateway/skill',
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
it('parses a UUID string as a uuid ref', () => {
|
|
29
|
-
const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
30
|
-
const result = parseRef(uuid);
|
|
31
|
-
expect(result).toEqual({
|
|
32
|
-
type: 'uuid',
|
|
33
|
-
uuid,
|
|
34
|
-
raw: uuid,
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
it('parses uppercase UUID correctly', () => {
|
|
38
|
-
const uuid = 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890';
|
|
39
|
-
const result = parseRef(uuid);
|
|
40
|
-
expect(result.type).toBe('uuid');
|
|
41
|
-
expect(result.uuid).toBe(uuid);
|
|
42
|
-
});
|
|
43
|
-
it('returns search for a random string', () => {
|
|
44
|
-
const result = parseRef('some-random-query');
|
|
45
|
-
expect(result).toEqual({
|
|
46
|
-
type: 'search',
|
|
47
|
-
raw: 'some-random-query',
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
it('returns search for an empty string', () => {
|
|
51
|
-
const result = parseRef('');
|
|
52
|
-
expect(result).toEqual({
|
|
53
|
-
type: 'search',
|
|
54
|
-
raw: '',
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
it('returns search for @ with only one segment', () => {
|
|
58
|
-
const result = parseRef('@org');
|
|
59
|
-
expect(result).toEqual({
|
|
60
|
-
type: 'search',
|
|
61
|
-
raw: '@org',
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
it('returns search for @ with more than three segments', () => {
|
|
65
|
-
const result = parseRef('@org/gw/skill/extra');
|
|
66
|
-
expect(result).toEqual({
|
|
67
|
-
type: 'search',
|
|
68
|
-
raw: '@org/gw/skill/extra',
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
// ---------------------------------------------------------------------------
|
|
73
|
-
// generateMetaSkill()
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
describe('generateMetaSkill', () => {
|
|
76
|
-
it('returns a SkillFile with name "skills" and fileName "almyty-skills"', () => {
|
|
77
|
-
const skill = generateMetaSkill();
|
|
78
|
-
expect(skill.name).toBe('skills');
|
|
79
|
-
expect(skill.fileName).toBe('almyty-skills');
|
|
80
|
-
});
|
|
81
|
-
it('content includes npx @almyty/skills commands', () => {
|
|
82
|
-
const { content } = generateMetaSkill();
|
|
83
|
-
expect(content).toContain('npx @almyty/skills');
|
|
84
|
-
});
|
|
85
|
-
it('content references daemon command', () => {
|
|
86
|
-
const { content } = generateMetaSkill();
|
|
87
|
-
expect(content).toContain('daemon');
|
|
88
|
-
});
|
|
89
|
-
it('content references search command', () => {
|
|
90
|
-
const { content } = generateMetaSkill();
|
|
91
|
-
expect(content).toContain('search');
|
|
92
|
-
});
|
|
93
|
-
it('content references install command', () => {
|
|
94
|
-
const { content } = generateMetaSkill();
|
|
95
|
-
expect(content).toContain('install');
|
|
96
|
-
});
|
|
97
|
-
it('content references run command', () => {
|
|
98
|
-
const { content } = generateMetaSkill();
|
|
99
|
-
expect(content).toContain('run');
|
|
100
|
-
});
|
|
101
|
-
it('content includes SKILL.md front-matter', () => {
|
|
102
|
-
const { content } = generateMetaSkill();
|
|
103
|
-
expect(content).toContain('---');
|
|
104
|
-
expect(content).toContain('name: almyty-skills');
|
|
105
|
-
expect(content).toContain('description:');
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
// ---------------------------------------------------------------------------
|
|
109
|
-
// loadConfig()
|
|
110
|
-
// ---------------------------------------------------------------------------
|
|
111
|
-
describe('loadConfig', () => {
|
|
112
|
-
const originalEnv = process.env;
|
|
113
|
-
beforeEach(() => {
|
|
114
|
-
// Reset env between tests
|
|
115
|
-
process.env = { ...originalEnv };
|
|
116
|
-
delete process.env.APIFAI_SKILLS_DIR;
|
|
117
|
-
});
|
|
118
|
-
afterEach(() => {
|
|
119
|
-
process.env = originalEnv;
|
|
120
|
-
vi.restoreAllMocks();
|
|
121
|
-
});
|
|
122
|
-
it('returns empty config when no .almytyrc exists and no env var', () => {
|
|
123
|
-
// Use a temp directory that definitely has no .almytyrc
|
|
124
|
-
const config = loadConfig('/tmp/nonexistent-dir-' + Date.now());
|
|
125
|
-
expect(config).toEqual({});
|
|
126
|
-
});
|
|
127
|
-
it('uses APIFAI_SKILLS_DIR env var when set', () => {
|
|
128
|
-
process.env.APIFAI_SKILLS_DIR = '/custom/skills/dir';
|
|
129
|
-
const config = loadConfig();
|
|
130
|
-
expect(config).toEqual({ skillsDir: '/custom/skills/dir' });
|
|
131
|
-
});
|
|
132
|
-
it('env var takes precedence over .almytyrc files', () => {
|
|
133
|
-
process.env.APIFAI_SKILLS_DIR = '/env/override';
|
|
134
|
-
// Even if we pass a projectDir, env should take priority
|
|
135
|
-
const config = loadConfig('/some/project');
|
|
136
|
-
expect(config.skillsDir).toBe('/env/override');
|
|
137
|
-
});
|
|
138
|
-
it('returns empty config for a directory with no rc file and no home rc', () => {
|
|
139
|
-
// Use a deeply nested temp path that certainly has no .almytyrc anywhere
|
|
140
|
-
// and also won't match ~/.almytyrc (already handled by the missing file)
|
|
141
|
-
const fakePath = `/tmp/almyty-test-${Date.now()}/deeply/nested/project`;
|
|
142
|
-
const config = loadConfig(fakePath);
|
|
143
|
-
expect(config).toEqual({});
|
|
144
|
-
});
|
|
145
|
-
});
|