chattermate-cli 0.2.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,104 @@
1
+ """
2
+ ChatterMate - CLI Knowledge Commands
3
+ Copyright (C) 2024 ChatterMate
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as
7
+ published by the Free Software Foundation, either version 3 of the
8
+ License, or (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>
17
+ """
18
+
19
+ from typing import List, Optional
20
+
21
+ import typer
22
+ from rich.table import Table
23
+
24
+ from .. import config
25
+ from ..context import console, get_client, output, print_error, run
26
+
27
+ knowledge_app = typer.Typer(no_args_is_help=True, help="Manage agent knowledge sources.")
28
+
29
+
30
+ @knowledge_app.command("add-url")
31
+ def add_url(
32
+ website: List[str] = typer.Option([], "--website", "-w", help="Website URL (repeatable)."),
33
+ pdf_url: List[str] = typer.Option([], "--pdf-url", help="PDF URL (repeatable)."),
34
+ agent_id: Optional[str] = typer.Option(None, "--agent-id", help="Attach to this agent."),
35
+ as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
36
+ ):
37
+ """Add website and/or PDF URLs to the knowledge base."""
38
+ if not website and not pdf_url:
39
+ print_error("Provide at least one --website or --pdf-url.")
40
+ raise typer.Exit(code=1)
41
+ org_id = config.load_config().get("organization_id")
42
+ if not org_id:
43
+ print_error("No organization in session. Run 'chattermate login' or 'whoami' first.")
44
+ raise typer.Exit(code=1)
45
+ client = get_client()
46
+ data = run(lambda: client.add_knowledge(
47
+ org_id=org_id, pdf_urls=list(pdf_url), websites=list(website), agent_id=agent_id,
48
+ ))
49
+ if not as_json:
50
+ console.print("[green]Queued knowledge ingestion.[/green]")
51
+ output(data, as_json)
52
+
53
+
54
+ @knowledge_app.command("list")
55
+ def list_knowledge(
56
+ agent_id: str = typer.Argument(..., help="Agent id."),
57
+ as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
58
+ ):
59
+ """List knowledge sources linked to an agent."""
60
+ client = get_client()
61
+ data = run(lambda: client.list_knowledge_for_agent(agent_id))
62
+
63
+ def render(payload):
64
+ rows = payload.get("items", payload) if isinstance(payload, dict) else payload
65
+ table = Table(title="Knowledge sources")
66
+ for col in ("id", "source_type", "source"):
67
+ table.add_column(col)
68
+ for r in rows or []:
69
+ table.add_row(str(r.get("id")), str(r.get("source_type", "")), str(r.get("source", "")))
70
+ console.print(table)
71
+
72
+ output(data, as_json, render)
73
+
74
+
75
+ @knowledge_app.command("link")
76
+ def link(
77
+ knowledge_id: int = typer.Argument(..., help="Knowledge id."),
78
+ agent_id: str = typer.Argument(..., help="Agent id."),
79
+ ):
80
+ """Link a knowledge source to an agent."""
81
+ client = get_client()
82
+ run(lambda: client.link_knowledge(knowledge_id, agent_id))
83
+ console.print(f"[green]Linked[/green] knowledge {knowledge_id} -> agent {agent_id}")
84
+
85
+
86
+ @knowledge_app.command("unlink")
87
+ def unlink(
88
+ knowledge_id: int = typer.Argument(..., help="Knowledge id."),
89
+ agent_id: str = typer.Argument(..., help="Agent id."),
90
+ ):
91
+ """Unlink a knowledge source from an agent."""
92
+ client = get_client()
93
+ run(lambda: client.unlink_knowledge(knowledge_id, agent_id))
94
+ console.print(f"[green]Unlinked[/green] knowledge {knowledge_id} from agent {agent_id}")
95
+
96
+
97
+ @knowledge_app.command("status")
98
+ def status(
99
+ queue_id: int = typer.Argument(..., help="Ingestion queue id."),
100
+ as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
101
+ ):
102
+ """Check the status of a knowledge ingestion queue item."""
103
+ client = get_client()
104
+ output(run(lambda: client.knowledge_queue_status(queue_id)), as_json)
@@ -0,0 +1,129 @@
1
+ """
2
+ ChatterMate - CLI Workflow Commands
3
+ Copyright (C) 2024 ChatterMate
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as
7
+ published by the Free Software Foundation, either version 3 of the
8
+ License, or (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>
17
+ """
18
+
19
+ import json
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ import typer
24
+
25
+ from ..context import console, get_client, output, print_error, run
26
+
27
+ workflow_app = typer.Typer(no_args_is_help=True, help="Create and manage agent workflows.")
28
+
29
+
30
+ def _load_json_arg(data: Optional[str], file: Optional[Path]) -> dict:
31
+ if file is not None:
32
+ try:
33
+ raw = file.read_text()
34
+ except OSError as e:
35
+ print_error(f"Could not read {file}: {e}")
36
+ raise typer.Exit(code=1)
37
+ elif data is not None:
38
+ raw = data
39
+ else:
40
+ print_error("Provide --data '<json>' or --file <path>.")
41
+ raise typer.Exit(code=1)
42
+ try:
43
+ return json.loads(raw)
44
+ except json.JSONDecodeError as e:
45
+ print_error(f"Invalid JSON: {e}")
46
+ raise typer.Exit(code=1)
47
+
48
+
49
+ @workflow_app.command("get")
50
+ def get_workflow(
51
+ agent_id: str = typer.Argument(..., help="Agent id whose workflow to fetch."),
52
+ as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
53
+ ):
54
+ """Get the workflow attached to an agent."""
55
+ client = get_client()
56
+ output(run(lambda: client.get_workflow_for_agent(agent_id)), as_json)
57
+
58
+
59
+ @workflow_app.command("create")
60
+ def create_workflow(
61
+ agent_id: str = typer.Option(..., "--agent-id", help="Agent the workflow belongs to."),
62
+ name: str = typer.Option(..., "--name", help="Workflow name."),
63
+ description: Optional[str] = typer.Option(None, "--description"),
64
+ as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
65
+ ):
66
+ """Create a workflow for an agent."""
67
+ payload = {"name": name, "agent_id": agent_id}
68
+ if description is not None:
69
+ payload["description"] = description
70
+ client = get_client()
71
+ data = run(lambda: client.create_workflow(payload))
72
+ if not as_json:
73
+ console.print(f"[green]Created workflow[/green] {data.get('id')} ({name})")
74
+ output(data, as_json)
75
+
76
+
77
+ @workflow_app.command("update")
78
+ def update_workflow(
79
+ workflow_id: str = typer.Argument(..., help="Workflow id."),
80
+ name: Optional[str] = typer.Option(None, "--name"),
81
+ description: Optional[str] = typer.Option(None, "--description"),
82
+ status: Optional[str] = typer.Option(
83
+ None, "--status", help="draft, published or archived (case-insensitive)."
84
+ ),
85
+ as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
86
+ ):
87
+ """Update a workflow's metadata."""
88
+ payload: dict = {}
89
+ if name is not None:
90
+ payload["name"] = name
91
+ if description is not None:
92
+ payload["description"] = description
93
+ if status is not None:
94
+ # API enum values are lowercase (draft, published, archived); normalize.
95
+ payload["status"] = status.lower()
96
+ if not payload:
97
+ print_error("Nothing to update. Pass --name, --description or --status.")
98
+ raise typer.Exit(code=1)
99
+ client = get_client()
100
+ data = run(lambda: client.update_workflow(workflow_id, payload))
101
+ if not as_json:
102
+ console.print(f"[green]Updated workflow[/green] {workflow_id}")
103
+ output(data, as_json)
104
+
105
+
106
+ @workflow_app.command("nodes")
107
+ def get_nodes(
108
+ workflow_id: str = typer.Argument(..., help="Workflow id."),
109
+ as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
110
+ ):
111
+ """Get all nodes and connections for a workflow."""
112
+ client = get_client()
113
+ output(run(lambda: client.get_workflow_nodes(workflow_id)), as_json)
114
+
115
+
116
+ @workflow_app.command("set-nodes")
117
+ def set_nodes(
118
+ workflow_id: str = typer.Argument(..., help="Workflow id."),
119
+ data: Optional[str] = typer.Option(None, "--data", help="Nodes/connections JSON payload."),
120
+ file: Optional[Path] = typer.Option(None, "--file", help="Path to a JSON payload file."),
121
+ as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
122
+ ):
123
+ """Replace a workflow's nodes/connections from a JSON payload."""
124
+ payload = _load_json_arg(data, file)
125
+ client = get_client()
126
+ result = run(lambda: client.update_workflow_nodes(workflow_id, payload))
127
+ if not as_json:
128
+ console.print(f"[green]Updated nodes[/green] for workflow {workflow_id}")
129
+ output(result, as_json)
@@ -0,0 +1,101 @@
1
+ """
2
+ ChatterMate - CLI Configuration
3
+ Copyright (C) 2024 ChatterMate
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as
7
+ published by the Free Software Foundation, either version 3 of the
8
+ License, or (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>
17
+ """
18
+
19
+ import json
20
+ import os
21
+ from pathlib import Path
22
+ from typing import Any, Dict, Optional
23
+
24
+ # Hosted ChatterMate API. For a local/self-hosted backend, set
25
+ # CHATTERMATE_API_URL=http://localhost:8000 (or pass --api-url).
26
+ DEFAULT_API_URL = "https://api.chattermate.chat"
27
+ ENV_API_URL = "CHATTERMATE_API_URL"
28
+ ENV_TOKEN = "CHATTERMATE_TOKEN"
29
+ ENV_CONFIG_DIR = "CHATTERMATE_CONFIG_DIR"
30
+
31
+
32
+ def config_dir() -> Path:
33
+ return Path(os.environ.get(ENV_CONFIG_DIR, str(Path.home() / ".chattermate")))
34
+
35
+
36
+ def config_file() -> Path:
37
+ return config_dir() / "config.json"
38
+
39
+
40
+ def load_config() -> Dict[str, Any]:
41
+ """Load the on-disk config, or an empty dict if none exists / is unreadable."""
42
+ path = config_file()
43
+ try:
44
+ with open(path, "r", encoding="utf-8") as fh:
45
+ return json.load(fh)
46
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
47
+ return {}
48
+
49
+
50
+ def save_config(cfg: Dict[str, Any]) -> None:
51
+ """Persist config to ~/.chattermate/config.json atomically with 0600 permissions.
52
+
53
+ The file holds tokens, so it is created 0600 from the start (no world-readable
54
+ window) and written via a temp file + atomic replace (no truncated config on crash).
55
+ """
56
+ d = config_dir()
57
+ d.mkdir(parents=True, exist_ok=True)
58
+ path = config_file()
59
+ tmp = path.with_suffix(".json.tmp")
60
+ # os.open with mode 0600 ensures the secret-bearing file is never world-readable.
61
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
62
+ try:
63
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
64
+ json.dump(cfg, fh, indent=2)
65
+ except Exception:
66
+ try:
67
+ os.unlink(tmp)
68
+ finally:
69
+ raise
70
+ os.replace(tmp, path)
71
+
72
+
73
+ def update_config(**values: Any) -> Dict[str, Any]:
74
+ cfg = load_config()
75
+ cfg.update({k: v for k, v in values.items() if v is not None})
76
+ save_config(cfg)
77
+ return cfg
78
+
79
+
80
+ def clear_auth() -> None:
81
+ """Remove stored credentials (keeps api_url)."""
82
+ cfg = load_config()
83
+ for key in ("access_token", "refresh_token", "user_id", "organization_id", "email"):
84
+ cfg.pop(key, None)
85
+ save_config(cfg)
86
+
87
+
88
+ def resolve_api_url(override: Optional[str] = None) -> str:
89
+ if override:
90
+ return override
91
+ return os.environ.get(ENV_API_URL) or load_config().get("api_url") or DEFAULT_API_URL
92
+
93
+
94
+ def resolve_token(override: Optional[str] = None) -> Optional[str]:
95
+ """Resolve the bearer token: explicit override > env PAT > stored access token."""
96
+ if override:
97
+ return override
98
+ env_token = os.environ.get(ENV_TOKEN)
99
+ if env_token:
100
+ return env_token
101
+ return load_config().get("access_token")
@@ -0,0 +1,78 @@
1
+ """
2
+ ChatterMate - CLI Shared Context
3
+ Copyright (C) 2024 ChatterMate
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as
7
+ published by the Free Software Foundation, either version 3 of the
8
+ License, or (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>
17
+ """
18
+
19
+ import json as _json
20
+ from typing import Any, Optional
21
+
22
+ import typer
23
+ from rich.console import Console
24
+
25
+ from . import config
26
+ from .client import ChatterMateError, Client
27
+
28
+ console = Console()
29
+ err_console = Console(stderr=True)
30
+
31
+ # Set by the root callback so every command can honour a global --api-url.
32
+ _api_url_override: Optional[str] = None
33
+
34
+
35
+ def set_api_url_override(value: Optional[str]) -> None:
36
+ global _api_url_override
37
+ _api_url_override = value
38
+
39
+
40
+ def _persist_tokens(access: str, refresh: Optional[str]) -> None:
41
+ config.update_config(access_token=access, refresh_token=refresh)
42
+
43
+
44
+ def get_client(require_auth: bool = True) -> Client:
45
+ """Build a Client from --api-url / env / stored config."""
46
+ api_url = config.resolve_api_url(_api_url_override)
47
+ token = config.resolve_token()
48
+ cfg = config.load_config()
49
+ if require_auth and not token:
50
+ print_error("Not authenticated. Run 'chattermate login' or set CHATTERMATE_TOKEN.")
51
+ raise typer.Exit(code=1)
52
+ return Client(
53
+ api_url=api_url,
54
+ token=token,
55
+ refresh_token=cfg.get("refresh_token"),
56
+ on_tokens=_persist_tokens,
57
+ )
58
+
59
+
60
+ def output(data: Any, as_json: bool, table_renderer=None) -> None:
61
+ """Print ``data`` as JSON when ``as_json`` is set, else via ``table_renderer``."""
62
+ if as_json or table_renderer is None:
63
+ console.print_json(_json.dumps(data, default=str))
64
+ else:
65
+ table_renderer(data)
66
+
67
+
68
+ def print_error(message: str) -> None:
69
+ err_console.print(f"[bold red]Error:[/bold red] {message}")
70
+
71
+
72
+ def run(fn):
73
+ """Execute an API call, turning ChatterMateError into a clean non-zero exit."""
74
+ try:
75
+ return fn()
76
+ except ChatterMateError as e:
77
+ print_error(str(e))
78
+ raise typer.Exit(code=1)
@@ -0,0 +1,74 @@
1
+ """
2
+ ChatterMate - CLI Entry Point
3
+ Copyright (C) 2024 ChatterMate
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as
7
+ published by the Free Software Foundation, either version 3 of the
8
+ License, or (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>
17
+ """
18
+
19
+ from typing import Optional
20
+
21
+ import typer
22
+
23
+ from . import __version__, context
24
+ from .commands import auth as auth_cmd
25
+ from .commands.agent import agent_app
26
+ from .commands.knowledge import knowledge_app
27
+ from .commands.workflow import workflow_app
28
+
29
+ app = typer.Typer(
30
+ no_args_is_help=True,
31
+ add_completion=True,
32
+ help="ChatterMate CLI — sign up, log in, and configure agents, workflows and knowledge.",
33
+ )
34
+
35
+
36
+ def _version_callback(value: bool):
37
+ if value:
38
+ typer.echo(f"chattermate-cli {__version__}")
39
+ raise typer.Exit()
40
+
41
+
42
+ @app.callback()
43
+ def main_callback(
44
+ api_url: Optional[str] = typer.Option(
45
+ None, "--api-url",
46
+ help="ChatterMate API base URL (overrides CHATTERMATE_API_URL and stored config).",
47
+ ),
48
+ version: bool = typer.Option(
49
+ False, "--version", callback=_version_callback, is_eager=True, help="Show version."
50
+ ),
51
+ ):
52
+ """Global options."""
53
+ context.set_api_url_override(api_url)
54
+
55
+
56
+ # Top-level auth commands
57
+ app.command("login")(auth_cmd.login)
58
+ app.command("signup")(auth_cmd.signup)
59
+ app.command("logout")(auth_cmd.logout)
60
+ app.command("whoami")(auth_cmd.whoami)
61
+
62
+ # Sub-command groups
63
+ app.add_typer(auth_cmd.token_app, name="token")
64
+ app.add_typer(agent_app, name="agent")
65
+ app.add_typer(workflow_app, name="workflow")
66
+ app.add_typer(knowledge_app, name="knowledge")
67
+
68
+
69
+ def main():
70
+ app()
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()