@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 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
- ## Docs
119
+ ## About almyty
120
120
 
121
- https://almyty.com/docs
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
- ## License
125
+ - Website — https://almyty.com
126
+ - Docs — https://docs.almyty.com
127
+ - Source — https://github.com/almyty-inc/almyty
124
128
 
125
- BSL-1.1
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.15",
4
- "description": "Install API skills into AI coding agents — Claude Code, Cursor, Windsurf, Copilot, Codex",
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": "BSL-1.1",
36
+ "license": "Apache-2.0",
34
37
  "dependencies": {
35
- "@almyty/client": "^0.1.0",
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
- });