llms-py 3.0.23__py3-none-any.whl → 3.0.25__py3-none-any.whl

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.
@@ -0,0 +1,169 @@
1
+ # GitHub Auth Extension
2
+
3
+ The GitHub Auth extension enables OAuth 2.0 authentication via GitHub for your llms application. When enabled, users must sign in with their GitHub account before accessing the application.
4
+
5
+ ## Features
6
+
7
+ - **GitHub OAuth 2.0** - Standard OAuth flow with CSRF protection
8
+ - **User Restrictions** - Optionally restrict access to specific GitHub users
9
+ - **Session Management** - Automatic session handling with 24-hour expiry
10
+ - **Environment Variables** - Credentials can use env vars for secure deployment
11
+
12
+ ## Configuration
13
+
14
+ Create a config file at `~/.llms/users/default/github_auth/config.json`:
15
+
16
+ ```json
17
+ {
18
+ "enabled": true,
19
+ "client_id": "$GITHUB_CLIENT_ID",
20
+ "client_secret": "$GITHUB_CLIENT_SECRET",
21
+ "redirect_uri": "http://localhost:8000/auth/github/callback",
22
+ "restrict_to": "$GITHUB_USERS"
23
+ }
24
+ ```
25
+
26
+ | Property | Description |
27
+ |-----------------|-------------|
28
+ | `client_id` | GitHub OAuth App client ID |
29
+ | `client_secret` | GitHub OAuth App client secret |
30
+ | `redirect_uri` | Callback URL registered with GitHub |
31
+ | `restrict_to` | Optional comma/space-delimited list of allowed GitHub usernames |
32
+ | `enabled` | Set to `false` to disable the extension |
33
+
34
+ Values prefixed with `$` are resolved from environment variables.
35
+
36
+ ## Creating a GitHub OAuth App
37
+
38
+ 1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
39
+ 2. Click **New OAuth App**
40
+ 3. Fill in the application details:
41
+ - **Application name**: Your app name
42
+ - **Homepage URL**: Your app's homepage (e.g., `http://localhost:8000`)
43
+ - **Authorization callback URL**: Must match your `redirect_uri` (e.g., `http://localhost:8000/auth/github/callback`)
44
+ 4. Click **Register application**
45
+ 5. Copy the **Client ID** and generate a **Client Secret**
46
+
47
+ ## API Endpoints
48
+
49
+ The extension registers these routes:
50
+
51
+ | Method | Endpoint | Description |
52
+ |--------|-------------------------|-------------|
53
+ | GET | `/auth` | Check authentication status |
54
+ | GET | `/auth/github` | Initiate GitHub OAuth flow |
55
+ | GET | `/auth/github/callback` | OAuth callback handler |
56
+ | GET | `/auth/session` | Get current session info |
57
+ | POST | `/auth/logout` | End the current session |
58
+
59
+ ### GET /auth
60
+
61
+ Returns the authenticated user's info or a 401 error:
62
+
63
+ ```json
64
+ {
65
+ "userId": "12345",
66
+ "userName": "octocat",
67
+ "displayName": "The Octocat",
68
+ "profileUrl": "https://avatars.githubusercontent.com/u/12345",
69
+ "authProvider": "github"
70
+ }
71
+ ```
72
+
73
+ ### GET /auth/session
74
+
75
+ Returns full session details including the session token:
76
+
77
+ ```json
78
+ {
79
+ "userId": "12345",
80
+ "userName": "octocat",
81
+ "displayName": "The Octocat",
82
+ "profileUrl": "https://avatars.githubusercontent.com/u/12345",
83
+ "email": "octocat@github.com",
84
+ "created": 1706600000.123,
85
+ "sessionToken": "..."
86
+ }
87
+ ```
88
+
89
+ ## OAuth Flow
90
+
91
+ ```
92
+ ┌─────────┐ ┌─────────┐ ┌────────┐
93
+ │ Browser │ │ llms │ │ GitHub │
94
+ └────┬────┘ └────┬────┘ └───┬────┘
95
+ │ │ │
96
+ │ GET /auth/github │ │
97
+ ├───────────────────►│ │
98
+ │ │ │
99
+ │ 302 Redirect │ │
100
+ │◄───────────────────┤ │
101
+ │ │ │
102
+ │ /login/oauth/authorize?... │
103
+ ├────────────────────────────────────────►
104
+ │ │ │
105
+ │ User grants access │
106
+ │◄────────────────────────────────────────
107
+ │ │ │
108
+ │ GET /auth/github/callback?code=... │
109
+ ├───────────────────►│ │
110
+ │ │ │
111
+ │ │ POST /access_token │
112
+ │ ├──────────────────►│
113
+ │ │ │
114
+ │ │ access_token │
115
+ │ │◄──────────────────┤
116
+ │ │ │
117
+ │ │ GET /user │
118
+ │ ├──────────────────►│
119
+ │ │ │
120
+ │ │ user info │
121
+ │ │◄──────────────────┤
122
+ │ │ │
123
+ │ 302 /?session=... │ │
124
+ │ Set-Cookie: token │ │
125
+ │◄───────────────────┤ │
126
+ │ │ │
127
+ ```
128
+
129
+ 1. User clicks "Sign in with GitHub" → redirects to `/auth/github`
130
+ 2. Server generates CSRF state token and redirects to GitHub
131
+ 3. User authorizes the app on GitHub
132
+ 4. GitHub redirects back with authorization code
133
+ 5. Server exchanges code for access token
134
+ 6. Server fetches user info from GitHub API
135
+ 7. Server creates session and sets cookie
136
+
137
+ ## Restricting Access
138
+
139
+ To limit access to specific GitHub users, set `restrict_to` in your config:
140
+
141
+ ```json
142
+ {
143
+ "client_id": "...",
144
+ "client_secret": "...",
145
+ "redirect_uri": "...",
146
+ "restrict_to": "alice bob charlie"
147
+ }
148
+ ```
149
+
150
+ Users not in this list receive a `403 Forbidden` response.
151
+
152
+ ## UI Component
153
+
154
+ The extension provides a custom `SignIn` component that displays a "Sign in with GitHub" button. This component automatically overrides the default sign-in UI when the extension is loaded.
155
+
156
+ ## Session Storage
157
+
158
+ Sessions are stored in memory with:
159
+ - **Token**: Cryptographically secure random string
160
+ - **User data**: GitHub user ID, username, display name, avatar URL, email
161
+ - **Expiry**: Automatic cleanup after 24 hours
162
+ - **Cookie**: `llms-token` with `httponly` flag for security
163
+
164
+ ## Security Notes
165
+
166
+ - **CSRF Protection**: OAuth state tokens prevent cross-site request forgery
167
+ - **State Cleanup**: Expired state tokens (>10 min) are automatically removed
168
+ - **Session Cleanup**: Sessions older than 24 hours are pruned
169
+ - **HttpOnly Cookie**: Session token is not accessible via JavaScript
@@ -0,0 +1,254 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import secrets
5
+ import time
6
+ from urllib.parse import parse_qs, urlencode
7
+
8
+ import aiohttp
9
+ from aiohttp import web
10
+
11
+
12
+ def install(ctx):
13
+ g_app = ctx.app
14
+
15
+ auth_config_file = os.path.join(ctx.get_user_path(), "github_auth", "config.json")
16
+
17
+ auth_config = None
18
+ if os.path.exists(auth_config_file):
19
+ try:
20
+ with open(auth_config_file, encoding="utf-8") as f:
21
+ auth_config = json.load(f)
22
+ if "enabled" in auth_config and not auth_config["enabled"]:
23
+ ctx.log("GitHub Auth is disabled in config")
24
+ auth_config = None
25
+ except Exception as e:
26
+ ctx.err("Failed to load GitHub auth config", e)
27
+ else:
28
+ ctx.dbg(f"GitHub Auth config file '{auth_config_file}' not found")
29
+
30
+ if not auth_config:
31
+ # don't load extension if auth_config is not found or is disabled
32
+ ctx.disabled = True
33
+ return
34
+
35
+ client_id = auth_config.get("client_id", "")
36
+ client_secret = auth_config.get("client_secret", "")
37
+ redirect_uri = auth_config.get("redirect_uri", "")
38
+ restrict_to = auth_config.get("restrict_to", "")
39
+
40
+ # Expand environment variables
41
+ if client_id.startswith("$"):
42
+ client_id = client_id[1:]
43
+ if client_secret.startswith("$"):
44
+ client_secret = client_secret[1:]
45
+ client_secret = os.getenv(client_secret)
46
+ if redirect_uri.startswith("$"):
47
+ redirect_uri = redirect_uri[1:]
48
+ redirect_uri = os.getenv(redirect_uri)
49
+ if restrict_to.startswith("$"):
50
+ restrict_to = restrict_to[1:]
51
+ restrict_to = os.getenv(restrict_to)
52
+
53
+ # check if client_id is set
54
+ if client_id == "GITHUB_CLIENT_ID":
55
+ client_id = os.getenv(client_id)
56
+ if client_secret == "GITHUB_CLIENT_SECRET":
57
+ client_secret = os.getenv(client_secret)
58
+ if restrict_to == "GITHUB_USERS":
59
+ restrict_to = os.getenv(restrict_to)
60
+
61
+ if not client_id or not redirect_uri or not client_secret:
62
+ ctx.disabled = True
63
+ ctx.log("GitHub OAuth client_id, client_secret and redirect_uri are not configured")
64
+ return
65
+
66
+ from llms.main import AuthProvider
67
+
68
+ class GitHubAuthProvider(AuthProvider):
69
+ def __init__(self, app):
70
+ super().__init__(app)
71
+
72
+ # Adding an Auth Provider forces Authentication to be enabled
73
+ auth_provider = GitHubAuthProvider(g_app)
74
+ g_app.auth_providers.append(auth_provider)
75
+
76
+ # OAuth handlers
77
+ async def github_auth_handler(request):
78
+ # Generate CSRF state token
79
+ state = secrets.token_urlsafe(32)
80
+ ctx.oauth_states[state] = {"created": time.time(), "redirect_uri": redirect_uri}
81
+
82
+ # Clean up old states (older than 10 minutes)
83
+ current_time = time.time()
84
+ expired_states = [s for s, data in ctx.oauth_states.items() if current_time - data["created"] > 600]
85
+ for s in expired_states:
86
+ del ctx.oauth_states[s]
87
+
88
+ # Build GitHub authorization URL
89
+ params = {
90
+ "client_id": client_id,
91
+ "redirect_uri": redirect_uri,
92
+ "state": state,
93
+ "scope": "read:user user:email",
94
+ }
95
+ auth_url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
96
+
97
+ return web.HTTPFound(auth_url)
98
+
99
+ def validate_user(github_username):
100
+ # If restrict_to is configured, validate the user
101
+ if restrict_to:
102
+ # Parse allowed users (comma or space delimited)
103
+ allowed_users = [u.strip() for u in re.split(r"[,\s]+", restrict_to) if u.strip()]
104
+
105
+ # Check if user is in the allowed list
106
+ if not github_username or github_username not in allowed_users:
107
+ ctx.log(f"Access denied for user: {github_username}. Not in allowed list: {allowed_users}")
108
+ return web.Response(
109
+ text=f"Access denied. User '{github_username}' is not authorized to access this application.",
110
+ status=403,
111
+ )
112
+ return None
113
+
114
+ async def github_callback_handler(request):
115
+ """Handle GitHub OAuth callback"""
116
+ code = request.query.get("code")
117
+ state = request.query.get("state")
118
+
119
+ # Handle malformed URLs where query params are appended with & instead of ?
120
+ if not code and "tail" in request.match_info:
121
+ tail = request.match_info["tail"]
122
+ if tail.startswith("&"):
123
+ params = parse_qs(tail[1:])
124
+ code = params.get("code", [None])[0]
125
+ state = params.get("state", [None])[0]
126
+
127
+ if not code or not state:
128
+ return web.Response(text="Missing code or state parameter", status=400)
129
+
130
+ # Verify state token (CSRF protection)
131
+ if state not in ctx.oauth_states:
132
+ return web.Response(text="Invalid state parameter", status=400)
133
+
134
+ ctx.oauth_states.pop(state)
135
+
136
+ # Exchange code for access token
137
+ async with aiohttp.ClientSession() as session:
138
+ token_url = "https://github.com/login/oauth/access_token"
139
+ token_data = {
140
+ "client_id": client_id,
141
+ "client_secret": client_secret,
142
+ "code": code,
143
+ "redirect_uri": redirect_uri,
144
+ }
145
+ headers = {"Accept": "application/json"}
146
+
147
+ async with session.post(token_url, data=token_data, headers=headers) as resp:
148
+ token_response = await resp.json()
149
+ access_token = token_response.get("access_token")
150
+
151
+ if not access_token:
152
+ error = token_response.get("error_description", "Failed to get access token")
153
+ return web.json_response(ctx.create_error_response(f"OAuth error: {error}"), status=400)
154
+
155
+ # Fetch user info
156
+ user_url = "https://api.github.com/user"
157
+ headers = {"Authorization": f"Bearer {access_token}", "Accept": "application/json"}
158
+
159
+ async with session.get(user_url, headers=headers) as resp:
160
+ user_data = await resp.json()
161
+
162
+ # Validate user
163
+ error_response = validate_user(user_data.get("login", ""))
164
+ if error_response:
165
+ return error_response
166
+
167
+ # Create session
168
+ session_token = secrets.token_urlsafe(32)
169
+ ctx.sessions[session_token] = {
170
+ "userId": str(user_data.get("id", "")),
171
+ "userName": user_data.get("login", ""),
172
+ "displayName": user_data.get("name", ""),
173
+ "profileUrl": user_data.get("avatar_url", ""),
174
+ "email": user_data.get("email", ""),
175
+ "created": time.time(),
176
+ }
177
+
178
+ # Redirect to UI with session token
179
+ response = web.HTTPFound(f"/?session={session_token}")
180
+ response.set_cookie("llms-token", session_token, httponly=True, path="/", max_age=86400)
181
+ return response
182
+
183
+ async def session_handler(request):
184
+ """Validate and return session info"""
185
+ session_token = auth_provider.get_session_token(request)
186
+
187
+ if not session_token or session_token not in ctx.sessions:
188
+ return web.json_response(ctx.create_error_response("Invalid or expired session"), status=401)
189
+
190
+ session_data = ctx.sessions[session_token]
191
+
192
+ # Clean up old sessions (older than 24 hours)
193
+ current_time = time.time()
194
+ expired_sessions = [token for token, data in ctx.sessions.items() if current_time - data["created"] > 86400]
195
+ for token in expired_sessions:
196
+ del ctx.sessions[token]
197
+
198
+ return web.json_response({**session_data, "sessionToken": session_token})
199
+
200
+ async def logout_handler(request):
201
+ """End OAuth session"""
202
+ session_token = auth_provider.get_session_token(request)
203
+
204
+ if session_token and session_token in g_app.sessions:
205
+ del g_app.sessions[session_token]
206
+
207
+ response = web.json_response({"success": True})
208
+ response.del_cookie("llms-token")
209
+ return response
210
+
211
+ async def auth_handler(request):
212
+ """Check authentication status and return user info"""
213
+ # Check for OAuth session token
214
+ session_token = auth_provider.get_session_token(request)
215
+
216
+ if session_token and session_token in g_app.sessions:
217
+ session_data = g_app.sessions[session_token]
218
+ return web.json_response(
219
+ {
220
+ "userId": session_data.get("userId", ""),
221
+ "userName": session_data.get("userName", ""),
222
+ "displayName": session_data.get("displayName", ""),
223
+ "profileUrl": session_data.get("profileUrl", ""),
224
+ "authProvider": "github",
225
+ }
226
+ )
227
+
228
+ # Check for API key in Authorization header
229
+ # auth_header = request.headers.get('Authorization', '')
230
+ # if auth_header.startswith('Bearer '):
231
+ # # For API key auth, return a basic response
232
+ # # You can customize this based on your API key validation logic
233
+ # api_key = auth_header[7:]
234
+ # if api_key: # Add your API key validation logic here
235
+ # return web.json_response({
236
+ # "userId": "1",
237
+ # "userName": "apiuser",
238
+ # "displayName": "API User",
239
+ # "profileUrl": "",
240
+ # "authProvider": "apikey"
241
+ # })
242
+
243
+ # Not authenticated - return error in expected format
244
+ return web.json_response(g_app.error_auth_required, status=401)
245
+
246
+ ctx.add_get("/auth", auth_handler)
247
+ ctx.add_get("/auth/github", github_auth_handler)
248
+ ctx.add_get("/auth/github/callback", github_callback_handler)
249
+ ctx.add_get("/auth/github/callback{tail:.*}", github_callback_handler)
250
+ ctx.add_get("/auth/session", session_handler)
251
+ ctx.add_post("/auth/logout", logout_handler)
252
+
253
+
254
+ __install__ = install
@@ -0,0 +1,66 @@
1
+ import { ref } from "vue"
2
+
3
+ const SignIn = {
4
+ template: `
5
+ <div class="min-h-full -mt-36 flex flex-col justify-center sm:px-6 lg:px-8">
6
+ <div class="sm:mx-auto sm:w-full sm:max-w-md text-center">
7
+ <Welcome />
8
+ </div>
9
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
10
+ <div v-if="errorMessage" class="mb-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200 rounded-lg px-4 py-3">
11
+ <div class="flex items-start space-x-2">
12
+ <div class="flex-1">
13
+ <div class="text-base font-medium">{{ errorMessage }}</div>
14
+ </div>
15
+ <button type="button"
16
+ @click="errorMessage = null"
17
+ class="text-red-400 dark:text-red-300 hover:text-red-600 dark:hover:text-red-100 flex-shrink-0"
18
+ >
19
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
20
+ <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
21
+ </svg>
22
+ </button>
23
+ </div>
24
+ </div>
25
+ <div class="py-8 px-4 sm:px-10">
26
+ <div class="space-y-4">
27
+ <button
28
+ type="button"
29
+ @click="signInWithGitHub"
30
+ class="w-full inline-flex items-center justify-center px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-base font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors"
31
+ >
32
+ <svg class="w-6 h-6 mr-3" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
33
+ <path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" />
34
+ </svg>
35
+ Sign in with GitHub
36
+ </button>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ `,
42
+ emits: ['done'],
43
+ setup(props, { emit }) {
44
+ const errorMessage = ref(null)
45
+
46
+ function signInWithGitHub() {
47
+ // Redirect to GitHub OAuth endpoint
48
+ window.location.href = '/auth/github'
49
+ }
50
+
51
+ return {
52
+ signInWithGitHub,
53
+ errorMessage,
54
+ }
55
+ }
56
+ }
57
+
58
+
59
+ export default {
60
+ install(ctx) {
61
+ // Override SignIn component
62
+ ctx.components({
63
+ SignIn,
64
+ })
65
+ }
66
+ }
@@ -33,14 +33,21 @@ Access the skills panel by clicking the **Skills** icon in the top toolbar. The
33
33
 
