wingman-ai 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.
- share/wingman/node_listener/package-lock.json +1785 -0
- share/wingman/node_listener/package.json +50 -0
- share/wingman/node_listener/src/index.ts +108 -0
- share/wingman/node_listener/src/ipc.ts +70 -0
- share/wingman/node_listener/src/messageHandler.ts +135 -0
- share/wingman/node_listener/src/socket.ts +244 -0
- share/wingman/node_listener/src/types.d.ts +13 -0
- share/wingman/node_listener/tsconfig.json +19 -0
- wingman/__init__.py +4 -0
- wingman/__main__.py +6 -0
- wingman/cli/__init__.py +5 -0
- wingman/cli/commands/__init__.py +1 -0
- wingman/cli/commands/auth.py +90 -0
- wingman/cli/commands/config.py +109 -0
- wingman/cli/commands/init.py +71 -0
- wingman/cli/commands/logs.py +84 -0
- wingman/cli/commands/start.py +111 -0
- wingman/cli/commands/status.py +84 -0
- wingman/cli/commands/stop.py +33 -0
- wingman/cli/commands/uninstall.py +113 -0
- wingman/cli/main.py +50 -0
- wingman/cli/wizard.py +356 -0
- wingman/config/__init__.py +31 -0
- wingman/config/paths.py +153 -0
- wingman/config/personality.py +155 -0
- wingman/config/registry.py +343 -0
- wingman/config/settings.py +294 -0
- wingman/core/__init__.py +16 -0
- wingman/core/agent.py +257 -0
- wingman/core/ipc_handler.py +124 -0
- wingman/core/llm/__init__.py +5 -0
- wingman/core/llm/client.py +77 -0
- wingman/core/memory/__init__.py +6 -0
- wingman/core/memory/context.py +109 -0
- wingman/core/memory/models.py +213 -0
- wingman/core/message_processor.py +277 -0
- wingman/core/policy/__init__.py +5 -0
- wingman/core/policy/evaluator.py +265 -0
- wingman/core/process_manager.py +135 -0
- wingman/core/safety/__init__.py +8 -0
- wingman/core/safety/cooldown.py +63 -0
- wingman/core/safety/quiet_hours.py +75 -0
- wingman/core/safety/rate_limiter.py +58 -0
- wingman/core/safety/triggers.py +117 -0
- wingman/core/transports/__init__.py +14 -0
- wingman/core/transports/base.py +106 -0
- wingman/core/transports/imessage/__init__.py +5 -0
- wingman/core/transports/imessage/db_listener.py +280 -0
- wingman/core/transports/imessage/sender.py +162 -0
- wingman/core/transports/imessage/transport.py +140 -0
- wingman/core/transports/whatsapp.py +180 -0
- wingman/daemon/__init__.py +5 -0
- wingman/daemon/manager.py +303 -0
- wingman/installer/__init__.py +5 -0
- wingman/installer/node_installer.py +253 -0
- wingman_ai-1.0.0.dist-info/METADATA +553 -0
- wingman_ai-1.0.0.dist-info/RECORD +60 -0
- wingman_ai-1.0.0.dist-info/WHEEL +4 -0
- wingman_ai-1.0.0.dist-info/entry_points.txt +2 -0
- wingman_ai-1.0.0.dist-info/licenses/LICENSE +21 -0
wingman/cli/main.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Main CLI entry point for Wingman."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from wingman import __version__
|
|
7
|
+
|
|
8
|
+
from .commands import auth, config, init, logs, start, status, stop, uninstall
|
|
9
|
+
|
|
10
|
+
# Create main app
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="wingman",
|
|
13
|
+
help="Wingman - AI-powered personal chat agent for WhatsApp and iMessage",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
rich_markup_mode="rich",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Rich console for pretty output
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
# Add subcommands
|
|
22
|
+
app.command()(init.init)
|
|
23
|
+
app.command()(auth.auth)
|
|
24
|
+
app.command()(start.start)
|
|
25
|
+
app.command()(stop.stop)
|
|
26
|
+
app.command()(status.status)
|
|
27
|
+
app.command()(logs.logs)
|
|
28
|
+
app.command()(config.config)
|
|
29
|
+
app.command()(uninstall.uninstall)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.callback(invoke_without_command=True)
|
|
33
|
+
def main(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
version: bool = typer.Option(
|
|
36
|
+
False,
|
|
37
|
+
"--version",
|
|
38
|
+
"-v",
|
|
39
|
+
help="Show version and exit",
|
|
40
|
+
is_eager=True,
|
|
41
|
+
),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Wingman - AI-powered personal chat agent."""
|
|
44
|
+
if version:
|
|
45
|
+
console.print(f"Wingman v{__version__}")
|
|
46
|
+
raise typer.Exit()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
app()
|
wingman/cli/wizard.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Interactive setup wizard for Wingman."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import questionary
|
|
6
|
+
import yaml
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
9
|
+
|
|
10
|
+
from wingman.config.paths import WingmanPaths
|
|
11
|
+
from wingman.installer import NodeInstaller
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SetupWizard:
|
|
15
|
+
"""Interactive setup wizard for Wingman."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, paths: WingmanPaths, console: Console):
|
|
18
|
+
self.paths = paths
|
|
19
|
+
self.console = console
|
|
20
|
+
|
|
21
|
+
def run(self) -> bool:
|
|
22
|
+
"""Run the setup wizard. Returns True if setup completed successfully."""
|
|
23
|
+
# Step 1: Check prerequisites
|
|
24
|
+
if not self._check_prerequisites():
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
# Step 2: OpenAI configuration
|
|
28
|
+
api_key = self._get_openai_config()
|
|
29
|
+
if not api_key:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
# Step 3: Bot personality
|
|
33
|
+
bot_name, personality_desc, tone = self._get_personality_config()
|
|
34
|
+
|
|
35
|
+
# Step 4: Safety settings
|
|
36
|
+
safety_config = self._get_safety_config()
|
|
37
|
+
|
|
38
|
+
# Step 5: Install Node.js listener
|
|
39
|
+
if not self._install_node_listener():
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
# Generate config files
|
|
43
|
+
self._generate_configs(api_key, bot_name, personality_desc, tone, safety_config)
|
|
44
|
+
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
def _check_prerequisites(self) -> bool:
|
|
48
|
+
"""Check system prerequisites."""
|
|
49
|
+
self.console.print("[bold]Step 1/5: Checking prerequisites...[/bold]")
|
|
50
|
+
self.console.print()
|
|
51
|
+
|
|
52
|
+
installer = NodeInstaller(self.paths.node_dir)
|
|
53
|
+
all_ok, issues = installer.check_prerequisites()
|
|
54
|
+
|
|
55
|
+
# Python check (always passes if we're running)
|
|
56
|
+
import sys
|
|
57
|
+
|
|
58
|
+
python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
59
|
+
self.console.print(f" [green]✓[/green] Python {python_version}")
|
|
60
|
+
|
|
61
|
+
# Node.js check
|
|
62
|
+
version_info = installer.get_version_info()
|
|
63
|
+
if version_info["node_version"]:
|
|
64
|
+
self.console.print(f" [green]✓[/green] Node.js {version_info['node_version']}")
|
|
65
|
+
else:
|
|
66
|
+
self.console.print(" [red]✗[/red] Node.js not found")
|
|
67
|
+
|
|
68
|
+
# npm check
|
|
69
|
+
if version_info["npm_version"]:
|
|
70
|
+
self.console.print(f" [green]✓[/green] npm {version_info['npm_version']}")
|
|
71
|
+
else:
|
|
72
|
+
self.console.print(" [red]✗[/red] npm not found")
|
|
73
|
+
|
|
74
|
+
self.console.print()
|
|
75
|
+
|
|
76
|
+
if not all_ok:
|
|
77
|
+
self.console.print("[red]Prerequisites not met:[/red]")
|
|
78
|
+
for issue in issues:
|
|
79
|
+
self.console.print(f" - {issue}")
|
|
80
|
+
self.console.print()
|
|
81
|
+
self.console.print("Please install the missing prerequisites and try again.")
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
def _get_openai_config(self) -> str | None:
|
|
87
|
+
"""Get OpenAI API key from user."""
|
|
88
|
+
self.console.print("[bold]Step 2/5: OpenAI Configuration[/bold]")
|
|
89
|
+
self.console.print()
|
|
90
|
+
|
|
91
|
+
api_key = questionary.password(
|
|
92
|
+
"Enter your OpenAI API key:", instruction="(starts with 'sk-')"
|
|
93
|
+
).ask()
|
|
94
|
+
|
|
95
|
+
if not api_key:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
# Validate API key format
|
|
99
|
+
if not api_key.startswith("sk-"):
|
|
100
|
+
self.console.print(
|
|
101
|
+
"[yellow]Warning: API key doesn't start with 'sk-'. Proceeding anyway.[/yellow]"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Optional: Test API key
|
|
105
|
+
test = questionary.confirm("Test API key?", default=True).ask()
|
|
106
|
+
|
|
107
|
+
if test:
|
|
108
|
+
with Progress(
|
|
109
|
+
SpinnerColumn(),
|
|
110
|
+
TextColumn("[progress.description]{task.description}"),
|
|
111
|
+
console=self.console,
|
|
112
|
+
) as progress:
|
|
113
|
+
progress.add_task("Testing API key...", total=None)
|
|
114
|
+
|
|
115
|
+
if self._test_api_key(api_key):
|
|
116
|
+
self.console.print(" [green]✓[/green] API key is valid")
|
|
117
|
+
else:
|
|
118
|
+
self.console.print(" [red]✗[/red] API key test failed")
|
|
119
|
+
proceed = questionary.confirm("Continue anyway?", default=False).ask()
|
|
120
|
+
if not proceed:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
self.console.print()
|
|
124
|
+
return api_key
|
|
125
|
+
|
|
126
|
+
def _test_api_key(self, api_key: str) -> bool:
|
|
127
|
+
"""Test if the OpenAI API key is valid."""
|
|
128
|
+
try:
|
|
129
|
+
from openai import OpenAI
|
|
130
|
+
|
|
131
|
+
client = OpenAI(api_key=api_key)
|
|
132
|
+
# Simple test - list models
|
|
133
|
+
client.models.list()
|
|
134
|
+
return True
|
|
135
|
+
except Exception:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
def _get_personality_config(self) -> tuple[str, str, str]:
|
|
139
|
+
"""Get bot personality configuration."""
|
|
140
|
+
self.console.print("[bold]Step 3/5: Bot Personality[/bold]")
|
|
141
|
+
self.console.print()
|
|
142
|
+
|
|
143
|
+
bot_name = (
|
|
144
|
+
questionary.text("What should your bot be called?", default="Wingman").ask()
|
|
145
|
+
or "Wingman"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
personality_desc = (
|
|
149
|
+
questionary.text(
|
|
150
|
+
"Describe your bot's personality:", default="Witty and helpful assistant"
|
|
151
|
+
).ask()
|
|
152
|
+
or "Witty and helpful assistant"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
tone = (
|
|
156
|
+
questionary.select(
|
|
157
|
+
"Default tone:",
|
|
158
|
+
choices=[
|
|
159
|
+
questionary.Choice("casual - Relaxed and friendly", value="casual"),
|
|
160
|
+
questionary.Choice("friendly - Warm and approachable", value="friendly"),
|
|
161
|
+
questionary.Choice("professional - Polite and formal", value="professional"),
|
|
162
|
+
],
|
|
163
|
+
default="casual",
|
|
164
|
+
).ask()
|
|
165
|
+
or "casual"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
self.console.print()
|
|
169
|
+
return bot_name, personality_desc, tone
|
|
170
|
+
|
|
171
|
+
def _get_safety_config(self) -> dict:
|
|
172
|
+
"""Get safety settings configuration."""
|
|
173
|
+
self.console.print("[bold]Step 4/5: Safety Settings[/bold]")
|
|
174
|
+
self.console.print()
|
|
175
|
+
|
|
176
|
+
max_replies = (
|
|
177
|
+
questionary.text(
|
|
178
|
+
"Max replies per hour:", default="30", validate=lambda x: x.isdigit() and int(x) > 0
|
|
179
|
+
).ask()
|
|
180
|
+
or "30"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
enable_quiet_hours = questionary.confirm("Enable quiet hours?", default=True).ask()
|
|
184
|
+
|
|
185
|
+
quiet_start = 0
|
|
186
|
+
quiet_end = 6
|
|
187
|
+
|
|
188
|
+
if enable_quiet_hours:
|
|
189
|
+
quiet_range = (
|
|
190
|
+
questionary.text(
|
|
191
|
+
"Quiet hours (start-end, 24h format):",
|
|
192
|
+
default="0-6",
|
|
193
|
+
validate=lambda x: bool(re.match(r"^\d{1,2}-\d{1,2}$", x)),
|
|
194
|
+
).ask()
|
|
195
|
+
or "0-6"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
parts = quiet_range.split("-")
|
|
199
|
+
quiet_start = int(parts[0])
|
|
200
|
+
quiet_end = int(parts[1])
|
|
201
|
+
|
|
202
|
+
self.console.print()
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
"max_replies_per_hour": int(max_replies),
|
|
206
|
+
"quiet_hours_enabled": enable_quiet_hours,
|
|
207
|
+
"quiet_hours_start": quiet_start,
|
|
208
|
+
"quiet_hours_end": quiet_end,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
def _install_node_listener(self) -> bool:
|
|
212
|
+
"""Install the Node.js WhatsApp listener."""
|
|
213
|
+
self.console.print("[bold]Step 5/5: Installing WhatsApp listener...[/bold]")
|
|
214
|
+
self.console.print()
|
|
215
|
+
|
|
216
|
+
installer = NodeInstaller(self.paths.node_dir)
|
|
217
|
+
|
|
218
|
+
# Check if already installed
|
|
219
|
+
if installer.is_installed():
|
|
220
|
+
self.console.print(" [green]✓[/green] Node.js listener already installed")
|
|
221
|
+
self.console.print()
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
with Progress(
|
|
225
|
+
SpinnerColumn(),
|
|
226
|
+
TextColumn("[progress.description]{task.description}"),
|
|
227
|
+
console=self.console,
|
|
228
|
+
) as progress:
|
|
229
|
+
task = progress.add_task("Installing...", total=None)
|
|
230
|
+
|
|
231
|
+
def update_progress(step: str, message: str):
|
|
232
|
+
progress.update(task, description=message)
|
|
233
|
+
|
|
234
|
+
success = installer.install(progress_callback=update_progress)
|
|
235
|
+
|
|
236
|
+
if success:
|
|
237
|
+
self.console.print(" [green]✓[/green] Node.js listener installed")
|
|
238
|
+
else:
|
|
239
|
+
self.console.print(" [red]✗[/red] Installation failed")
|
|
240
|
+
|
|
241
|
+
self.console.print()
|
|
242
|
+
return success
|
|
243
|
+
|
|
244
|
+
def _generate_configs(
|
|
245
|
+
self, api_key: str, bot_name: str, personality_desc: str, tone: str, safety_config: dict
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Generate configuration files."""
|
|
248
|
+
# Ensure directories exist
|
|
249
|
+
self.paths.ensure_directories()
|
|
250
|
+
|
|
251
|
+
# Main config
|
|
252
|
+
config = {
|
|
253
|
+
"bot": {
|
|
254
|
+
"name": bot_name,
|
|
255
|
+
},
|
|
256
|
+
"openai": {
|
|
257
|
+
"api_key": api_key,
|
|
258
|
+
"model": "gpt-4o",
|
|
259
|
+
"max_response_tokens": 150,
|
|
260
|
+
"temperature": 0.8,
|
|
261
|
+
},
|
|
262
|
+
"personality": {
|
|
263
|
+
"base_prompt": f"You are {bot_name}, a {personality_desc}.",
|
|
264
|
+
"default_tone": tone,
|
|
265
|
+
},
|
|
266
|
+
"safety": {
|
|
267
|
+
"max_replies_per_hour": safety_config["max_replies_per_hour"],
|
|
268
|
+
"cooldown_seconds": 60,
|
|
269
|
+
"quiet_hours": {
|
|
270
|
+
"enabled": safety_config["quiet_hours_enabled"],
|
|
271
|
+
"start": safety_config["quiet_hours_start"],
|
|
272
|
+
"end": safety_config["quiet_hours_end"],
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
with open(self.paths.config_file, "w") as f:
|
|
278
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
279
|
+
|
|
280
|
+
# Contacts config (template)
|
|
281
|
+
contacts_config = {
|
|
282
|
+
"contacts": {
|
|
283
|
+
"# Add contacts here using their JID": {
|
|
284
|
+
"name": "Example Contact",
|
|
285
|
+
"role": "friend",
|
|
286
|
+
"tone": "casual",
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
"defaults": {
|
|
290
|
+
"role": "unknown",
|
|
291
|
+
"tone": "neutral",
|
|
292
|
+
"allow_proactive": False,
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
# Remove the comment key (it was just for illustration)
|
|
297
|
+
contacts_config["contacts"] = {}
|
|
298
|
+
|
|
299
|
+
with open(self.paths.contacts_config, "w") as f:
|
|
300
|
+
yaml.dump(contacts_config, f, default_flow_style=False, sort_keys=False)
|
|
301
|
+
f.write("\n# Add contacts like this:\n")
|
|
302
|
+
f.write("# contacts:\n")
|
|
303
|
+
f.write('# "+14155551234@s.whatsapp.net":\n')
|
|
304
|
+
f.write("# name: John\n")
|
|
305
|
+
f.write(
|
|
306
|
+
"# role: friend # girlfriend, sister, friend, family, colleague, unknown\n"
|
|
307
|
+
)
|
|
308
|
+
f.write(
|
|
309
|
+
"# tone: casual # affectionate, loving, friendly, casual, sarcastic, neutral\n"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Groups config (template)
|
|
313
|
+
groups_config = {
|
|
314
|
+
"groups": {},
|
|
315
|
+
"defaults": {
|
|
316
|
+
"category": "unknown",
|
|
317
|
+
"reply_policy": "selective",
|
|
318
|
+
},
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
with open(self.paths.groups_config, "w") as f:
|
|
322
|
+
yaml.dump(groups_config, f, default_flow_style=False, sort_keys=False)
|
|
323
|
+
f.write("\n# Add groups like this:\n")
|
|
324
|
+
f.write("# groups:\n")
|
|
325
|
+
f.write('# "120363012345678901@g.us":\n')
|
|
326
|
+
f.write("# name: Family Chat\n")
|
|
327
|
+
f.write("# category: family # family, friends, work, unknown\n")
|
|
328
|
+
f.write("# reply_policy: always # always, selective, never\n")
|
|
329
|
+
|
|
330
|
+
# Policies config (template)
|
|
331
|
+
policies_config = {
|
|
332
|
+
"rules": [
|
|
333
|
+
{
|
|
334
|
+
"name": "dm_always",
|
|
335
|
+
"conditions": {
|
|
336
|
+
"is_dm": True,
|
|
337
|
+
},
|
|
338
|
+
"action": "always",
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
"name": "group_selective",
|
|
342
|
+
"conditions": {
|
|
343
|
+
"is_group": True,
|
|
344
|
+
},
|
|
345
|
+
"action": "selective",
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
"fallback": {
|
|
349
|
+
"action": "selective",
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
with open(self.paths.policies_config, "w") as f:
|
|
354
|
+
yaml.dump(policies_config, f, default_flow_style=False, sort_keys=False)
|
|
355
|
+
|
|
356
|
+
self.console.print(f"[dim]Config saved to {self.paths.config_dir}[/dim]")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Configuration module for Wingman."""
|
|
2
|
+
|
|
3
|
+
from .paths import WingmanPaths
|
|
4
|
+
from .personality import SYSTEM_PROMPT, RoleBasedPromptBuilder, get_personality_prompt
|
|
5
|
+
from .registry import (
|
|
6
|
+
ContactProfile,
|
|
7
|
+
ContactRegistry,
|
|
8
|
+
ContactRole,
|
|
9
|
+
ContactTone,
|
|
10
|
+
GroupCategory,
|
|
11
|
+
GroupConfig,
|
|
12
|
+
GroupRegistry,
|
|
13
|
+
ReplyPolicy,
|
|
14
|
+
)
|
|
15
|
+
from .settings import Settings
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Settings",
|
|
19
|
+
"WingmanPaths",
|
|
20
|
+
"SYSTEM_PROMPT",
|
|
21
|
+
"get_personality_prompt",
|
|
22
|
+
"RoleBasedPromptBuilder",
|
|
23
|
+
"ContactRegistry",
|
|
24
|
+
"GroupRegistry",
|
|
25
|
+
"ContactProfile",
|
|
26
|
+
"GroupConfig",
|
|
27
|
+
"ContactRole",
|
|
28
|
+
"ContactTone",
|
|
29
|
+
"GroupCategory",
|
|
30
|
+
"ReplyPolicy",
|
|
31
|
+
]
|
wingman/config/paths.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""XDG-compliant path management for Wingman."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from platformdirs import user_cache_dir, user_config_dir, user_data_dir
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WingmanPaths:
|
|
9
|
+
"""
|
|
10
|
+
Manages XDG-compliant paths for Wingman configuration and data.
|
|
11
|
+
|
|
12
|
+
Directories:
|
|
13
|
+
- config_dir: ~/.config/wingman/ - Configuration files (YAML configs)
|
|
14
|
+
- data_dir: ~/.local/share/wingman/ - Data files (DB, auth state)
|
|
15
|
+
- cache_dir: ~/.cache/wingman/ - Cache and logs
|
|
16
|
+
- node_dir: ~/.config/wingman/node_listener/ - Installed Node.js listener
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
APP_NAME = "wingman"
|
|
20
|
+
APP_AUTHOR = "wingman"
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
config_dir: Path | None = None,
|
|
25
|
+
data_dir: Path | None = None,
|
|
26
|
+
cache_dir: Path | None = None,
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Initialize Wingman paths.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
config_dir: Override config directory (default: ~/.config/wingman)
|
|
33
|
+
data_dir: Override data directory (default: ~/.local/share/wingman)
|
|
34
|
+
cache_dir: Override cache directory (default: ~/.cache/wingman)
|
|
35
|
+
"""
|
|
36
|
+
self._config_dir = config_dir or Path(user_config_dir(self.APP_NAME, self.APP_AUTHOR))
|
|
37
|
+
self._data_dir = data_dir or Path(user_data_dir(self.APP_NAME, self.APP_AUTHOR))
|
|
38
|
+
self._cache_dir = cache_dir or Path(user_cache_dir(self.APP_NAME, self.APP_AUTHOR))
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def config_dir(self) -> Path:
|
|
42
|
+
"""Config directory (~/.config/wingman/)."""
|
|
43
|
+
return self._config_dir
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def data_dir(self) -> Path:
|
|
47
|
+
"""Data directory (~/.local/share/wingman/)."""
|
|
48
|
+
return self._data_dir
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def cache_dir(self) -> Path:
|
|
52
|
+
"""Cache directory (~/.cache/wingman/)."""
|
|
53
|
+
return self._cache_dir
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def log_dir(self) -> Path:
|
|
57
|
+
"""Log directory (~/.cache/wingman/logs/)."""
|
|
58
|
+
return self._cache_dir / "logs"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def node_dir(self) -> Path:
|
|
62
|
+
"""Node.js listener directory (~/.config/wingman/node_listener/)."""
|
|
63
|
+
return self._config_dir / "node_listener"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def auth_state_dir(self) -> Path:
|
|
67
|
+
"""WhatsApp auth state directory (~/.local/share/wingman/auth_state/)."""
|
|
68
|
+
return self._data_dir / "auth_state"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def db_path(self) -> Path:
|
|
72
|
+
"""Database file path (~/.local/share/wingman/conversations.db)."""
|
|
73
|
+
return self._data_dir / "conversations.db"
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def config_file(self) -> Path:
|
|
77
|
+
"""Main config file (~/.config/wingman/config.yaml)."""
|
|
78
|
+
return self._config_dir / "config.yaml"
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def contacts_config(self) -> Path:
|
|
82
|
+
"""Contacts config file (~/.config/wingman/contacts.yaml)."""
|
|
83
|
+
return self._config_dir / "contacts.yaml"
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def groups_config(self) -> Path:
|
|
87
|
+
"""Groups config file (~/.config/wingman/groups.yaml)."""
|
|
88
|
+
return self._config_dir / "groups.yaml"
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def policies_config(self) -> Path:
|
|
92
|
+
"""Policies config file (~/.config/wingman/policies.yaml)."""
|
|
93
|
+
return self._config_dir / "policies.yaml"
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def personality_config(self) -> Path:
|
|
97
|
+
"""Personality config file (~/.config/wingman/personality.yaml)."""
|
|
98
|
+
return self._config_dir / "personality.yaml"
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def pid_file(self) -> Path:
|
|
102
|
+
"""PID file for daemon (~/.cache/wingman/wingman.pid)."""
|
|
103
|
+
return self._cache_dir / "wingman.pid"
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def launchd_plist(self) -> Path:
|
|
107
|
+
"""Launchd plist file (~/Library/LaunchAgents/com.wingman.agent.plist)."""
|
|
108
|
+
return Path.home() / "Library" / "LaunchAgents" / "com.wingman.agent.plist"
|
|
109
|
+
|
|
110
|
+
def ensure_directories(self) -> None:
|
|
111
|
+
"""Create all required directories if they don't exist."""
|
|
112
|
+
for directory in [
|
|
113
|
+
self._config_dir,
|
|
114
|
+
self._data_dir,
|
|
115
|
+
self._cache_dir,
|
|
116
|
+
self.log_dir,
|
|
117
|
+
self.auth_state_dir,
|
|
118
|
+
]:
|
|
119
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
|
|
121
|
+
def config_exists(self) -> bool:
|
|
122
|
+
"""Check if the main config file exists."""
|
|
123
|
+
return self.config_file.exists()
|
|
124
|
+
|
|
125
|
+
def is_initialized(self) -> bool:
|
|
126
|
+
"""Check if Wingman has been set up (config and node_listener exist)."""
|
|
127
|
+
return (
|
|
128
|
+
self.config_file.exists()
|
|
129
|
+
and self.node_dir.exists()
|
|
130
|
+
and (self.node_dir / "dist" / "index.js").exists()
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_project_root(cls, project_root: Path) -> "WingmanPaths":
|
|
135
|
+
"""
|
|
136
|
+
Create paths relative to a project root (for development/legacy mode).
|
|
137
|
+
|
|
138
|
+
This maintains backward compatibility with the original project structure.
|
|
139
|
+
"""
|
|
140
|
+
return cls(
|
|
141
|
+
config_dir=project_root / "config",
|
|
142
|
+
data_dir=project_root / "data",
|
|
143
|
+
cache_dir=project_root / "logs",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def __repr__(self) -> str:
|
|
147
|
+
return (
|
|
148
|
+
f"WingmanPaths(\n"
|
|
149
|
+
f" config_dir={self._config_dir}\n"
|
|
150
|
+
f" data_dir={self._data_dir}\n"
|
|
151
|
+
f" cache_dir={self._cache_dir}\n"
|
|
152
|
+
f")"
|
|
153
|
+
)
|