ai-config-cli 0.1.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.
ai_config/watch.py ADDED
@@ -0,0 +1,279 @@
1
+ """File watching for auto-sync on changes."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from threading import Event, Timer
7
+ from typing import Literal
8
+
9
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
10
+ from watchdog.observers import Observer
11
+
12
+ from ai_config.types import AIConfig, PluginSource
13
+
14
+
15
+ @dataclass
16
+ class FileChange:
17
+ """Represents a detected file change."""
18
+
19
+ path: Path
20
+ change_type: Literal["config", "plugin_directory"]
21
+ event_type: str # "created", "modified", "deleted"
22
+
23
+
24
+ @dataclass
25
+ class WatchConfig:
26
+ """Configuration for file watching."""
27
+
28
+ config_path: Path
29
+ plugin_directories: list[Path]
30
+ debounce_seconds: float = 1.5
31
+
32
+
33
+ # Patterns to ignore when watching files
34
+ IGNORE_PATTERNS = frozenset(
35
+ {
36
+ ".swp", # Vim swap files
37
+ ".swo", # Vim swap overflow
38
+ ".swn", # Vim swap
39
+ "~", # Backup files
40
+ ".tmp", # Temp files
41
+ ".bak", # Backup files
42
+ }
43
+ )
44
+
45
+ IGNORE_DIRS = frozenset(
46
+ {
47
+ ".git",
48
+ "__pycache__",
49
+ ".pytest_cache",
50
+ "node_modules",
51
+ ".venv",
52
+ "venv",
53
+ }
54
+ )
55
+
56
+
57
+ def collect_watch_paths(config: AIConfig, config_path: Path) -> WatchConfig:
58
+ """Extract paths to watch from config.
59
+
60
+ Args:
61
+ config: The loaded AIConfig.
62
+ config_path: Path to the config file itself.
63
+
64
+ Returns:
65
+ WatchConfig with paths to monitor.
66
+ """
67
+ plugin_directories: list[Path] = []
68
+
69
+ for target in config.targets:
70
+ if target.type == "claude":
71
+ for marketplace in target.config.marketplaces.values():
72
+ if marketplace.source == PluginSource.LOCAL:
73
+ plugin_directories.append(Path(marketplace.path))
74
+
75
+ return WatchConfig(
76
+ config_path=config_path,
77
+ plugin_directories=plugin_directories,
78
+ )
79
+
80
+
81
+ def _should_ignore_path(path: Path) -> bool:
82
+ """Check if a path should be ignored.
83
+
84
+ Args:
85
+ path: Path to check.
86
+
87
+ Returns:
88
+ True if the path should be ignored.
89
+ """
90
+ # Check file suffixes/patterns
91
+ name = path.name
92
+ for pattern in IGNORE_PATTERNS:
93
+ if name.endswith(pattern):
94
+ return True
95
+
96
+ # Check for hidden files starting with .
97
+ if name.startswith(".") and not name.startswith(".."):
98
+ # Allow normal dotfiles but check for swap pattern
99
+ if name.endswith(".swp") or name.endswith(".swo"):
100
+ return True
101
+
102
+ # Check if any parent is an ignored directory
103
+ for part in path.parts:
104
+ if part in IGNORE_DIRS:
105
+ return True
106
+
107
+ return False
108
+
109
+
110
+ class ChangeCollector(FileSystemEventHandler):
111
+ """Collects file changes and debounces callback invocation."""
112
+
113
+ def __init__(
114
+ self,
115
+ config_path: Path,
116
+ plugin_directories: list[Path],
117
+ debounce_seconds: float,
118
+ on_changes: Callable[[list[FileChange]], None],
119
+ ) -> None:
120
+ """Initialize the collector.
121
+
122
+ Args:
123
+ config_path: Path to the config file.
124
+ plugin_directories: List of plugin directories to monitor.
125
+ debounce_seconds: Seconds to wait before firing callback.
126
+ on_changes: Callback to invoke with collected changes.
127
+ """
128
+ super().__init__()
129
+ self._config_path = config_path.resolve()
130
+ self._plugin_directories = [d.resolve() for d in plugin_directories]
131
+ self._debounce_seconds = debounce_seconds
132
+ self._on_changes = on_changes
133
+ self._pending_changes: dict[Path, FileChange] = {}
134
+ self._debounce_timer: Timer | None = None
135
+
136
+ def _classify_change(self, path: Path) -> Literal["config", "plugin_directory"] | None:
137
+ """Classify a file change by type.
138
+
139
+ Args:
140
+ path: Path of the changed file.
141
+
142
+ Returns:
143
+ Change type or None if not a watched path.
144
+ """
145
+ resolved = path.resolve()
146
+
147
+ if resolved == self._config_path:
148
+ return "config"
149
+
150
+ for plugin_dir in self._plugin_directories:
151
+ try:
152
+ resolved.relative_to(plugin_dir)
153
+ return "plugin_directory"
154
+ except ValueError:
155
+ continue
156
+
157
+ return None
158
+
159
+ def _handle_event(self, event: FileSystemEvent) -> None:
160
+ """Handle a file system event.
161
+
162
+ Args:
163
+ event: The watchdog event.
164
+ """
165
+ if event.is_directory:
166
+ return
167
+
168
+ src_path = event.src_path
169
+ if isinstance(src_path, bytes):
170
+ src_path = src_path.decode()
171
+ path = Path(src_path)
172
+
173
+ if _should_ignore_path(path):
174
+ return
175
+
176
+ change_type = self._classify_change(path)
177
+ if change_type is None:
178
+ return
179
+
180
+ # Add to pending changes (deduplicate by path)
181
+ self._pending_changes[path] = FileChange(
182
+ path=path,
183
+ change_type=change_type,
184
+ event_type=event.event_type,
185
+ )
186
+
187
+ # Reset debounce timer
188
+ if self._debounce_timer is not None:
189
+ self._debounce_timer.cancel()
190
+
191
+ self._debounce_timer = Timer(self._debounce_seconds, self._fire_callback)
192
+ self._debounce_timer.daemon = True
193
+ self._debounce_timer.start()
194
+
195
+ def _fire_callback(self) -> None:
196
+ """Fire the callback with collected changes."""
197
+ if not self._pending_changes:
198
+ return
199
+
200
+ changes = list(self._pending_changes.values())
201
+ self._pending_changes.clear()
202
+ self._debounce_timer = None
203
+
204
+ self._on_changes(changes)
205
+
206
+ def on_created(self, event: FileSystemEvent) -> None:
207
+ """Handle file created event."""
208
+ self._handle_event(event)
209
+
210
+ def on_modified(self, event: FileSystemEvent) -> None:
211
+ """Handle file modified event."""
212
+ self._handle_event(event)
213
+
214
+ def on_deleted(self, event: FileSystemEvent) -> None:
215
+ """Handle file deleted event."""
216
+ self._handle_event(event)
217
+
218
+ def on_moved(self, event: FileSystemEvent) -> None:
219
+ """Handle file moved event."""
220
+ self._handle_event(event)
221
+
222
+
223
+ @dataclass
224
+ class WatchResult:
225
+ """Result of a watch operation."""
226
+
227
+ config_changes: int = 0
228
+ plugin_changes: int = 0
229
+ errors: list[str] = field(default_factory=list)
230
+
231
+
232
+ def run_watch_loop(
233
+ watch_config: WatchConfig,
234
+ on_changes: Callable[[list[FileChange]], None],
235
+ stop_event: Event,
236
+ debounce_seconds: float = 1.5,
237
+ ) -> None:
238
+ """Run the watch loop until stop_event is set.
239
+
240
+ Args:
241
+ watch_config: Configuration for what to watch.
242
+ on_changes: Callback to invoke when changes are detected.
243
+ stop_event: Event to signal the loop should stop.
244
+ debounce_seconds: Seconds to wait before syncing after changes.
245
+ """
246
+ collector = ChangeCollector(
247
+ config_path=watch_config.config_path,
248
+ plugin_directories=watch_config.plugin_directories,
249
+ debounce_seconds=debounce_seconds,
250
+ on_changes=on_changes,
251
+ )
252
+
253
+ observer = Observer()
254
+
255
+ # Watch config file's directory
256
+ if watch_config.config_path.parent.exists():
257
+ observer.schedule(
258
+ collector,
259
+ str(watch_config.config_path.parent),
260
+ recursive=False,
261
+ )
262
+
263
+ # Watch plugin directories recursively
264
+ for plugin_dir in watch_config.plugin_directories:
265
+ if plugin_dir.exists():
266
+ observer.schedule(
267
+ collector,
268
+ str(plugin_dir),
269
+ recursive=True,
270
+ )
271
+
272
+ observer.start()
273
+
274
+ try:
275
+ while not stop_event.is_set():
276
+ stop_event.wait(timeout=0.5)
277
+ finally:
278
+ observer.stop()
279
+ observer.join()
@@ -0,0 +1,235 @@
1
+ Metadata-Version: 2.4
2
+ Name: ai-config-cli
3
+ Version: 0.1.0
4
+ Summary: Declarative plugin manager for Claude Code
5
+ Project-URL: Homepage, https://github.com/safurrier/ai-config
6
+ Project-URL: Documentation, https://safurrier.github.io/ai-config/
7
+ Project-URL: Repository, https://github.com/safurrier/ai-config
8
+ Project-URL: Issues, https://github.com/safurrier/ai-config/issues
9
+ Project-URL: Changelog, https://github.com/safurrier/ai-config/blob/main/CHANGELOG.md
10
+ Author-email: Alex Furrier <afurrier@gmail.com>
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: ai,claude,claude-code,configuration,plugin
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Environment :: Console
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.9
28
+ Requires-Dist: click>=8.0
29
+ Requires-Dist: pyyaml>=6.0
30
+ Requires-Dist: questionary>=2.0
31
+ Requires-Dist: requests>=2.28
32
+ Requires-Dist: rich>=13.0
33
+ Requires-Dist: watchdog>=3.0
34
+ Provides-Extra: dev
35
+ Requires-Dist: pre-commit>=3.6.0; extra == 'dev'
36
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
37
+ Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
38
+ Requires-Dist: pytest>=8.1.1; extra == 'dev'
39
+ Requires-Dist: ruff>=0.3.0; extra == 'dev'
40
+ Requires-Dist: ty>=0.0.2; extra == 'dev'
41
+ Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
42
+ Requires-Dist: types-requests>=2.28; extra == 'dev'
43
+ Provides-Extra: docs
44
+ Requires-Dist: mkdocs-material>=9.6.14; extra == 'docs'
45
+ Requires-Dist: mkdocstrings[python]>=0.26.1; extra == 'docs'
46
+ Description-Content-Type: text/markdown
47
+
48
+ # ai-config
49
+
50
+ Declarative plugin manager for Claude Code.
51
+
52
+ (Future: Codex CLI and OpenCode support planned once plugins become more standardized for sharing ai-context)
53
+
54
+ ## Why this exists
55
+
56
+ Claude Code plugins are useful. They let you extend Claude with custom skills, hooks, and MCP servers. The problem is managing them.
57
+
58
+ Without ai-config, you're running `claude plugin install` and `claude plugin marketplace add` commands by hand across machines. There's no config file. No way to version control your setup. No way to share it.
59
+
60
+ ai-config fixes that. You write a YAML file describing what plugins you want, and it handles the rest.
61
+
62
+ Or more simply, run `ai-config init` and it writes the config for you.
63
+
64
+ ## What this isn't
65
+
66
+ This README does not have:
67
+
68
+ - 14 shields.io badges declaring build status, coverage, npm downloads, discord members, twitter followers, and mass-to-charge ratio
69
+ - A mass of emojis to make it look "friendly" and "approachable"
70
+ - Claims about revolutionizing your development workflow
71
+ - Integration with 47 different tools (we integrate with one)
72
+ - A "Quick Start" that's actually 73 steps
73
+ - Screenshots of a dashboard that doesn't exist
74
+ - A "Powered by AI" badge despite just being a for-loop
75
+
76
+ It's a config file and some commands. That's it.
77
+
78
+ ## Installation
79
+
80
+ > **Alpha software**: This project is in active development. APIs and config formats may change between versions.
81
+
82
+ ```bash
83
+ pip install ai-config-cli
84
+ # or
85
+ uv tool install ai-config-cli
86
+ ```
87
+
88
+ This installs `ai-config` globally. Run `ai-config --help` to verify.
89
+
90
+ ### From source (latest)
91
+
92
+ ```bash
93
+ uv tool install git+https://github.com/safurrier/ai-config
94
+ ```
95
+
96
+ ### For development
97
+
98
+ ```bash
99
+ git clone https://github.com/safurrier/ai-config.git
100
+ cd ai-config
101
+ just setup # Install dependencies
102
+ just check # Run lint, type check, tests
103
+ ```
104
+
105
+ ## Quick Start
106
+
107
+ **1. Create your config**
108
+
109
+ ```bash
110
+ ai-config init
111
+ ```
112
+
113
+ Interactive wizard walks you through adding marketplaces and plugins. Creates `.ai-config/config.yaml`.
114
+
115
+ **2. Sync to install plugins**
116
+
117
+ ```bash
118
+ ai-config sync
119
+ ```
120
+
121
+ Installs/uninstalls plugins to match your config. Run this after editing `config.yaml`.
122
+
123
+ If plugins seem stale or out of date:
124
+
125
+ ```bash
126
+ ai-config sync --fresh
127
+ ```
128
+
129
+ **3. Iterate with watch (plugin development)**
130
+
131
+ ```bash
132
+ ai-config watch
133
+ ```
134
+
135
+ Auto-syncs when you edit config or plugin files. Press Ctrl+C to stop.
136
+
137
+ **Note:** Claude Code loads plugins at session start. After changes sync, restart Claude Code to apply them. Use `claude --resume` to continue your previous session.
138
+
139
+ **4. Troubleshoot with doctor**
140
+
141
+ ```bash
142
+ ai-config doctor
143
+ ```
144
+
145
+ Validates marketplaces, plugins, skills, hooks, and MCP servers. Shows fix hints for any issues.
146
+
147
+ ## What it does
148
+
149
+ **Declarative config** - Define your plugins in `.ai-config/config.yaml`:
150
+
151
+ ```yaml
152
+ version: 1
153
+ targets:
154
+ - type: claude
155
+ config:
156
+ marketplaces:
157
+ claude-code-tutorial:
158
+ source: github
159
+ repo: safurrier/claude-code-tutorial
160
+ plugins:
161
+ - id: claude-code-tutorial@claude-code-tutorial
162
+ scope: user
163
+ enabled: true
164
+ ```
165
+
166
+ **Interactive setup** - Don't want to write YAML? Run the wizard:
167
+
168
+ ```bash
169
+ ai-config init
170
+ ```
171
+
172
+ It walks you through adding marketplaces and plugins with arrow-key navigation.
173
+
174
+ **Sync** - Make reality match your config:
175
+
176
+ ```bash
177
+ ai-config sync
178
+ ```
179
+
180
+ **Validation** - Find problems before they bite you:
181
+
182
+ ```bash
183
+ ai-config doctor
184
+ ```
185
+
186
+ Checks that marketplaces exist, plugins are installed, skills are valid, hooks work.
187
+
188
+ ## Commands
189
+
190
+ | Command | What it does |
191
+ |---------|--------------|
192
+ | `init` | Interactive config generator |
193
+ | `sync` | Install/uninstall plugins to match config |
194
+ | `status` | Show what's currently installed |
195
+ | `watch` | Auto-sync on file changes (plugin development) |
196
+ | `update` | Update plugins to latest versions |
197
+ | `doctor` | Validate setup and show fix hints |
198
+ | `plugin create` | Scaffold a new plugin |
199
+ | `cache clear` | Clear the plugin cache |
200
+
201
+ ## Config file locations
202
+
203
+ ai-config looks for config in this order:
204
+
205
+ 1. `.ai-config/config.yaml` (project-local)
206
+ 2. `~/.ai-config/config.yaml` (global)
207
+
208
+ You can also pass `-c /path/to/config.yaml` to any command.
209
+
210
+ ## Scopes
211
+
212
+ Plugins can be installed in different scopes:
213
+
214
+ - **user** - Available everywhere (`~/.claude/plugins/`)
215
+ - **project** - Only in the current project (`.claude/plugins/`)
216
+
217
+ ## Troubleshooting
218
+
219
+ **Plugin installed but not showing up in / commands**
220
+
221
+ Clear cache and re-sync:
222
+
223
+ ```bash
224
+ ai-config sync --fresh
225
+ ```
226
+
227
+ **Something's broken and Claude Code won't help**
228
+
229
+ ```bash
230
+ ai-config doctor --verbose
231
+ ```
232
+
233
+ ## License
234
+
235
+ MIT
@@ -0,0 +1,32 @@
1
+ ai_config/__init__.py,sha256=gjK4b53Q3FicdMkCfFxlMfb1UGppWUPA2eQlgPU4upU,88
2
+ ai_config/__main__.py,sha256=GmxRKMTWWibho0WpkCjFMil8jZBSToDFf-Otenax0Xs,114
3
+ ai_config/cli.py,sha256=aMt_h9B5GJSB08qvO_SkzC3V3-GzI1Y346-if3Z0cuE,23865
4
+ ai_config/cli_render.py,sha256=gJ3Lnb-4_ONXNIz2sRjVDhdDGIi833WGC_WnTFX0xKQ,18658
5
+ ai_config/cli_theme.py,sha256=AU3EdLAvyWtxwFtQYNhowRCBJerlZfU89cFgwDRq5wo,1094
6
+ ai_config/config.py,sha256=mqol3YKXiipkBqdJV1cBBvlhMFu4PRjOsbw5ypiChZs,8686
7
+ ai_config/init.py,sha256=uJl7Egh13iJlG6_ciuYIRbA3GwOa339bUp5NZO1BNjY,22517
8
+ ai_config/operations.py,sha256=om967tGLhywcz_RFoDc0nXjzRp-O8NMmVffd32YWJj8,10907
9
+ ai_config/scaffold.py,sha256=rP-U2rwEe67WXp26-9LzoalRP_S8MjntrXO5D7xONSw,1836
10
+ ai_config/settings.py,sha256=uwqVpz_YkTqaFI8Qtq7UIWx7KF1l2Nd86CFD8P0B0As,1642
11
+ ai_config/types.py,sha256=Rn9QNdWvymH28jLi_mLn_e_rnVKWoMxwZ7tgNWFKzdM,4022
12
+ ai_config/watch.py,sha256=fYCZTc__jzEpj38633wF62wT7J5DFmYrid_n1h_7NlI,7711
13
+ ai_config/adapters/__init__.py,sha256=qHNJ4e7OVzkreIS5Cu7KtehPVGiWmrWMPUqZ97NiKIM,46
14
+ ai_config/adapters/claude.py,sha256=baWdzMFVj94mO10jUCm6AojcKxT5svqYtNh00ADPcYU,9496
15
+ ai_config/validators/__init__.py,sha256=nCk8_PKQ1_RBd_CaWLB5BHEug_JOb3OlLJ3dyldfqEQ,4404
16
+ ai_config/validators/base.py,sha256=AfOxBJOjVSrw8uHsUZTlz4ZdTGmtFvkxgOm77HXo8rI,1225
17
+ ai_config/validators/context.py,sha256=q1IxgX501e3GoiLhJAxJYQQlOFlxlMKlSYmfNKJeKM8,2642
18
+ ai_config/validators/component/__init__.py,sha256=nPod-3X9oLl1mUfz_kyfmUzIxogZTG2BWGpLhClmrFw,64
19
+ ai_config/validators/component/hook.py,sha256=h7bCoQYD03WAsspNCIoX-iz5Mb7rxexjmc0kxnL0UT4,13188
20
+ ai_config/validators/component/mcp.py,sha256=Oq9X-K9EDO4bTUGE70lEW4EIn30mUsq65Fi4L4Vhvaw,8613
21
+ ai_config/validators/component/skill.py,sha256=o5uAaWVljpGEw0Xx85d7tovt7gYrRZ9AwK_w96PO-EM,12647
22
+ ai_config/validators/marketplace/__init__.py,sha256=eg1JthnlrnEQeG8Daghyn4zZOKLea3y5xYuqfUxHv7M,44
23
+ ai_config/validators/marketplace/validators.py,sha256=uOmO8yl4q10QvIt4kNhUyb4V3qWhhhqbI-WfxSkbDC4,16353
24
+ ai_config/validators/plugin/__init__.py,sha256=xT8IoczuJrhorek6naYwZNESDaQkeOJ_nijozAOz4Wo,39
25
+ ai_config/validators/plugin/validators.py,sha256=dP4T-JhuC41CDcllvgRwlVTkwHdKf106aR-bpk5pzFs,12170
26
+ ai_config/validators/target/__init__.py,sha256=A_57pCwACG1oFd80UlGUN1iWRHlYqzceBz5aCYYtTKA,39
27
+ ai_config/validators/target/claude.py,sha256=rYs9gqQRcqtnN-jv4EA5HXIrpIP-DEq6lVqAJGk9b78,5014
28
+ ai_config_cli-0.1.0.dist-info/METADATA,sha256=YkMC1WvFkCl1YRPkVF6vKbdkHH_TLAZkZi6ji805Ql8,6603
29
+ ai_config_cli-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
30
+ ai_config_cli-0.1.0.dist-info/entry_points.txt,sha256=Bw4iKZy9_HtrJplb26AWXbVOQYugpMre11wg31GhnB0,49
31
+ ai_config_cli-0.1.0.dist-info/licenses/LICENSE,sha256=YubrN8gJ2atD-zJmLwr1OJa2pejx1ZwArylzHBEYAb8,1069
32
+ ai_config_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ai-config = ai_config.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Alex Furrier
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.