34
34
  ### Skill Groups
35
35
 
36
- Skills are organized into groups based on their source:
36
+ Skills are organized into groups based on their source location. Skills are discovered from these directories in order:
37
37
 
38
- | Group | Description | Editable |
39
- |-------|-------------|----------|
40
- | `~/.llms/.agents` | Your personal skills collection | ✓ Yes |
41
- | Built-in | Skills bundled with the extension | No |
38
+ | Group | Location | Description | Editable |
39
+ |-------|----------|-------------|----------|
40
+ | Project (Agent) | `.agent/skills/` | Skills local to the current project | ✓ Yes |
41
+ | Project (Claude) | `.claude/skills/` | Claude-format skills in the current project | Yes |
42
+ | User (Agent) | `~/.llms/.agents/skills/` | Your personal skills collection | ✓ Yes |
43
+ | User (Claude) | `~/.claude/skills/` | Claude-format skills in your home directory | ✓ Yes |
44
+ | Built-in | Extension directory | Skills bundled with the extension | ✗ No |
42
45
 
43
- Your personal skills (`~/.llms/.agents`) are fully editable. Built-in skills provide reference implementations you can learn from.
46
+ **Project-level skills** (`.agent/` and `.claude/`) are specific to the workspace you're working in. They're ideal for project-specific workflows, coding standards, or team conventions.
47
+
48
+ **User-level skills** (`~/.llms/.agents/` and `~/.claude/`) are available across all projects. Use these for personal workflows and preferences.
49
+
50
+ Both `.agent` and `.claude` directory formats are supported for compatibility with different tooling conventions.
44
51
 
