agent-devkit 0.1.0 → 0.1.5
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.
- package/README.md +42 -2
- package/package.json +1 -1
- package/runtime/README.md +47 -2
- package/runtime/cli/README.md +37 -1
- package/runtime/cli/aikit/__init__.py +1 -1
- package/runtime/cli/aikit/configuration_orchestrator.py +291 -0
- package/runtime/cli/aikit/decision_store.py +158 -0
- package/runtime/cli/aikit/fallback.py +48 -2
- package/runtime/cli/aikit/llm.py +9 -0
- package/runtime/cli/aikit/main.py +317 -9
- package/runtime/cli/aikit/model_router.py +70 -0
- package/runtime/cli/aikit/ollama.py +237 -0
- package/runtime/cli/aikit/provider_wizard.py +19 -0
- package/runtime/cli/aikit/review_gate.py +40 -0
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ agent doctor
|
|
|
26
26
|
Expected version for this release:
|
|
27
27
|
|
|
28
28
|
```text
|
|
29
|
-
agent 0.1.
|
|
29
|
+
agent 0.1.5
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Quick Start
|
|
@@ -173,12 +173,19 @@ agent llm doctor openrouter
|
|
|
173
173
|
### Option F: Ollama local backend
|
|
174
174
|
|
|
175
175
|
```bash
|
|
176
|
+
agent ollama status
|
|
177
|
+
agent ollama models
|
|
178
|
+
agent ollama pull qwen2.5-coder --dry-run
|
|
179
|
+
agent ollama pull qwen2.5-coder --yes
|
|
176
180
|
ollama serve
|
|
177
|
-
ollama pull qwen2.5-coder
|
|
178
181
|
agent llm configure ollama --base-url http://localhost:11434/v1 --model qwen2.5-coder --set-default
|
|
179
182
|
agent llm doctor ollama
|
|
180
183
|
```
|
|
181
184
|
|
|
185
|
+
Ollama is treated as an operational worker for repetitive local tasks. Codex and
|
|
186
|
+
Claude remain the preferred coordinators and reviewers for high-level planning,
|
|
187
|
+
software changes, documents, automation decisions and final review.
|
|
188
|
+
|
|
182
189
|
### Switch or override the backend
|
|
183
190
|
|
|
184
191
|
```bash
|
|
@@ -186,6 +193,8 @@ agent llm list
|
|
|
186
193
|
agent llm set-default codex-cli
|
|
187
194
|
agent llm set-default claude-code
|
|
188
195
|
agent llm set-default openai
|
|
196
|
+
agent llm disable ollama
|
|
197
|
+
agent llm enable ollama
|
|
189
198
|
agent llm doctor
|
|
190
199
|
```
|
|
191
200
|
|
|
@@ -210,6 +219,17 @@ Natural-language example:
|
|
|
210
219
|
agent "analise o problema relatado no card 9900"
|
|
211
220
|
```
|
|
212
221
|
|
|
222
|
+
If a required source or provider is not configured yet, prompt routing and
|
|
223
|
+
capability execution return the global `provider-configurator` setup wizard
|
|
224
|
+
instead of a hardcoded manual command:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
agent --json "analise o card 7914 do projeto sustentacao no azure"
|
|
228
|
+
agent --json run topdesk-orchestrator read-incident --number "I 2606 001"
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The wizard asks for opt-in and collects one configuration item at a time.
|
|
232
|
+
|
|
213
233
|
Deterministic example:
|
|
214
234
|
|
|
215
235
|
```bash
|
|
@@ -222,6 +242,26 @@ Inspect a capability contract before running it:
|
|
|
222
242
|
agent inspect azure-devops-orchestrator read-card
|
|
223
243
|
```
|
|
224
244
|
|
|
245
|
+
## Local Decisions And Tool Control
|
|
246
|
+
|
|
247
|
+
Agent DevKit persists opt-in and opt-out decisions locally. You can inspect and
|
|
248
|
+
change tools, integrations, skills and LLM availability from commands or natural
|
|
249
|
+
language prompts:
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
agent decisions list
|
|
253
|
+
agent tools disable azure-devops
|
|
254
|
+
agent tools enable azure-devops
|
|
255
|
+
agent integrations list
|
|
256
|
+
agent skills list
|
|
257
|
+
agent "mostre minhas decisoes"
|
|
258
|
+
agent "desative o azure devops por enquanto"
|
|
259
|
+
agent "reative o ollama"
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Decisions are stored under `~/.ai-devkit/config/decisions.json`; secret values
|
|
263
|
+
are not stored there.
|
|
264
|
+
|
|
225
265
|
## Configure LLM Backends
|
|
226
266
|
|
|
227
267
|
Agent DevKit stores references to credentials, not secret values. API keys stay
|
package/package.json
CHANGED
package/runtime/README.md
CHANGED
|
@@ -204,11 +204,17 @@ agent "roteie este pedido para o agente especialista adequado"
|
|
|
204
204
|
|
|
205
205
|
### Opcao F: usar Ollama local
|
|
206
206
|
|
|
207
|
-
|
|
207
|
+
O Agent DevKit consegue diagnosticar Ollama, listar modelos, planejar pull e
|
|
208
|
+
usar o backend local como trabalhador operacional. Claude/Codex continuam sendo
|
|
209
|
+
os coordenadores e revisores preferenciais para decisao, especificacao e entrega
|
|
210
|
+
final.
|
|
208
211
|
|
|
209
212
|
```bash
|
|
213
|
+
agent ollama status
|
|
214
|
+
agent ollama models
|
|
215
|
+
agent ollama pull qwen2.5-coder --dry-run
|
|
216
|
+
agent ollama pull qwen2.5-coder --yes
|
|
210
217
|
ollama serve
|
|
211
|
-
ollama pull qwen2.5-coder
|
|
212
218
|
agent llm configure ollama --base-url http://localhost:11434/v1 --model qwen2.5-coder --set-default
|
|
213
219
|
agent llm doctor ollama
|
|
214
220
|
```
|
|
@@ -226,6 +232,8 @@ agent llm list
|
|
|
226
232
|
agent llm set-default codex-cli
|
|
227
233
|
agent llm set-default claude-code
|
|
228
234
|
agent llm set-default openai
|
|
235
|
+
agent llm disable ollama
|
|
236
|
+
agent llm enable ollama
|
|
229
237
|
agent llm doctor
|
|
230
238
|
```
|
|
231
239
|
|
|
@@ -309,6 +317,43 @@ Exemplo de uso deterministico com uma capability:
|
|
|
309
317
|
agent run azure-devops-orchestrator read-card --project "Projeto" --id 9900 --include-comments
|
|
310
318
|
```
|
|
311
319
|
|
|
320
|
+
## Configuracao agentica e decisoes locais
|
|
321
|
+
|
|
322
|
+
Quando um prompt ou capability exige uma fonte ou provider ainda nao
|
|
323
|
+
configurado, o `agent` nao deve mais parar apenas sugerindo um comando manual.
|
|
324
|
+
Ele aciona o configurador global `provider-configurator`, retorna um wizard
|
|
325
|
+
agentico com opt-in, perguntas progressivas e retomada do prompt original:
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
agent --json "analise o card 7914 do projeto sustentacao no azure"
|
|
329
|
+
agent --json run topdesk-orchestrator read-incident --number "I 2606 001"
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
O usuario tambem pode controlar ferramentas, integracoes, skills e LLMs sem
|
|
333
|
+
editar arquivos diretamente:
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
agent decisions list
|
|
337
|
+
agent tools list
|
|
338
|
+
agent tools disable azure-devops
|
|
339
|
+
agent tools enable azure-devops
|
|
340
|
+
agent integrations list
|
|
341
|
+
agent skills list
|
|
342
|
+
agent llm list
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
As mesmas operacoes podem ser feitas por prompt:
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
agent "mostre minhas decisoes"
|
|
349
|
+
agent "desative o azure devops por enquanto"
|
|
350
|
+
agent "reative o ollama"
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Se o usuario negar ou desativar uma ferramenta, a decisao fica persistida em
|
|
354
|
+
`~/.ai-devkit/config/decisions.json` e o agente segue sem usar essa ferramenta
|
|
355
|
+
nas proximas sessoes ate reativacao explicita.
|
|
356
|
+
|
|
312
357
|
Comandos uteis:
|
|
313
358
|
|
|
314
359
|
```bash
|
package/runtime/cli/README.md
CHANGED
|
@@ -263,12 +263,19 @@ agent llm doctor openrouter
|
|
|
263
263
|
### Ollama local
|
|
264
264
|
|
|
265
265
|
```bash
|
|
266
|
+
agent ollama status
|
|
267
|
+
agent ollama models
|
|
268
|
+
agent ollama pull qwen2.5-coder --dry-run
|
|
269
|
+
agent ollama pull qwen2.5-coder --yes
|
|
266
270
|
ollama serve
|
|
267
|
-
ollama pull qwen2.5-coder
|
|
268
271
|
agent llm configure ollama --base-url http://localhost:11434/v1 --model qwen2.5-coder --set-default
|
|
269
272
|
agent llm doctor ollama
|
|
270
273
|
```
|
|
271
274
|
|
|
275
|
+
Ollama e tratado como executor operacional local. Codex e Claude continuam como
|
|
276
|
+
coordenadores/revisores preferenciais para decisao, especificacao, codigo,
|
|
277
|
+
documentos, automacoes e fechamento de entrega.
|
|
278
|
+
|
|
272
279
|
Backends suportados no MVP:
|
|
273
280
|
|
|
274
281
|
- `openai`: API OpenAI ou endpoint OpenAI-compatible.
|
|
@@ -289,6 +296,8 @@ agent llm configure ollama --base-url http://localhost:11434/v1 --model qwen2.5-
|
|
|
289
296
|
agent llm configure codex-cli --set-default
|
|
290
297
|
agent llm configure claude-code --set-default
|
|
291
298
|
agent llm set-default codex-cli
|
|
299
|
+
agent llm disable ollama
|
|
300
|
+
agent llm enable ollama
|
|
292
301
|
agent llm doctor
|
|
293
302
|
agent llm doctor openai
|
|
294
303
|
```
|
|
@@ -331,6 +340,33 @@ Use `--session-only` para validar uma configuracao sem escrever em
|
|
|
331
340
|
`~/.ai-devkit/config.json`. Arquivos passados com `--env-file` precisam conter
|
|
332
341
|
ao menos um campo reconhecido pelo provider selecionado.
|
|
333
342
|
|
|
343
|
+
Quando um prompt ou capability exige uma source/provider ausente, o runtime
|
|
344
|
+
aciona o configurador global `provider-configurator` e retorna um wizard
|
|
345
|
+
agentico com opt-in e perguntas progressivas em vez de sugerir um comando
|
|
346
|
+
hardcoded:
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
agent --json "analise o card 7914 do projeto sustentacao no azure"
|
|
350
|
+
agent --json run topdesk-orchestrator read-incident --number "I 2606 001"
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## Decisoes, ferramentas e integracoes
|
|
354
|
+
|
|
355
|
+
Opt-ins e opt-outs ficam em `~/.ai-devkit/config/decisions.json` e podem ser
|
|
356
|
+
gerenciados por comando ou prompt:
|
|
357
|
+
|
|
358
|
+
```bash
|
|
359
|
+
agent decisions list
|
|
360
|
+
agent tools list
|
|
361
|
+
agent tools disable azure-devops
|
|
362
|
+
agent tools enable azure-devops
|
|
363
|
+
agent integrations list
|
|
364
|
+
agent skills list
|
|
365
|
+
agent "mostre minhas decisoes"
|
|
366
|
+
agent "desative o azure devops por enquanto"
|
|
367
|
+
agent "reative o ollama"
|
|
368
|
+
```
|
|
369
|
+
|
|
334
370
|
## Credential Resolver
|
|
335
371
|
|
|
336
372
|
O resolver de credenciais opera por referencias e origens, sem retornar valores
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Global configuration orchestration for missing providers and sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from cli.aikit.decision_store import is_disabled
|
|
10
|
+
from cli.aikit.providers import ProviderRegistryError, load_providers, provider_or_error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
PROJECT_PATTERN = re.compile(r"(?i)\bprojeto\s+([A-Za-z0-9._ -]{2,80}?)(?:\s+no\b|\s+na\b|\s+do\b|\s+da\b|$)")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def provider_setup_wizard(
|
|
17
|
+
root: Path,
|
|
18
|
+
provider_id: str,
|
|
19
|
+
*,
|
|
20
|
+
prompt: str | None = None,
|
|
21
|
+
route: dict[str, Any] | None = None,
|
|
22
|
+
agent_id: str | None = None,
|
|
23
|
+
capability_id: str | None = None,
|
|
24
|
+
reason: str | None = None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
provider = load_provider(root, provider_id)
|
|
27
|
+
provider_name = str(provider.get("name") or provider_id)
|
|
28
|
+
intent = str((route or {}).get("intent") or capability_id or "provider-configuration")
|
|
29
|
+
suggested_project = infer_project(prompt or "")
|
|
30
|
+
source_context = bool(route and route.get("intent"))
|
|
31
|
+
suggested_source_id = suggest_source_id(provider_id, suggested_project)
|
|
32
|
+
disabled = is_disabled("tools", provider_id) or is_disabled("integrations", provider_id)
|
|
33
|
+
questions = build_questions(provider, suggested_project=suggested_project, suggested_source_id=suggested_source_id, include_source=source_context)
|
|
34
|
+
next_question = disabled_notice(provider_id, provider_name) if disabled else opt_in_question(provider_id, provider_name)
|
|
35
|
+
return {
|
|
36
|
+
"kind": "provider-setup-wizard",
|
|
37
|
+
"status": "denied-by-user" if disabled else "waiting-for-user",
|
|
38
|
+
"provider": provider_id,
|
|
39
|
+
"provider_name": provider_name,
|
|
40
|
+
"provider_kind": provider.get("kind"),
|
|
41
|
+
"intent": intent,
|
|
42
|
+
"agent_id": agent_id or (route or {}).get("agent_id"),
|
|
43
|
+
"capability_id": capability_id or (route or {}).get("capability_id"),
|
|
44
|
+
"reason": reason,
|
|
45
|
+
"resume_prompt": prompt,
|
|
46
|
+
"suggested_source_id": suggested_source_id if source_context else None,
|
|
47
|
+
"suggested_config": suggested_config(provider_id, suggested_project),
|
|
48
|
+
"next_question": next_question,
|
|
49
|
+
"questions": questions,
|
|
50
|
+
"credential_options": credential_options(provider),
|
|
51
|
+
"config_fields": public_config_fields(provider),
|
|
52
|
+
"auth_methods": public_auth_methods(provider),
|
|
53
|
+
"fallbacks": list(provider.get("fallbacks", []) or []),
|
|
54
|
+
"stores_secret": False,
|
|
55
|
+
"owner_agent": "provider-configurator",
|
|
56
|
+
"message": (
|
|
57
|
+
f"{provider_name} esta desativado. O agente seguira sem essa ferramenta ate voce reativar."
|
|
58
|
+
if disabled
|
|
59
|
+
else f"Para continuar, o agente precisa configurar ou autorizar {provider_name}."
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def provider_wizard_from_requirement(
|
|
65
|
+
root: Path,
|
|
66
|
+
provider_id: str,
|
|
67
|
+
*,
|
|
68
|
+
agent_id: str | None = None,
|
|
69
|
+
capability_id: str | None = None,
|
|
70
|
+
reason: str | None = None,
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
return provider_setup_wizard(
|
|
73
|
+
root,
|
|
74
|
+
provider_id,
|
|
75
|
+
agent_id=agent_id,
|
|
76
|
+
capability_id=capability_id,
|
|
77
|
+
reason=reason or "Capability provider requirement is not configured.",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_provider(root: Path, provider_id: str) -> dict[str, Any]:
|
|
82
|
+
try:
|
|
83
|
+
return provider_or_error(load_providers(root), provider_id)
|
|
84
|
+
except ProviderRegistryError:
|
|
85
|
+
return {
|
|
86
|
+
"id": provider_id,
|
|
87
|
+
"name": provider_id,
|
|
88
|
+
"kind": "unknown",
|
|
89
|
+
"config_fields": [],
|
|
90
|
+
"auth_methods": [],
|
|
91
|
+
"fallbacks": [],
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_questions(
|
|
96
|
+
provider: dict[str, Any],
|
|
97
|
+
*,
|
|
98
|
+
suggested_project: str | None,
|
|
99
|
+
suggested_source_id: str,
|
|
100
|
+
include_source: bool,
|
|
101
|
+
) -> list[dict[str, Any]]:
|
|
102
|
+
questions: list[dict[str, Any]] = []
|
|
103
|
+
for field in provider.get("config_fields", []) or []:
|
|
104
|
+
if not isinstance(field, dict):
|
|
105
|
+
continue
|
|
106
|
+
name = str(field.get("name") or "").strip()
|
|
107
|
+
if not name:
|
|
108
|
+
continue
|
|
109
|
+
suggested_value = suggested_value_for_field(provider, name, suggested_project)
|
|
110
|
+
questions.append(
|
|
111
|
+
{
|
|
112
|
+
"id": field_question_id(provider["id"], name),
|
|
113
|
+
"type": "text",
|
|
114
|
+
"text": question_text(provider, name, suggested_value=suggested_value),
|
|
115
|
+
"config_key": name,
|
|
116
|
+
"env": name,
|
|
117
|
+
"required": bool(field.get("required")),
|
|
118
|
+
"secret": bool(field.get("secret")),
|
|
119
|
+
"default": field.get("default"),
|
|
120
|
+
"suggested_value": suggested_value,
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
auth_methods = public_auth_methods(provider)
|
|
124
|
+
if auth_methods:
|
|
125
|
+
questions.append(
|
|
126
|
+
{
|
|
127
|
+
"id": f"{provider_slug(str(provider['id']))}_auth",
|
|
128
|
+
"type": "select",
|
|
129
|
+
"text": "Como deseja autenticar?",
|
|
130
|
+
"options": [method["id"] for method in auth_methods] + ["env", "file", "skip"],
|
|
131
|
+
"auth_methods": auth_methods,
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
if include_source:
|
|
135
|
+
questions.append(
|
|
136
|
+
{
|
|
137
|
+
"id": "source_id",
|
|
138
|
+
"type": "text",
|
|
139
|
+
"text": "Qual nome local deseja dar para esta fonte?",
|
|
140
|
+
"suggested_value": suggested_source_id,
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
questions.append(
|
|
144
|
+
{
|
|
145
|
+
"id": "default_for_intent",
|
|
146
|
+
"type": "confirm",
|
|
147
|
+
"text": "Posso usar esta fonte como padrao para este tipo de pedido?",
|
|
148
|
+
"default": True,
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
if not questions:
|
|
152
|
+
questions.append(
|
|
153
|
+
{
|
|
154
|
+
"id": f"{provider_slug(str(provider['id']))}_confirm",
|
|
155
|
+
"type": "confirm",
|
|
156
|
+
"text": f"Posso tentar usar {provider.get('name') or provider['id']} com a cadeia de credenciais nativa?",
|
|
157
|
+
"default": False,
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
return questions
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def opt_in_question(provider_id: str, provider_name: str) -> dict[str, Any]:
|
|
164
|
+
return {
|
|
165
|
+
"id": f"{provider_slug(provider_id)}_opt_in",
|
|
166
|
+
"type": "confirm",
|
|
167
|
+
"text": f"Posso configurar ou autorizar {provider_name} para atender este pedido?",
|
|
168
|
+
"default": False,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def disabled_notice(provider_id: str, provider_name: str) -> dict[str, Any]:
|
|
173
|
+
return {
|
|
174
|
+
"id": f"{provider_slug(provider_id)}_disabled",
|
|
175
|
+
"type": "notice",
|
|
176
|
+
"text": f"{provider_name} esta desativado por decisao do usuario. Reative para usar esta integracao.",
|
|
177
|
+
"default": False,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def public_config_fields(provider: dict[str, Any]) -> list[dict[str, Any]]:
|
|
182
|
+
fields = []
|
|
183
|
+
for field in provider.get("config_fields", []) or []:
|
|
184
|
+
if not isinstance(field, dict):
|
|
185
|
+
continue
|
|
186
|
+
fields.append(
|
|
187
|
+
{
|
|
188
|
+
"name": field.get("name"),
|
|
189
|
+
"required": bool(field.get("required")),
|
|
190
|
+
"secret": bool(field.get("secret")),
|
|
191
|
+
"default": field.get("default"),
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
return fields
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def public_auth_methods(provider: dict[str, Any]) -> list[dict[str, Any]]:
|
|
198
|
+
methods = []
|
|
199
|
+
for method in provider.get("auth_methods", []) or []:
|
|
200
|
+
if not isinstance(method, dict):
|
|
201
|
+
continue
|
|
202
|
+
methods.append(
|
|
203
|
+
{
|
|
204
|
+
"id": method.get("id"),
|
|
205
|
+
"label": method.get("label") or method.get("id"),
|
|
206
|
+
"native": bool(method.get("native")),
|
|
207
|
+
"config_fields": list(method.get("config_fields", []) or []),
|
|
208
|
+
"secret_fields": list(method.get("secret_fields", []) or []),
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
return methods
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def credential_options(provider: dict[str, Any]) -> list[dict[str, Any]]:
|
|
215
|
+
options = []
|
|
216
|
+
for method in public_auth_methods(provider):
|
|
217
|
+
for secret_field in method.get("secret_fields") or []:
|
|
218
|
+
options.append(
|
|
219
|
+
{
|
|
220
|
+
"id": f"env:{secret_field}",
|
|
221
|
+
"label": f"Variavel de ambiente {secret_field}",
|
|
222
|
+
"env": secret_field,
|
|
223
|
+
"auth_method": method.get("id"),
|
|
224
|
+
"stores_secret": False,
|
|
225
|
+
}
|
|
226
|
+
)
|
|
227
|
+
options.extend(
|
|
228
|
+
[
|
|
229
|
+
{"id": "file", "label": "Arquivo local de credencial", "stores_secret": False},
|
|
230
|
+
{"id": "native", "label": "Cadeia nativa ou CLI ja autenticada", "stores_secret": False},
|
|
231
|
+
{"id": "skip", "label": "Ignorar esta ferramenta", "stores_secret": False},
|
|
232
|
+
]
|
|
233
|
+
)
|
|
234
|
+
seen: set[str] = set()
|
|
235
|
+
unique = []
|
|
236
|
+
for option in options:
|
|
237
|
+
option_id = str(option.get("id"))
|
|
238
|
+
if option_id not in seen:
|
|
239
|
+
seen.add(option_id)
|
|
240
|
+
unique.append(option)
|
|
241
|
+
return unique
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def suggested_config(provider_id: str, suggested_project: str | None) -> dict[str, Any]:
|
|
245
|
+
config: dict[str, Any] = {}
|
|
246
|
+
if provider_id == "azure-devops" and suggested_project:
|
|
247
|
+
config["project"] = suggested_project
|
|
248
|
+
config["AZURE_DEVOPS_PROJECT"] = suggested_project
|
|
249
|
+
return config
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def suggested_value_for_field(provider: dict[str, Any], name: str, suggested_project: str | None) -> str | None:
|
|
253
|
+
if provider.get("id") == "azure-devops" and name == "AZURE_DEVOPS_PROJECT" and suggested_project:
|
|
254
|
+
return suggested_project
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def question_text(provider: dict[str, Any], name: str, *, suggested_value: str | None) -> str:
|
|
259
|
+
if provider.get("id") == "azure-devops" and name == "AZURE_DEVOPS_ORG":
|
|
260
|
+
return "Qual e a organizacao do Azure DevOps?"
|
|
261
|
+
if provider.get("id") == "azure-devops" and name == "AZURE_DEVOPS_PROJECT":
|
|
262
|
+
if suggested_value:
|
|
263
|
+
return f'O projeto e "{suggested_value}"? Se nao, informe o nome correto.'
|
|
264
|
+
return "Qual e o nome do projeto no Azure DevOps?"
|
|
265
|
+
label = name.replace("_", " ").lower()
|
|
266
|
+
return f"Informe {label} para {provider.get('name') or provider.get('id')}."
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def field_question_id(provider_id: str, name: str) -> str:
|
|
270
|
+
return f"{provider_slug(provider_id)}_{provider_slug(name)}"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def infer_project(prompt: str) -> str | None:
|
|
274
|
+
match = PROJECT_PATTERN.search(prompt)
|
|
275
|
+
if not match:
|
|
276
|
+
return None
|
|
277
|
+
project = " ".join(match.group(1).strip(" .,_-").split())
|
|
278
|
+
return project or None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def suggest_source_id(provider: str, project: str | None) -> str:
|
|
282
|
+
parts = [provider]
|
|
283
|
+
if project:
|
|
284
|
+
parts.append(project)
|
|
285
|
+
raw = "-".join(parts)
|
|
286
|
+
normalized = re.sub(r"[^a-z0-9._-]+", "-", raw.lower()).strip("-")
|
|
287
|
+
return normalized or provider_slug(provider)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def provider_slug(provider: str) -> str:
|
|
291
|
+
return re.sub(r"[^a-z0-9]+", "_", provider.lower()).strip("_") or "provider"
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Persistent local decisions for tools, integrations, skills and LLMs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from cli.aikit.app_home import app_path, ensure_app_home
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DECISION_VERSION = 1
|
|
15
|
+
VALID_CATEGORIES = {"tools", "integrations", "skills", "llms"}
|
|
16
|
+
VALID_STATES = {"enabled", "disabled_by_user", "denied_by_user", "needs_setup", "unavailable", "available", "requires_permission"}
|
|
17
|
+
ITEM_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def decisions_path() -> Path:
|
|
21
|
+
ensure_app_home()
|
|
22
|
+
path = app_path("config", "decisions.json")
|
|
23
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
return path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def empty_decisions() -> dict[str, Any]:
|
|
28
|
+
return {"version": DECISION_VERSION, "items": {}}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_decisions() -> dict[str, Any]:
|
|
32
|
+
path = decisions_path()
|
|
33
|
+
if not path.exists():
|
|
34
|
+
return empty_decisions()
|
|
35
|
+
try:
|
|
36
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
37
|
+
except (OSError, json.JSONDecodeError):
|
|
38
|
+
return empty_decisions()
|
|
39
|
+
if not isinstance(data, dict):
|
|
40
|
+
return empty_decisions()
|
|
41
|
+
data.setdefault("version", DECISION_VERSION)
|
|
42
|
+
if not isinstance(data.get("items"), dict):
|
|
43
|
+
data["items"] = {}
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def save_decisions(data: dict[str, Any]) -> Path:
|
|
48
|
+
path = decisions_path()
|
|
49
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
50
|
+
return path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def list_decisions(category: str | None = None) -> dict[str, Any]:
|
|
54
|
+
data = load_decisions()
|
|
55
|
+
items = [public_decision(item) for item in data.get("items", {}).values() if not category or item.get("category") == category]
|
|
56
|
+
items.sort(key=lambda item: (item["category"], item["id"]))
|
|
57
|
+
return {
|
|
58
|
+
"kind": "decisions",
|
|
59
|
+
"status": "ok",
|
|
60
|
+
"category": category,
|
|
61
|
+
"path": str(decisions_path()),
|
|
62
|
+
"items": items,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def set_decision(
|
|
67
|
+
category: str,
|
|
68
|
+
item_id: str,
|
|
69
|
+
state: str,
|
|
70
|
+
*,
|
|
71
|
+
reason: str | None = None,
|
|
72
|
+
scope: str = "persistent",
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
validate_category(category)
|
|
75
|
+
validate_item_id(item_id)
|
|
76
|
+
if state not in VALID_STATES:
|
|
77
|
+
raise ValueError(f"invalid decision state: {state}")
|
|
78
|
+
data = load_decisions()
|
|
79
|
+
key = decision_key(category, item_id)
|
|
80
|
+
item = {
|
|
81
|
+
"key": key,
|
|
82
|
+
"category": category,
|
|
83
|
+
"id": item_id,
|
|
84
|
+
"state": state,
|
|
85
|
+
"scope": scope,
|
|
86
|
+
"reason": reason,
|
|
87
|
+
"updated_at": now_iso(),
|
|
88
|
+
}
|
|
89
|
+
existing = data["items"].get(key)
|
|
90
|
+
if isinstance(existing, dict) and existing.get("created_at"):
|
|
91
|
+
item["created_at"] = existing["created_at"]
|
|
92
|
+
else:
|
|
93
|
+
item["created_at"] = item["updated_at"]
|
|
94
|
+
data["items"][key] = item
|
|
95
|
+
path = save_decisions(data)
|
|
96
|
+
return {
|
|
97
|
+
"kind": "decision",
|
|
98
|
+
"status": "updated",
|
|
99
|
+
"path": str(path),
|
|
100
|
+
"item": public_decision(item),
|
|
101
|
+
"category": category,
|
|
102
|
+
"id": item_id,
|
|
103
|
+
"state": state,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_decision(category: str, item_id: str) -> dict[str, Any] | None:
|
|
108
|
+
validate_category(category)
|
|
109
|
+
validate_item_id(item_id)
|
|
110
|
+
item = load_decisions().get("items", {}).get(decision_key(category, item_id))
|
|
111
|
+
return public_decision(item) if isinstance(item, dict) else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def reset_decisions(category: str | None = None) -> dict[str, Any]:
|
|
115
|
+
data = load_decisions()
|
|
116
|
+
if category:
|
|
117
|
+
validate_category(category)
|
|
118
|
+
data["items"] = {key: value for key, value in data.get("items", {}).items() if value.get("category") != category}
|
|
119
|
+
else:
|
|
120
|
+
data["items"] = {}
|
|
121
|
+
path = save_decisions(data)
|
|
122
|
+
return {"kind": "decisions-reset", "status": "reset", "category": category, "path": str(path)}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_disabled(category: str, item_id: str) -> bool:
|
|
126
|
+
decision = get_decision(category, item_id)
|
|
127
|
+
return bool(decision and decision.get("state") in {"disabled_by_user", "denied_by_user"})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def public_decision(item: dict[str, Any]) -> dict[str, Any]:
|
|
131
|
+
return {
|
|
132
|
+
"category": item.get("category"),
|
|
133
|
+
"id": item.get("id"),
|
|
134
|
+
"state": item.get("state"),
|
|
135
|
+
"scope": item.get("scope"),
|
|
136
|
+
"reason": item.get("reason"),
|
|
137
|
+
"created_at": item.get("created_at"),
|
|
138
|
+
"updated_at": item.get("updated_at"),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def decision_key(category: str, item_id: str) -> str:
|
|
143
|
+
return f"{category}:{item_id}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def validate_category(category: str) -> None:
|
|
147
|
+
if category not in VALID_CATEGORIES:
|
|
148
|
+
available = ", ".join(sorted(VALID_CATEGORIES))
|
|
149
|
+
raise ValueError(f"invalid decision category: {category}. available: {available}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def validate_item_id(item_id: str) -> None:
|
|
153
|
+
if not item_id or not ITEM_ID_PATTERN.fullmatch(item_id):
|
|
154
|
+
raise ValueError("item id must use letters, numbers, dots, dashes, underscores or colons")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def now_iso() -> str:
|
|
158
|
+
return datetime.now(timezone.utc).isoformat()
|