iflow-mcp_excelsier-things3-enhanced-mcp 1.0.0__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,335 @@
1
+ #!/usr/bin/env python3
2
+ import subprocess
3
+ import logging
4
+ from typing import Optional, List, Dict, Any, Union
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ def run_applescript(script: str) -> Union[str, bool]:
9
+ """Run an AppleScript command and return the result.
10
+
11
+ Args:
12
+ script: The AppleScript code to execute
13
+
14
+ Returns:
15
+ The result of the AppleScript execution, or False if it failed
16
+ """
17
+ try:
18
+ result = subprocess.run(['osascript', '-e', script],
19
+ capture_output=True, text=True)
20
+
21
+ if result.returncode != 0:
22
+ logger.error(f"AppleScript error: {result.stderr}")
23
+ return False
24
+
25
+ return result.stdout.strip()
26
+ except Exception as e:
27
+ logger.error(f"Error running AppleScript: {str(e)}")
28
+ return False
29
+
30
+ def add_todo_direct(title: str, notes: Optional[str] = None, when: Optional[str] = None,
31
+ tags: Optional[List[str]] = None, list_title: Optional[str] = None) -> str:
32
+ """Add a todo to Things directly using AppleScript.
33
+
34
+ This bypasses URL schemes entirely to avoid encoding issues.
35
+
36
+ Args:
37
+ title: Title of the todo
38
+ notes: Notes for the todo
39
+ when: When to schedule the todo (today, tomorrow, evening, anytime, someday)
40
+ tags: Tags to apply to the todo
41
+ list_title: Name of project/area to add to
42
+
43
+ Returns:
44
+ ID of the created todo if successful, False otherwise
45
+ """
46
+ # Build the AppleScript command
47
+ script_parts = ['tell application "Things3"']
48
+
49
+ # Create the todo with properties
50
+ properties = []
51
+ properties.append(f'name:"{escape_applescript_string(title)}"')
52
+
53
+ if notes:
54
+ properties.append(f'notes:"{escape_applescript_string(notes)}"')
55
+
56
+ # Create with properties in the right way
57
+ script_parts.append(f'set newTodo to make new to do with properties {{{", ".join(properties)}}}')
58
+
59
+ # Add scheduling
60
+ if when:
61
+ when_mapping = {
62
+ 'today': '', # Default is today, no need to set
63
+ 'tomorrow': 'set activation date of newTodo to ((current date) + 1 * days)',
64
+ 'evening': '', # Default is today, no need to set
65
+ 'anytime': '', # Default
66
+ 'someday': 'set status of newTodo to someday'
67
+ }
68
+
69
+ if when in when_mapping:
70
+ if when_mapping[when]:
71
+ script_parts.append(when_mapping[when])
72
+ else:
73
+ # For date handling, it's safest to just log it and not try to set it
74
+ # This avoids AppleScript date formatting issues
75
+ logger.warning(f"Custom date format '{when}' not supported, defaulting to today")
76
+
77
+ # Add tags if provided
78
+ if tags and len(tags) > 0:
79
+ for tag in tags:
80
+ script_parts.append(f'tell newTodo to make new tag with properties {{name:"{escape_applescript_string(tag)}"}}')
81
+
82
+ # Add to a specific project/area if specified
83
+ if list_title:
84
+ script_parts.append(f'set project_name to "{escape_applescript_string(list_title)}"')
85
+ script_parts.append('try')
86
+ script_parts.append(' set target_project to first project whose name is project_name')
87
+ script_parts.append(' set project of newTodo to target_project')
88
+ script_parts.append('on error')
89
+ script_parts.append(' -- Project not found, try area')
90
+ script_parts.append(' try')
91
+ script_parts.append(' set target_area to first area whose name is project_name')
92
+ script_parts.append(' set area of newTodo to target_area')
93
+ script_parts.append(' on error')
94
+ script_parts.append(' -- Neither project nor area found, todo will remain in inbox')
95
+ script_parts.append(' end try')
96
+ script_parts.append('end try')
97
+
98
+ # Get the ID of the created todo
99
+ script_parts.append('return id of newTodo')
100
+
101
+ # Close the tell block
102
+ script_parts.append('end tell')
103
+
104
+ # Execute the script
105
+ script = '\n'.join(script_parts)
106
+ logger.debug(f"Executing AppleScript: {script}")
107
+
108
+ result = run_applescript(script)
109
+ if result:
110
+ logger.info(f"Successfully created todo with ID: {result}")
111
+ return result
112
+ else:
113
+ logger.error("Failed to create todo")
114
+ return False
115
+
116
+ def escape_applescript_string(text: str) -> str:
117
+ """Escape special characters in an AppleScript string.
118
+
119
+ Args:
120
+ text: The string to escape
121
+
122
+ Returns:
123
+ The escaped string
124
+ """
125
+ if not text:
126
+ return ""
127
+
128
+ # Replace any "+" with spaces first
129
+ text = text.replace("+", " ")
130
+
131
+ # Escape quotes by doubling them (AppleScript style)
132
+ return text.replace('"', '""')
133
+
134
+ def update_todo_direct(id: str, title: Optional[str] = None, notes: Optional[str] = None,
135
+ when: Optional[str] = None, deadline: Optional[str] = None,
136
+ tags: Optional[Union[List[str], str]] = None, add_tags: Optional[Union[List[str], str]] = None,
137
+ checklist_items: Optional[List[str]] = None, completed: Optional[bool] = None,
138
+ canceled: Optional[bool] = None) -> bool:
139
+ """Update a todo directly using AppleScript.
140
+
141
+ This bypasses URL schemes entirely to avoid authentication issues.
142
+
143
+ Args:
144
+ id: The ID of the todo to update
145
+ title: New title for the todo
146
+ notes: New notes for the todo
147
+ when: New schedule for the todo (today, tomorrow, evening, anytime, someday, or YYYY-MM-DD)
148
+ deadline: New deadline for the todo (YYYY-MM-DD)
149
+ tags: New tags for the todo (replaces existing tags)
150
+ add_tags: Tags to add to the todo (preserves existing tags)
151
+ checklist_items: Checklist items to set for the todo (replaces existing items)
152
+ completed: Mark as completed
153
+ canceled: Mark as canceled
154
+
155
+ Returns:
156
+ True if successful, False otherwise
157
+ """
158
+ import re
159
+
160
+ # Build the AppleScript command to find and update the todo
161
+ script_parts = ['tell application "Things3"']
162
+ script_parts.append('try')
163
+ script_parts.append(f' set theTodo to to do id "{id}"')
164
+
165
+ # Update properties one at a time
166
+ if title:
167
+ script_parts.append(f' set name of theTodo to "{escape_applescript_string(title)}"')
168
+
169
+ if notes:
170
+ script_parts.append(f' set notes of theTodo to "{escape_applescript_string(notes)}"')
171
+
172
+ # Handle date-related properties
173
+ if when:
174
+ # Check if when is a date in YYYY-MM-DD format
175
+ is_date_format = re.match(r'^\d{4}-\d{2}-\d{2}$', when)
176
+
177
+ # Simple mapping of common 'when' values to AppleScript commands
178
+ if when == 'today':
179
+ script_parts.append(' move theTodo to list "Today"')
180
+ elif when == 'tomorrow':
181
+ script_parts.append(' set activation date of theTodo to ((current date) + (1 * days))')
182
+ script_parts.append(' move theTodo to list "Upcoming"')
183
+ elif when == 'evening':
184
+ script_parts.append(' move theTodo to list "Evening"')
185
+ elif when == 'anytime':
186
+ script_parts.append(' move theTodo to list "Anytime"')
187
+ elif when == 'someday':
188
+ script_parts.append(' move theTodo to list "Someday"')
189
+ elif is_date_format:
190
+ # Handle YYYY-MM-DD format dates
191
+ year, month, day = when.split('-')
192
+ script_parts.append(f'''
193
+ -- Set activation date with direct date string
194
+ set dateString to "{when}"
195
+ set newDate to date dateString
196
+ set activation date of theTodo to newDate
197
+ -- Move to the Upcoming list
198
+ move theTodo to list "Upcoming"
199
+ ''')
200
+ else:
201
+ # For other formats, just log a warning and don't try to set it
202
+ logger.warning(f"Schedule format '{when}' not directly supported in this simplified version")
203
+
204
+ if deadline:
205
+ # Check if deadline is in YYYY-MM-DD format
206
+ if re.match(r'^\d{4}-\d{2}-\d{2}$', deadline):
207
+ year, month, day = deadline.split('-')
208
+ script_parts.append(f'''
209
+ -- Set deadline with direct date string
210
+ set deadlineString to "{deadline}"
211
+ set deadlineDate to date deadlineString
212
+ set deadline of theTodo to deadlineDate
213
+ ''')
214
+ else:
215
+ logger.warning(f"Invalid deadline format: {deadline}. Expected YYYY-MM-DD")
216
+
217
+ # Handle tags (clearing and adding new ones)
218
+ if tags is not None:
219
+ # Convert string tags to list if needed
220
+ if isinstance(tags, str):
221
+ tags = [tags]
222
+
223
+ if tags:
224
+ # Clear existing tags first
225
+ script_parts.append(' -- Clear existing tags')
226
+ script_parts.append(' set tag_names of theTodo to {}')
227
+
228
+ # Simplified tag handling
229
+ import json
230
+ tags_json = json.dumps(tags)
231
+ script_parts.append(f'''
232
+ -- Set tags using a list
233
+ set tagNameList to {tags_json}
234
+ -- Clear existing tags
235
+ set oldTags to tags of theTodo
236
+ repeat with t from (count of oldTags) to 1 by -1
237
+ delete item t of oldTags
238
+ end repeat
239
+ -- Add new tags
240
+ repeat with t from 1 to (count of tagNameList)
241
+ set tagText to item t of tagNameList
242
+ tell theTodo
243
+ set newTag to make new tag
244
+ set name of newTag to tagText
245
+ end tell
246
+ end repeat
247
+ ''')
248
+ else:
249
+ # Clear all tags if empty list provided
250
+ script_parts.append(' -- Clear all tags')
251
+ script_parts.append(' set tag_names of theTodo to {}')
252
+
253
+ # Handle adding tags without replacing existing ones
254
+ if add_tags is not None:
255
+ # Convert string to list if needed
256
+ if isinstance(add_tags, str):
257
+ add_tags = [add_tags]
258
+
259
+ for tag in add_tags:
260
+ tag_name = escape_applescript_string(tag)
261
+ script_parts.append(f'''
262
+ -- Add tag {tag_name} if it doesn't exist
263
+ set tagFound to false
264
+ repeat with t in tags of theTodo
265
+ if name of t is "{tag_name}" then
266
+ set tagFound to true
267
+ exit repeat
268
+ end if
269
+ end repeat
270
+ if not tagFound then
271
+ tell theTodo to make new tag with properties {{name:"{tag_name}"}}
272
+ end if
273
+ ''')
274
+
275
+ # Handle checklist items - simplified approach
276
+ if checklist_items is not None:
277
+ # Convert string to list if needed
278
+ if isinstance(checklist_items, str):
279
+ checklist_items = checklist_items.split('\n')
280
+
281
+ if checklist_items:
282
+ # For simplicity, we'll use JSON to pass checklist items
283
+ import json
284
+ items_json = json.dumps([item for item in checklist_items])
285
+ script_parts.append(f'''
286
+ -- Clear and set checklist items
287
+ set oldItems to check list items of theTodo
288
+ repeat with i from (count of oldItems) to 1 by -1
289
+ delete item i of oldItems
290
+ end repeat
291
+
292
+ set itemList to {items_json}
293
+ repeat with i from 1 to (count of itemList)
294
+ set itemText to item i of itemList
295
+ tell theTodo
296
+ set newItem to make new check list item
297
+ set name of newItem to itemText
298
+ end tell
299
+ end repeat
300
+ ''')
301
+
302
+ # Handle completion status - use completion date approach
303
+ if completed is not None:
304
+ if completed:
305
+ script_parts.append(' set status of theTodo to completed')
306
+ else:
307
+ script_parts.append(' set status of theTodo to open')
308
+
309
+ # Handle canceled status
310
+ if canceled is not None:
311
+ if canceled:
312
+ script_parts.append(' set status of theTodo to canceled')
313
+ else:
314
+ script_parts.append(' set status of theTodo to open')
315
+
316
+ # Return true on success
317
+ script_parts.append(' return true')
318
+ script_parts.append('on error errMsg')
319
+ script_parts.append(' log "Error updating todo: " & errMsg')
320
+ script_parts.append(' return false')
321
+ script_parts.append('end try')
322
+ script_parts.append('end tell')
323
+
324
+ # Execute the script
325
+ script = '\n'.join(script_parts)
326
+ logger.info(f"Executing AppleScript for update_todo_direct: \n{script}")
327
+
328
+ result = run_applescript(script)
329
+
330
+ if result == "true":
331
+ logger.info(f"Successfully updated todo with ID: {id}")
332
+ return True
333
+ else:
334
+ logger.error(f"AppleScript update_todo_direct failed: {result}")
335
+ return False
things_mcp/cache.py ADDED
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Caching module for Things MCP server.
4
+ Provides intelligent caching for frequently accessed data to improve performance.
5
+ """
6
+ import time
7
+ import json
8
+ import hashlib
9
+ import logging
10
+ from typing import Any, Dict, Optional, Callable, Tuple
11
+ from functools import wraps
12
+ from threading import Lock
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ class ThingsCache:
17
+ """
18
+ Thread-safe cache for Things data with TTL support.
19
+ """
20
+
21
+ def __init__(self, default_ttl: int = 300): # 5 minutes default
22
+ """
23
+ Initialize the cache.
24
+
25
+ Args:
26
+ default_ttl: Default time-to-live for cache entries in seconds
27
+ """
28
+ self.cache: Dict[str, Tuple[Any, float]] = {}
29
+ self.default_ttl = default_ttl
30
+ self.lock = Lock()
31
+ self.hit_count = 0
32
+ self.miss_count = 0
33
+
34
+ def _make_key(self, operation: str, **kwargs) -> str:
35
+ """Generate a cache key from operation and parameters."""
36
+ # Sort kwargs to ensure consistent keys
37
+ sorted_params = json.dumps(kwargs, sort_keys=True)
38
+ key_string = f"{operation}:{sorted_params}"
39
+ # Use hash for shorter keys
40
+ return hashlib.md5(key_string.encode()).hexdigest()
41
+
42
+ def get(self, operation: str, **kwargs) -> Optional[Any]:
43
+ """
44
+ Get a value from cache if it exists and hasn't expired.
45
+
46
+ Args:
47
+ operation: The operation name
48
+ **kwargs: Parameters for the operation
49
+
50
+ Returns:
51
+ Cached value if available and valid, None otherwise
52
+ """
53
+ key = self._make_key(operation, **kwargs)
54
+
55
+ with self.lock:
56
+ if key in self.cache:
57
+ value, expiry_time = self.cache[key]
58
+ if time.time() < expiry_time:
59
+ self.hit_count += 1
60
+ logger.debug(f"Cache hit for {operation}: {key}")
61
+ return value
62
+ else:
63
+ # Expired, remove it
64
+ del self.cache[key]
65
+ logger.debug(f"Cache expired for {operation}: {key}")
66
+
67
+ self.miss_count += 1
68
+ return None
69
+
70
+ def set(self, operation: str, value: Any, ttl: Optional[int] = None, **kwargs) -> None:
71
+ """
72
+ Set a value in the cache.
73
+
74
+ Args:
75
+ operation: The operation name
76
+ value: The value to cache
77
+ ttl: Time-to-live in seconds (uses default if not specified)
78
+ **kwargs: Parameters for the operation
79
+ """
80
+ key = self._make_key(operation, **kwargs)
81
+ ttl = ttl if ttl is not None else self.default_ttl
82
+ expiry_time = time.time() + ttl
83
+
84
+ with self.lock:
85
+ self.cache[key] = (value, expiry_time)
86
+ logger.debug(f"Cache set for {operation}: {key}, TTL: {ttl}s")
87
+
88
+ def invalidate(self, operation: Optional[str] = None, **kwargs) -> None:
89
+ """
90
+ Invalidate cache entries.
91
+
92
+ Args:
93
+ operation: If specified, only invalidate entries for this operation
94
+ **kwargs: If specified with operation, invalidate specific entry
95
+ """
96
+ with self.lock:
97
+ if operation and kwargs:
98
+ # Invalidate specific entry
99
+ key = self._make_key(operation, **kwargs)
100
+ if key in self.cache:
101
+ del self.cache[key]
102
+ logger.debug(f"Invalidated specific cache entry: {key}")
103
+ elif operation:
104
+ # Invalidate all entries for an operation
105
+ keys_to_remove = [k for k in self.cache.keys()
106
+ if k.startswith(hashlib.md5(f"{operation}:".encode()).hexdigest()[:8])]
107
+ for key in keys_to_remove:
108
+ del self.cache[key]
109
+ logger.debug(f"Invalidated {len(keys_to_remove)} cache entries for operation: {operation}")
110
+ else:
111
+ # Clear entire cache
112
+ self.cache.clear()
113
+ logger.info("Cleared entire cache")
114
+
115
+ def cleanup_expired(self) -> int:
116
+ """Remove expired entries from cache. Returns number of entries removed."""
117
+ current_time = time.time()
118
+ removed_count = 0
119
+
120
+ with self.lock:
121
+ keys_to_remove = []
122
+ for key, (_, expiry_time) in self.cache.items():
123
+ if current_time >= expiry_time:
124
+ keys_to_remove.append(key)
125
+
126
+ for key in keys_to_remove:
127
+ del self.cache[key]
128
+ removed_count += 1
129
+
130
+ if removed_count > 0:
131
+ logger.debug(f"Cleaned up {removed_count} expired cache entries")
132
+
133
+ return removed_count
134
+
135
+ def get_stats(self) -> Dict[str, Any]:
136
+ """Get cache statistics."""
137
+ with self.lock:
138
+ total_requests = self.hit_count + self.miss_count
139
+ hit_rate = (self.hit_count / total_requests * 100) if total_requests > 0 else 0
140
+
141
+ return {
142
+ "entries": len(self.cache),
143
+ "hits": self.hit_count,
144
+ "misses": self.miss_count,
145
+ "hit_rate": f"{hit_rate:.1f}%",
146
+ "total_requests": total_requests
147
+ }
148
+
149
+ # Global cache instance
150
+ _cache = ThingsCache()
151
+
152
+ def cached(ttl: Optional[int] = None, invalidate_on: Optional[list] = None):
153
+ """
154
+ Decorator for caching function results.
155
+
156
+ Args:
157
+ ttl: Time-to-live for cached results in seconds
158
+ invalidate_on: List of operation names that should invalidate this cache
159
+
160
+ Example:
161
+ @cached(ttl=60)
162
+ def get_projects():
163
+ return things.projects()
164
+ """
165
+ def decorator(func: Callable) -> Callable:
166
+ @wraps(func)
167
+ def wrapper(*args, **kwargs):
168
+ # Create operation name from function
169
+ operation = func.__name__
170
+
171
+ # Check cache first
172
+ cached_value = _cache.get(operation, **kwargs)
173
+ if cached_value is not None:
174
+ return cached_value
175
+
176
+ # Call the actual function
177
+ result = func(*args, **kwargs)
178
+
179
+ # Cache the result
180
+ _cache.set(operation, result, ttl=ttl, **kwargs)
181
+
182
+ return result
183
+
184
+ # Store invalidation info
185
+ if invalidate_on:
186
+ wrapper._invalidate_on = invalidate_on
187
+
188
+ return wrapper
189
+ return decorator
190
+
191
+ def invalidate_caches_for(operations: list) -> None:
192
+ """
193
+ Invalidate caches for specific operations.
194
+
195
+ This is useful when data is modified and related caches need to be cleared.
196
+
197
+ Args:
198
+ operations: List of operation names to invalidate
199
+ """
200
+ for operation in operations:
201
+ _cache.invalidate(operation)
202
+
203
+ def get_cache_stats() -> Dict[str, Any]:
204
+ """Get cache statistics."""
205
+ return _cache.get_stats()
206
+
207
+ def clear_cache() -> None:
208
+ """Clear all cached data."""
209
+ _cache.invalidate()
210
+
211
+ # TTL configurations for different types of data
212
+ CACHE_TTL = {
213
+ "inbox": 30, # 30 seconds - changes frequently
214
+ "today": 30, # 30 seconds - changes frequently
215
+ "upcoming": 60, # 1 minute
216
+ "anytime": 300, # 5 minutes
217
+ "someday": 300, # 5 minutes
218
+ "projects": 300, # 5 minutes
219
+ "areas": 600, # 10 minutes - rarely changes
220
+ "tags": 600, # 10 minutes - rarely changes
221
+ "logbook": 300, # 5 minutes
222
+ "trash": 300, # 5 minutes
223
+ }
224
+
225
+ # Auto-cleanup task
226
+ def start_cache_cleanup_task(interval: int = 300):
227
+ """Start a background task to clean up expired cache entries."""
228
+ import threading
229
+
230
+ def cleanup_task():
231
+ while True:
232
+ time.sleep(interval)
233
+ _cache.cleanup_expired()
234
+
235
+ thread = threading.Thread(target=cleanup_task, daemon=True)
236
+ thread.start()
237
+ logger.info(f"Started cache cleanup task with {interval}s interval")
238
+
239
+ # Start cleanup task when module is imported
240
+ start_cache_cleanup_task()
things_mcp/config.py ADDED
@@ -0,0 +1,120 @@
1
+ """
2
+ Configuration module for Things MCP.
3
+ Stores settings and user-specific configuration values.
4
+ """
5
+ import os
6
+ import json
7
+ import logging
8
+ from pathlib import Path
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Default configuration values
13
+ DEFAULT_CONFIG = {
14
+ "things_auth_token": "", # Empty by default, must be set by user
15
+ "retry_attempts": 3,
16
+ "retry_delay": 1.0
17
+ }
18
+
19
+ # Path to the configuration file
20
+ CONFIG_DIR = Path.home() / ".things-mcp"
21
+ CONFIG_FILE = CONFIG_DIR / "config.json"
22
+
23
+ # Global configuration dictionary
24
+ _config = None
25
+
26
+ def load_config():
27
+ """Load configuration from file, create with defaults if it doesn't exist."""
28
+ global _config
29
+
30
+ # Create config directory if it doesn't exist
31
+ if not CONFIG_DIR.exists():
32
+ try:
33
+ CONFIG_DIR.mkdir(parents=True)
34
+ logger.info(f"Created configuration directory: {CONFIG_DIR}")
35
+ except Exception as e:
36
+ logger.error(f"Failed to create config directory: {e}")
37
+ return DEFAULT_CONFIG
38
+
39
+ # Check if config file exists
40
+ if not CONFIG_FILE.exists():
41
+ # Create default config file
42
+ try:
43
+ with open(CONFIG_FILE, 'w') as f:
44
+ json.dump(DEFAULT_CONFIG, f, indent=2)
45
+ logger.info(f"Created default configuration file: {CONFIG_FILE}")
46
+ _config = DEFAULT_CONFIG.copy()
47
+ except Exception as e:
48
+ logger.error(f"Failed to create config file: {e}")
49
+ _config = DEFAULT_CONFIG.copy()
50
+ else:
51
+ # Load existing config
52
+ try:
53
+ with open(CONFIG_FILE, 'r') as f:
54
+ loaded_config = json.load(f)
55
+
56
+ # Ensure all required keys are present
57
+ _config = DEFAULT_CONFIG.copy()
58
+ _config.update(loaded_config)
59
+
60
+ logger.info(f"Loaded configuration from: {CONFIG_FILE}")
61
+ except Exception as e:
62
+ logger.error(f"Failed to load config file: {e}")
63
+ _config = DEFAULT_CONFIG.copy()
64
+
65
+ return _config
66
+
67
+ def save_config():
68
+ """Save current configuration to file."""
69
+ if _config is None:
70
+ logger.error("Cannot save config: No configuration loaded")
71
+ return False
72
+
73
+ try:
74
+ with open(CONFIG_FILE, 'w') as f:
75
+ json.dump(_config, f, indent=2)
76
+ logger.info(f"Saved configuration to: {CONFIG_FILE}")
77
+ return True
78
+ except Exception as e:
79
+ logger.error(f"Failed to save config: {e}")
80
+ return False
81
+
82
+ def get_config():
83
+ """Get the current configuration, loading it if necessary."""
84
+ global _config
85
+ if _config is None:
86
+ _config = load_config()
87
+ return _config
88
+
89
+ def set_config_value(key, value):
90
+ """Set a configuration value and save to file."""
91
+ config = get_config()
92
+ config[key] = value
93
+ return save_config()
94
+
95
+ def get_config_value(key, default=None):
96
+ """Get a configuration value, return default if not found."""
97
+ config = get_config()
98
+ return config.get(key, default)
99
+
100
+ def get_things_auth_token():
101
+ """Get the Things authentication token.
102
+
103
+ First checks environment variable THINGS_AUTH_TOKEN,
104
+ then falls back to config file.
105
+ """
106
+ # Check environment variable first
107
+ token = os.environ.get('THINGS_AUTH_TOKEN')
108
+ if token:
109
+ logger.debug("Using Things auth token from environment variable")
110
+ return token
111
+
112
+ # Fall back to config file
113
+ return get_config_value("things_auth_token", "")
114
+
115
+ def set_things_auth_token(token):
116
+ """Set the Things authentication token."""
117
+ return set_config_value("things_auth_token", token)
118
+
119
+ # Initialize configuration on module import
120
+ load_config()