45
52
  ### Selecting Skills for a Conversation
46
53
 
@@ -21,6 +21,9 @@ g_home_skills = None
21
21
  # }
22
22
  g_available_skills = []
23
23
 
24
+ LLMS_HOME_SKILLS = "~/.llms/.agent/skills"
25
+ LLMS_LOCAL_SKILLS = ".agent/skills"
26
+
24
27
 
25
28
  def is_safe_path(base_path: str, requested_path: str) -> bool:
26
29
  """Check if the requested path is safely within the base path."""
@@ -132,10 +135,12 @@ def install(ctx):
132
135
  if os.path.exists(os.path.join(".claude", "skills")):
133
136
  skill_roots[".claude/skills"] = os.path.join(".claude", "skills")
134
137
 
135
- skill_roots["~/.llms/.agents"] = home_skills
138
+ skill_roots[LLMS_HOME_SKILLS] = home_skills
136
139
 
137
- if os.path.exists(os.path.join(".agent", "skills")):
138
- skill_roots[".agents"] = os.path.join(".agent", "skills")
140
+ local_skills = os.path.join(".agent", "skills")
141
+ if os.path.exists(local_skills):
142
+ local_skills = str(Path(local_skills).resolve())
143
+ skill_roots[LLMS_LOCAL_SKILLS] = local_skills
139
144
 
