Skip to content

Commit 82d91ff

Browse files
authored
feat(context): context hooks with /context hooks (#1218)
Follow up to: #1176 RFC: #1050 - Add new command `/context hooks` - subcommands to add/remove and enable/disable hooks - Context hooks are called then added to the beginning of the chat history as context, or to each prompt, depending on what is chosen.
1 parent 2d15346 commit 82d91ff

File tree

3 files changed

+545
-34
lines changed

3 files changed

+545
-34
lines changed

crates/q_cli/src/cli/chat/command.rs

Lines changed: 209 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
use std::io::Write;
22

3+
use clap::{
4+
Parser,
5+
Subcommand,
6+
};
37
use crossterm::style::Color;
48
use crossterm::{
59
queue,
@@ -87,6 +91,57 @@ Profiles allow you to organize and manage different sets of context files for di
8791
}
8892
}
8993

94+
#[derive(Parser, Debug, Clone)]
95+
#[command(name = "hooks", disable_help_flag = true, disable_help_subcommand = true)]
96+
struct HooksCommand {
97+
#[command(subcommand)]
98+
command: HooksSubcommand,
99+
}
100+
101+
#[derive(Subcommand, Debug, Clone, Eq, PartialEq)]
102+
pub enum HooksSubcommand {
103+
Add {
104+
name: String,
105+
106+
#[arg(long, value_parser = ["per_prompt", "conversation_start"])]
107+
r#type: String,
108+
109+
#[arg(long, value_parser = clap::value_parser!(String))]
110+
command: String,
111+
112+
#[arg(long)]
113+
global: bool,
114+
},
115+
#[command(name = "rm")]
116+
Remove {
117+
name: String,
118+
119+
#[arg(long)]
120+
global: bool,
121+
},
122+
Enable {
123+
name: String,
124+
125+
#[arg(long)]
126+
global: bool,
127+
},
128+
Disable {
129+
name: String,
130+
131+
#[arg(long)]
132+
global: bool,
133+
},
134+
EnableAll {
135+
#[arg(long)]
136+
global: bool,
137+
},
138+
DisableAll {
139+
#[arg(long)]
140+
global: bool,
141+
},
142+
Help,
143+
}
144+
90145
#[derive(Debug, Clone, PartialEq, Eq)]
91146
pub enum ContextSubcommand {
92147
Show {
@@ -104,6 +159,9 @@ pub enum ContextSubcommand {
104159
Clear {
105160
global: bool,
106161
},
162+
Hooks {
163+
subcommand: Option<HooksSubcommand>,
164+
},
107165
Help,
108166
}
109167

@@ -124,15 +182,43 @@ impl ContextSubcommand {
124182
<black!>--global: Remove specified rules globally</black!>
125183
126184
<em>clear [--global]</em> <black!>Remove all rules from current profile</black!>
127-
<black!>--global: Remove global rules</black!>"};
185+
<black!>--global: Remove global rules</black!>
186+
187+
<em>hooks</em> <black!>View and manage context hooks</black!>"};
128188
const CLEAR_USAGE: &str = "/context clear [--global]";
189+
const HOOKS_AVAILABLE_COMMANDS: &str = color_print::cstr! {"<cyan!>Available subcommands</cyan!>
190+
<em>hooks help</em> <black!>Show an explanation for context hooks commands</black!>
191+
192+
<em>hooks add [--global] <<name>></em> <black!>Add a new command context hook</black!>
193+
<black!>--global: Add to global hooks</black!>
194+
<em>--type <<type>></em> <black!>Type of hook, valid options: `per_prompt` or `conversation_start`</black!>
195+
<em>--command <<command>></em> <black!>Shell command to execute</black!>
196+
197+
<em>hooks rm [--global] <<name>></em> <black!>Remove an existing context hook</black!>
198+
<black!>--global: Remove from global hooks</black!>
199+
200+
<em>hooks enable [--global] <<name>></em> <black!>Enable an existing context hook</black!>
201+
<black!>--global: Enable in global hooks</black!>
202+
203+
<em>hooks disable [--global] <<name>></em> <black!>Disable an existing context hook</black!>
204+
<black!>--global: Disable in global hooks</black!>
205+
206+
<em>hooks enable-all [--global]</em> <black!>Enable all existing context hooks</black!>
207+
<black!>--global: Enable all in global hooks</black!>
208+
209+
<em>hooks disable-all [--global]</em> <black!>Disable all existing context hooks</black!>
210+
<black!>--global: Disable all in global hooks</black!>"};
129211
const REMOVE_USAGE: &str = "/context rm [--global] <path1> [path2...]";
130212
const SHOW_USAGE: &str = "/context show [--expand]";
131213

132214
fn usage_msg(header: impl AsRef<str>) -> String {
133215
format!("{}\n\n{}", header.as_ref(), Self::AVAILABLE_COMMANDS)
134216
}
135217

218+
fn hooks_usage_msg(header: impl AsRef<str>) -> String {
219+
format!("{}\n\n{}", header.as_ref(), Self::HOOKS_AVAILABLE_COMMANDS)
220+
}
221+
136222
pub fn help_text() -> String {
137223
color_print::cformat!(
138224
r#"
@@ -143,6 +229,9 @@ The files matched by these rules provide Amazon Q with additional information
143229
about your project or environment. Adding relevant files helps Q generate
144230
more accurate and helpful responses.
145231
232+
In addition to files, you can specify hooks that will run commands and return
233+
the output as context to Amazon Q.
234+
146235
{}
147236
148237
<cyan!>Notes</cyan!>
@@ -154,6 +243,32 @@ more accurate and helpful responses.
154243
Self::AVAILABLE_COMMANDS
155244
)
156245
}
246+
247+
pub fn hooks_help_text() -> String {
248+
color_print::cformat!(
249+
r#"
250+
<magenta,em>(Beta) Context Hooks</magenta,em>
251+
252+
Use context hooks to specify shell commands to run. The output from these
253+
commands will be appended to the prompt to Amazon Q. Hooks can be defined
254+
in global or local profiles.
255+
256+
<cyan!>Usage: /context hooks [SUBCOMMAND]</cyan!>
257+
258+
<cyan!>Description</cyan!>
259+
Show existing global or profile-specific hooks.
260+
Alternatively, specify a subcommand to modify the hooks.
261+
262+
{}
263+
264+
<cyan!>Notes</cyan!>
265+
• Hooks are executed in parallel
266+
• 'conversation_start' hooks run on the first user prompt and are attached once to the conversation history sent to Amazon Q
267+
• 'per_prompt' hooks run on each user prompt and are attached to the prompt
268+
"#,
269+
Self::HOOKS_AVAILABLE_COMMANDS
270+
)
271+
}
157272
}
158273

159274
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -495,6 +610,18 @@ impl Command {
495610
"help" => Self::Context {
496611
subcommand: ContextSubcommand::Help,
497612
},
613+
"hooks" => {
614+
if parts.get(2).is_none() {
615+
return Ok(Self::Context {
616+
subcommand: ContextSubcommand::Hooks { subcommand: None },
617+
});
618+
};
619+
620+
match Self::parse_hooks(&parts) {
621+
Ok(command) => command,
622+
Err(err) => return Err(ContextSubcommand::hooks_usage_msg(err)),
623+
}
624+
},
498625
other => {
499626
return Err(ContextSubcommand::usage_msg(format!("Unknown subcommand '{}'.", other)));
500627
},
@@ -579,6 +706,27 @@ impl Command {
579706
prompt: input.to_string(),
580707
})
581708
}
709+
710+
// NOTE: Here we use clap to parse the hooks subcommand instead of parsing manually
711+
// like the rest of the file.
712+
// Since the hooks subcommand has a lot of options, this makes more sense.
713+
// Ideally, we parse everything with clap instead of trying to do it manually.
714+
fn parse_hooks(parts: &[&str]) -> Result<Self, String> {
715+
// Skip the first two parts ("/context" and "hooks")
716+
let args = match shlex::split(&parts[1..].join(" ")) {
717+
Some(args) => args,
718+
None => return Err("Failed to parse arguments".to_string()),
719+
};
720+
721+
// Parse with Clap
722+
HooksCommand::try_parse_from(args)
723+
.map(|hooks_command| Self::Context {
724+
subcommand: ContextSubcommand::Hooks {
725+
subcommand: Some(hooks_command.command),
726+
},
727+
})
728+
.map_err(|e| e.to_string())
729+
}
582730
}
583731

584732
#[cfg(test)]
@@ -700,6 +848,66 @@ mod tests {
700848
("/issue \"there was an error in the chat\"", Command::Issue {
701849
prompt: Some("\"there was an error in the chat\"".to_string()),
702850
}),
851+
(
852+
"/context hooks",
853+
context!(ContextSubcommand::Hooks { subcommand: None }),
854+
),
855+
(
856+
"/context hooks add test --type per_prompt --command 'echo 1' --global",
857+
context!(ContextSubcommand::Hooks {
858+
subcommand: Some(HooksSubcommand::Add {
859+
name: "test".to_string(),
860+
global: true,
861+
r#type: "per_prompt".to_string(),
862+
command: "echo 1".to_string()
863+
})
864+
}),
865+
),
866+
(
867+
"/context hooks rm test --global",
868+
context!(ContextSubcommand::Hooks {
869+
subcommand: Some(HooksSubcommand::Remove {
870+
name: "test".to_string(),
871+
global: true
872+
})
873+
}),
874+
),
875+
(
876+
"/context hooks enable test --global",
877+
context!(ContextSubcommand::Hooks {
878+
subcommand: Some(HooksSubcommand::Enable {
879+
name: "test".to_string(),
880+
global: true
881+
})
882+
}),
883+
),
884+
(
885+
"/context hooks disable test",
886+
context!(ContextSubcommand::Hooks {
887+
subcommand: Some(HooksSubcommand::Disable {
888+
name: "test".to_string(),
889+
global: false
890+
})
891+
}),
892+
),
893+
(
894+
"/context hooks enable-all --global",
895+
context!(ContextSubcommand::Hooks {
896+
subcommand: Some(HooksSubcommand::EnableAll { global: true })
897+
}),
898+
),
899+
(
900+
"/context hooks disable-all",
901+
context!(ContextSubcommand::Hooks {
902+
subcommand: Some(HooksSubcommand::DisableAll { global: false })
903+
}),
904+
),
905+
(
906+
"/context hooks help",
907+
context!(ContextSubcommand::Hooks {
908+
subcommand: Some(HooksSubcommand::Help)
909+
}),
910+
),
703911
];
704912

705913
for (input, parsed) in tests {

0 commit comments

Comments
 (0)