140
145
  g_skills = {}
141
146
  for group, root in skill_roots.items():
@@ -237,7 +242,7 @@ def install(ctx):
237
242
  skill_props = props.to_dict()
238
243
  skill_props.update(
239
244
  {
240
- "group": "~/.llms/.agents",
245
+ "group": LLMS_HOME_SKILLS,
241
246
  "location": str(skill_dir),
242
247
  "files": files,
243
248
  }
@@ -303,9 +308,9 @@ def install(ctx):
303
308
 
304
309
  location = skill_info.get("location")
305
310
 
306
- # Only allow modifications to skills in home directory
307
- if not is_safe_path(home_skills, location):
308
- raise Exception("Cannot modify skills outside of home directory")
311
+ # Only allow modifications to skills in home or local .agent directory
312
+ if not is_safe_path(home_skills, location) and not (local_skills and is_safe_path(local_skills, location)):
313
+ raise Exception("Cannot modify skills outside of allowed directories")
309
314
 
310
315
  full_path = os.path.join(location, file_path)
311
316
 
@@ -319,7 +324,7 @@ def install(ctx):
319
324
  f.write(content)
320
325
 
321
326
  # Reload skill metadata
322
- group = skill_info.get("group", "~/.llms/.agents")
327
+ group = skill_info.get("group", LLMS_HOME_SKILLS)
323
328
  updated_skill = reload_skill(name, location, group)
324
329
 
325
330
  return aiohttp.web.json_response({"path": file_path, "skill": updated_skill})
@@ -342,9 +347,9 @@ def install(ctx):
342
347
 
343
348
  location = skill_info.get("location")
344
349
 
345
- # Only allow modifications to skills in home directory
346
- if not is_safe_path(home_skills, location):
347
- raise Exception("Cannot modify skills outside of home directory")
350
+ # Only allow modifications to skills in home or local .agent directory
351
+ if not is_safe_path(home_skills, location) and not (local_skills and is_safe_path(local_skills, location)):
352
+ raise Exception("Cannot modify skills outside of allowed directories")
348
353
 
349
354
  full_path = os.path.join(location, file_path)
350
355
 
@@ -371,7 +376,7 @@ def install(ctx):
371
376
  break
372
377
 
373
378
  # Reload skill metadata
374
- group = skill_info.get("group", "~/.llms/.agents")
379
+ group = skill_info.get("group", LLMS_HOME_SKILLS)
375
380
  updated_skill = reload_skill(name, location, group)
376
381
 
377
382
  return aiohttp.web.json_response({"path": file_path, "skill": updated_skill})
@@ -433,7 +438,7 @@ def install(ctx):
433
438
  skill_props = props.to_dict()
434
439
  skill_props.update(
435
440
  {
436
- "group": "~/.llms/.agents",
441
+ "group": LLMS_HOME_SKILLS,
437
442
  "location": str(skill_dir_path),
438
443
  "files": files,
439
444
  }
@@ -467,9 +472,9 @@ def install(ctx):
467
472
  else:
468
473
  raise Exception(f"Skill '{name}' not found")
469
474
 
470
- # Only allow deletion of skills in home directory
471
- if not is_safe_path(home_skills, location):
472
- raise Exception("Cannot delete skills outside of home directory")
475
+ # Only allow deletion of skills in home or local .agent directory
476
+ if not is_safe_path(home_skills, location) and not (local_skills and is_safe_path(local_skills, location)):
477
+ raise Exception("Cannot delete skills outside of allowed directories")
473
478
 
474
479
  try:
475
480
  if os.path.exists(location):